From a264de0e21bda7351bb2ef635b29e926771b724f Mon Sep 17 00:00:00 2001 From: Landyn Date: Mon, 24 Aug 2026 18:58:39 -0500 Subject: [PATCH 01/15] Register sn7 and sn74 alpha assets in the TAO backing family --- allways/chains.py | 36 ++++++++++++++++++++++++++++++++++++ allways/constants.py | 12 ++++++++++++ 2 files changed, 48 insertions(+) diff --git a/allways/chains.py b/allways/chains.py index b263c0e2..262ac2fe 100644 --- a/allways/chains.py +++ b/allways/chains.py @@ -51,6 +51,11 @@ class ChainDefinition: # on a given amount (PAXG's getFeeFor). Set only on tokens with an admin-settable fee; a live # non-zero fee shaves every delivery below the pinned amount, so it's a no-fault cancel (V-M2). fee_check: str | None = None + # The subnet an alpha token belongs to. None for every non-alpha asset. + netuid: int | None = None + # The backing family this asset settles penalties in ('tao' for an sn alpha). The program + # derives the same fact from the id prefix, so only 'tao' rows may carry an sn id. + backing_family: str | None = None # ─── Supported Chains ──────────────────────────────────── @@ -514,6 +519,35 @@ class ChainDefinition: fee_check='getFeeFor(uint256)', ) +CHAIN_SN7 = ChainDefinition( + id='sn7', + name='Subnet 7 Alpha', + native_unit='rao', + decimals=9, + # Bittensor's prefix, shared with CHAIN_TAO: one TAO_* config serves every subtensor asset. + env_prefix='TAO', + # CHAIN_TAO's clock and reorg depth, deliberately identical — three assets on one chain must + # not disagree about either. + seconds_per_block=12, + min_confirmations=6, + # 1.0 alpha: a rate-sanity floor that can only over-restrict, never slash. + min_onchain_amount=1_000_000_000, + netuid=7, + backing_family='tao', +) +CHAIN_SN74 = ChainDefinition( + id='sn74', + name='Subnet 74 Alpha', + native_unit='rao', + decimals=9, + env_prefix='TAO', + seconds_per_block=12, + min_confirmations=6, + min_onchain_amount=1_000_000_000, + netuid=74, + backing_family='tao', +) + SUPPORTED_CHAINS = { 'btc': CHAIN_BTC, 'tao': CHAIN_TAO, @@ -533,6 +567,8 @@ class ChainDefinition: 'polusdc': CHAIN_POLUSDC, 'paxg': CHAIN_PAXG, 'solusdc': CHAIN_SOLUSDC, + 'sn7': CHAIN_SN7, + 'sn74': CHAIN_SN74, } diff --git a/allways/constants.py b/allways/constants.py index 505dad2d..01c8a4e8 100644 --- a/allways/constants.py +++ b/allways/constants.py @@ -1,3 +1,5 @@ +import re + from allways.classes import MinerActivity # ─── Network ─────────────────────────────────────────────── @@ -92,6 +94,11 @@ NUMERAIRE_CHAIN = 'sol' +def family(chain: str) -> str: + """The backing family a chain settles in (twin of ``backing.rs::family``): an sn alpha settles in TAO.""" + return 'tao' if re.fullmatch(r'sn\d+', chain) else chain + + def is_hub(chain: str) -> bool: """True iff ``chain`` can anchor a pair (and back quotes with its own collateral purse).""" return chain in HUB_CHAINS @@ -131,6 +138,11 @@ def declarable_backings(from_chain: str, to_chain: str) -> list[str]: 'paxg', 'solusdc', ) +# Alpha tokens paired against each hub; add a subnet here to launch its pairs. +LAUNCH_ALPHAS = ( + 'sn7', + 'sn74', +) # Every launch pair as (hub, spoke): each hub pairs against every spoke except itself. sol↔tao # lands exactly once (under SOL, its anchor) because sol never appears in LAUNCH_SPOKES. LAUNCH_PAIRS: tuple[tuple[str, str], ...] = tuple( From 28d68af76105f7a0ed3b89a16b809f4be6f628d1 Mon Sep 17 00:00:00 2001 From: Landyn Date: Mon, 24 Aug 2026 19:10:23 -0500 Subject: [PATCH 02/15] Anchor and scope pairs by backing family --- allways/chains.py | 16 +++++++--------- allways/constants.py | 17 ++++++++++------- tests/test_chains.py | 24 +++++++++++++++++++++++- tests/test_tao_hub_pairs.py | 11 +++++++++++ 4 files changed, 51 insertions(+), 17 deletions(-) diff --git a/allways/chains.py b/allways/chains.py index 262ac2fe..7991c9e4 100644 --- a/allways/chains.py +++ b/allways/chains.py @@ -5,7 +5,7 @@ from allways.constants import ( EXTENSION_BUCKET_SECONDS, EXTENSION_PADDING_SECONDS, - HUB_CHAINS, + hub_leg, ) @@ -609,16 +609,14 @@ def canonical_pair(chain_a: str, chain_b: str) -> tuple: Determines the rate unit: rate is always 'dest per 1 source' in this ordering. Ordering rules (priority): - 1. The pair's hub leg is always the canonical SOURCE, so every launch pair reads uniformly as - 'dest per 1 hub' (e.g. TAO per SOL, ETH per TAO). ``HUB_CHAINS`` order breaks a hub↔hub - pair: sol↔tao stays SOL-anchored (grandfathered — stored quotes keep their convention). + 1. The pair's ``hub_leg`` is the canonical SOURCE, so every pair reads 'dest per 1 anchor': + the literal hub (TAO per SOL, ETH per TAO; ``HUB_CHAINS`` order keeps sol↔tao SOL-anchored), + else the family-bearing leg (AVAX per SN7). 2. Else alphabetical — deterministic fallback for spoke↔spoke (never a valid swap pair). """ - for hub in HUB_CHAINS: - if chain_a == hub: - return (chain_a, chain_b) - if chain_b == hub: - return (chain_b, chain_a) + anchor = hub_leg(chain_a, chain_b) + if anchor: + return (anchor, chain_b if anchor == chain_a else chain_a) return (chain_a, chain_b) if chain_a < chain_b else (chain_b, chain_a) diff --git a/allways/constants.py b/allways/constants.py index 01c8a4e8..36b5f500 100644 --- a/allways/constants.py +++ b/allways/constants.py @@ -105,17 +105,19 @@ def is_hub(chain: str) -> bool: def hub_leg(from_chain: str, to_chain: str) -> str | None: - """The pair's hub anchor — its pricing/bounds leg. None for a spoke↔spoke pair (invalid).""" + """The pair's anchor — its pricing leg and scoring family: the literal hub if one is a leg, else the + alphabetically first family-bearing leg (an alpha is its own scoring family). None = invalid pair.""" for hub in HUB_CHAINS: if hub in (from_chain, to_chain): return hub - return None + family_legs = sorted(chain for chain in (from_chain, to_chain) if family(chain) != chain) + return family_legs[0] if family_legs else None def declarable_backings(from_chain: str, to_chain: str) -> list[str]: """The pair's hub-capable legs = the backings a quote may declare = its scoring lanes (F4): - two on the hub↔hub pair (sol↔tao), one on a spoke pair, none on a spoke↔spoke pair (invalid).""" - return [hub for hub in HUB_CHAINS if hub in (from_chain, to_chain)] + the hubs among the legs' families — two on sol↔tao, one on a spoke or alpha pair, none if invalid.""" + return [hub for hub in HUB_CHAINS if hub in {family(from_chain), family(to_chain)}] # Chains paired against each hub; add a chain here to launch its pairs. @@ -143,11 +145,12 @@ def declarable_backings(from_chain: str, to_chain: str) -> list[str]: 'sn7', 'sn74', ) -# Every launch pair as (hub, spoke): each hub pairs against every spoke except itself. sol↔tao -# lands exactly once (under SOL, its anchor) because sol never appears in LAUNCH_SPOKES. +# Every launch pair in canonical order: each hub against every spoke and alpha (sol↔tao lands once, +# under SOL, because sol never appears in LAUNCH_SPOKES). Alpha↔spoke pairs are gated on the +# emissions redesign and deliberately absent. LAUNCH_PAIRS: tuple[tuple[str, str], ...] = tuple( (hub, spoke) for hub in HUB_CHAINS for spoke in LAUNCH_SPOKES if spoke != hub -) +) + tuple((hub, alpha) for hub in HUB_CHAINS for alpha in LAUNCH_ALPHAS) # Fixed burn: pools sum to MINER_POOL_SHARE instead of 1.0, so at least # BURN_RATE of every round recycles to RECYCLE_UID before any shortfall. BURN_RATE = 0.90 diff --git a/tests/test_chains.py b/tests/test_chains.py index 4e3e5037..f55ba6ca 100644 --- a/tests/test_chains.py +++ b/tests/test_chains.py @@ -21,6 +21,8 @@ CHAIN_POL, CHAIN_POLUSDC, CHAIN_QNT, + CHAIN_SN7, + CHAIN_SN74, CHAIN_SOL, CHAIN_SOLUSDC, CHAIN_TAO, @@ -31,6 +33,7 @@ compute_extension_target_secs, get_chain_def, ) +from allways.constants import family class TestGetChain: @@ -94,6 +97,19 @@ def test_ids_are_lowercase_and_fit_the_wire(self): assert re.fullmatch(r'[a-z0-9]{1,10}', chain_id), chain_id assert chain.id == chain_id + def test_subnet_prefix_matches_tao_backing_family(self): + for chain in SUPPORTED_CHAINS.values(): + assert bool(re.fullmatch(r'sn\d+', chain.id)) is (chain.backing_family == 'tao'), chain.id + assert family(chain.id) == (chain.backing_family or chain.id), chain.id + + def test_subnet_assets_share_tao_clock(self): + for chain in (CHAIN_SN7, CHAIN_SN74): + assert (chain.seconds_per_block, chain.min_confirmations, chain.decimals) == ( + CHAIN_TAO.seconds_per_block, + CHAIN_TAO.min_confirmations, + CHAIN_TAO.decimals, + ) + def test_assets_on_one_network_share_its_env_identity(self): """A network is configured once, by EXACTLY ONE of its rows. Rows sharing a host_chain MUST share env_prefix, and exactly one of them declares ``networks`` — no more, because @@ -136,7 +152,7 @@ def test_only_self_hosted_assets_lack_a_host_chain(self): EVM_NETWORKS key, or 'solana' for an SPL token beside native SOL. Only the self-hosted list is enumerated — a new hosted asset needs no edit here.""" for chain_id, chain in SUPPORTED_CHAINS.items(): - if chain_id in ('btc', 'tao', 'sol'): + if chain_id in ('btc', 'tao', 'sol') or chain.backing_family: assert chain.host_chain is None, chain_id else: assert chain.host_chain in EVM_NETWORKS or chain.host_chain == 'solana', chain_id @@ -192,6 +208,12 @@ def test_sol_always_source(self): assert canonical_pair('sol', 'eth') == ('sol', 'eth') assert canonical_pair('eth', 'sol') == ('sol', 'eth') + def test_family_leg_anchors_without_overriding_literal_hubs(self): + assert canonical_pair('sn7', 'sol') == ('sol', 'sn7') + assert canonical_pair('sn7', 'tao') == ('tao', 'sn7') + assert canonical_pair('avax', 'sn7') == ('sn7', 'avax') + assert canonical_pair('sn74', 'sn7') == ('sn7', 'sn74') + class TestComputeExtensionTargetSecs: # Unix-seconds target = now + max(0, min_confirmations - confs) * seconds_per_block + 120s padding, diff --git a/tests/test_tao_hub_pairs.py b/tests/test_tao_hub_pairs.py index 8c55395b..ce1a255f 100644 --- a/tests/test_tao_hub_pairs.py +++ b/tests/test_tao_hub_pairs.py @@ -31,9 +31,11 @@ from allways.constants import ( DIRECTION_POOLS, HUB_CHAINS, + LAUNCH_ALPHAS, LAUNCH_PAIRS, MINER_POOL_SHARE, RATE_PRECISION, + declarable_backings, hub_leg, is_hub, ) @@ -58,6 +60,13 @@ def test_hub_leg_anchors(self): # hub↔hub: HUB_CHAINS order wins — sol↔tao stays SOL-anchored (grandfathered). assert hub_leg('tao', 'sol') == 'sol' assert hub_leg('btc', 'eth') is None + assert hub_leg('sn7', 'avax') == 'sn7' + assert hub_leg('sn7', 'sn74') == 'sn7' + + def test_declarable_backings_follow_leg_families(self): + assert declarable_backings('sn7', 'avax') == ['tao'] + assert declarable_backings('sol', 'sn7') == ['sol', 'tao'] + assert declarable_backings('sn7', 'sn74') == ['tao'] def test_hub_leg_is_the_canonical_source(self): for a, b in (('tao', 'eth'), ('eth', 'tao'), ('sol', 'tao'), ('btc', 'sol')): @@ -69,6 +78,8 @@ def test_launch_pairs_cover_every_hub_spoke_once(self): assert ('tao', 'eth') in LAUNCH_PAIRS and ('tao', 'btc') in LAUNCH_PAIRS assert len(LAUNCH_PAIRS) == len(set(LAUNCH_PAIRS)) assert all(hub in HUB_CHAINS and spoke != hub for hub, spoke in LAUNCH_PAIRS) + assert all(pair == canonical_pair(*pair) for pair in LAUNCH_PAIRS) + assert all((hub, alpha) in LAUNCH_PAIRS for hub in HUB_CHAINS for alpha in LAUNCH_ALPHAS) def test_direction_pools_span_both_families_and_conserve(self): assert len(DIRECTION_POOLS) == 2 * len(LAUNCH_PAIRS) From ac35efea138f55f5fbb8af42ff32a50fb50bd7f6 Mon Sep 17 00:00:00 2001 From: Landyn Date: Mon, 24 Aug 2026 19:10:23 -0500 Subject: [PATCH 03/15] Derive alpha price flags for miner quotes from LAUNCH_ALPHAS --- allways/cli/swap_commands/numeraire.py | 39 ++++++++++++++------------ tests/test_numeraire.py | 37 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/allways/cli/swap_commands/numeraire.py b/allways/cli/swap_commands/numeraire.py index 0d6f72e9..0b648fb2 100644 --- a/allways/cli/swap_commands/numeraire.py +++ b/allways/cli/swap_commands/numeraire.py @@ -33,7 +33,7 @@ safe_read, ) from allways.cli.swap_commands.pair import write_rate_posted_flag -from allways.constants import HUB_CHAINS, LAUNCH_SPOKES, NUMERAIRE_CHAIN, RATE_PRECISION +from allways.constants import HUB_CHAINS, LAUNCH_ALPHAS, LAUNCH_SPOKES, NUMERAIRE_CHAIN, RATE_PRECISION, family from allways.solana.client import SolanaClientError from allways.utils.rate import quantize_rate_display, quantize_rate_fixed @@ -74,18 +74,19 @@ def _addr_kw(chain: str) -> str: def quote_options(f): - """Attach the SOL-address flag plus a ``---price`` / ``---address`` pair for every - launch spoke. Registry-derived from ``LAUNCH_SPOKES`` — add a spoke there and its flags appear - here automatically, with no hand-typed per-chain options. Every flag stays explicit, so posting - quotes is fully scriptable (``--yes`` skips the confirm). Under ``--hub tao`` the prices read - 'X per 1 TAO' and ``--tao-address`` is the hub leg.""" - for spoke in reversed(LAUNCH_SPOKES): # reversed: decorators stack bottom-up, so this restores registry order - f = click.option(f'--{spoke}-address', default=None, help=f'Your {spoke.upper()} address.')(f) + """Attach the SOL-address flag plus a ``---price`` for every launch spoke and alpha, and a + ``---address`` for every spoke (an alpha is delivered to the TAO address). Registry-derived + from ``LAUNCH_SPOKES`` / ``LAUNCH_ALPHAS`` — add a chain there and its flags appear here + automatically. Every flag stays explicit, so posting quotes is fully scriptable (``--yes`` skips + the confirm). Under ``--hub tao`` the prices read 'X per 1 TAO' and ``--tao-address`` is the hub leg.""" + for chain in reversed(LAUNCH_SPOKES + LAUNCH_ALPHAS): # reversed: decorators stack bottom-up + if chain in LAUNCH_SPOKES: + f = click.option(f'--{chain}-address', default=None, help=f'Your {chain.upper()} address.')(f) f = click.option( - f'--{spoke}-price', + f'--{chain}-price', type=FINITE_FLOAT, default=None, - help=f'{spoke.upper()} per 1 hub unit (0/omit to skip {spoke.upper()}).', + help=f'{chain.upper()} per 1 hub unit (0/omit to skip {chain.upper()}).', )(f) return click.option( f'--{NUMERAIRE_CHAIN}-address', @@ -98,6 +99,7 @@ def quote_options(f): def _example() -> str: """A concrete, copy-pasteable usage line built from the current registry (not hand-typed).""" flags = ' '.join(f'--{s}-price <{s}-per-hub> --{s}-address <{s}>' for s in LAUNCH_SPOKES) + flags += ' ' + ' '.join(f'--{a}-price <{a}-per-hub>' for a in LAUNCH_ALPHAS) return f'alw miner quotes --{NUMERAIRE_CHAIN}-address <{NUMERAIRE_CHAIN}> {flags} --spread 50' @@ -132,20 +134,21 @@ def quotes_command(spread_bps, hub, backing, dry_run, yes, **spoke_opts): """ hub_address = spoke_opts.get(_addr_kw(hub)) chain_specs: Dict[str, Tuple[float, str]] = {} - for spoke in LAUNCH_SPOKES: - price = spoke_opts.get(f'{spoke}_price') - addr = spoke_opts.get(f'{spoke}_address') - if spoke == hub: + for chain in LAUNCH_SPOKES + LAUNCH_ALPHAS: + price = spoke_opts.get(f'{chain}_price') + addr_chain = family(chain) if chain in LAUNCH_ALPHAS else chain # an alpha lands on the TAO coldkey + addr = spoke_opts.get(_addr_kw(addr_chain)) + if chain == hub: if price: - fail(f'--{spoke}-price conflicts with --hub {hub} — {spoke.upper()} is the hub leg, not a spoke.') + fail(f'--{chain}-price conflicts with --hub {hub} — {chain.upper()} is the hub leg, not a chain.') continue if not price or price <= 0: continue - if not addr and uses_solana_wallet(spoke): + if not addr and uses_solana_wallet(chain): addr = spoke_opts.get(_addr_kw(NUMERAIRE_CHAIN)) # same wallet as the SOL leg if not addr: - fail(f'--{spoke}-address required with --{spoke}-price') - chain_specs[spoke] = (price, addr) + fail(f'--{addr_chain}-address required with --{chain}-price') + chain_specs[chain] = (price, addr) if not chain_specs: fail('Nothing to post — give at least one ---price/---address.') if not hub_address: diff --git a/tests/test_numeraire.py b/tests/test_numeraire.py index bb6263aa..b45216ce 100644 --- a/tests/test_numeraire.py +++ b/tests/test_numeraire.py @@ -1,5 +1,10 @@ """Unit tests for the SOL-numéraire quote derivation (one price per chain → all directions).""" +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from allways.cli.swap_commands import numeraire from allways.cli.swap_commands.numeraire import derive_hub_numeraire_quotes @@ -32,3 +37,35 @@ def test_spread_applies_symmetric_margin(): def test_skips_sol_and_nonpositive_prices(): specs = derive_hub_numeraire_quotes('sol', 'S', {'sol': (1.0, 'S'), 'btc': (0.0, 'B'), 'tao': (-1.0, 'T')}) assert specs == [] + + +def test_alpha_price_reuses_tao_address_without_alpha_address_flag(): + client = MagicMock() + client.keypair.pubkey.return_value = 'miner-pk' + client.get_miner_state.return_value = MagicMock() + client.get_quote.return_value = None + wallet = MagicMock() + with ( + patch.object(numeraire, 'get_cli_context', return_value=({}, wallet, None, None)), + patch.object(numeraire, 'get_solana_cli_context', return_value=({}, client)), + patch.object(numeraire, 'resolve_quote_backing', return_value='tao'), + patch.object(numeraire, 'write_rate_posted_flag'), + ): + result = CliRunner().invoke( + numeraire.quotes_command, + ['--sol-address', 'SOLADDR', '--tao-address', 'TAOADDR', '--sn7-price', '2', '--yes'], + ) + assert result.exit_code == 0, result.output + posted_addresses = {(call.args[2], call.args[3]) for call in client.set_quote.call_args_list} + assert posted_addresses == {('SOLADDR', 'TAOADDR'), ('TAOADDR', 'SOLADDR')} + + +def test_alpha_gets_a_price_flag_but_no_address_flag(): + output = CliRunner().invoke(numeraire.quotes_command, ['--help']).output + assert '--sn7-price' in output and '--sn7-address' not in output + + +def test_alpha_price_requires_tao_address(): + result = CliRunner().invoke(numeraire.quotes_command, ['--sol-address', 'SOLADDR', '--sn7-price', '2', '--dry-run']) + assert result.exit_code != 0 + assert '--tao-address required with --sn7-price' in result.output From 05286aecc11d1ff2c4cd5b9682378ea7324c8563 Mon Sep 17 00:00:00 2001 From: Landyn Date: Mon, 24 Aug 2026 19:21:16 -0500 Subject: [PATCH 04/15] Lift subtensor scan and settlement mechanics onto Tao, parametrized by the asset's decoder --- allways/assets/tao.py | 335 +++++++++++++++++++++++++-------------- tests/test_block_time.py | 10 +- 2 files changed, 217 insertions(+), 128 deletions(-) diff --git a/allways/assets/tao.py b/allways/assets/tao.py index 119577e6..356610a4 100644 --- a/allways/assets/tao.py +++ b/allways/assets/tao.py @@ -1,5 +1,5 @@ from hashlib import blake2b -from typing import Any, Dict, Optional, Tuple +from typing import Any, Callable, Dict, Optional, Tuple import bittensor as bt from bittensor import Keypair @@ -11,6 +11,14 @@ LOG_SUB = '[Subtensor]' +# (extrinsic_hash, dest, amount, sender) of one asset's transfer call, decoded from an extrinsic. +Transfer = Tuple[str, str, int, str] +Decoder = Callable[[Any, bool], Optional[Transfer]] +# (sender, amount) that provably moved for a decoded transfer at (block, extrinsic_idx), else None. +Settler = Callable[[int, int, Transfer], Optional[Tuple[str, int]]] +# dedup_scope -> (to, amount, extrinsic_hash, seen_block); one dict per asset (see send_amount). +Broadcasts = Dict[str, Tuple[str, int, str, int]] + class Tao(Asset, Chain): """TAO chain provider using bt.Subtensor and substrate-interface. @@ -33,11 +41,11 @@ def __init__(self, subtensor: bt.Subtensor, wallet: Optional['bt.Wallet'] = None self.events_cache: Dict[str, list] = {} # Deposit-scanner head cursors, keyed per (from, to, amount) triple — see find_recent_outgoing. self.scan_cursors: Dict[Tuple[str, str, int], int] = {} - # Own-broadcast dedup: dedup_scope -> (to, amount, extrinsic_hash, seen_block). subtensor.transfer - # can report failure — or raise mid-submit — on a transfer that actually included; without this the - # next poll re-sends and double-pays. hash '' = submit raised before the hash was learned (resolved - # by triple scan); seen_block 0 = head unreadable at record time (backfilled on the next poll). - self.broadcasted_txids: Dict[str, Tuple[str, int, str, int]] = {} + # Own-broadcast dedup: subtensor.transfer can report failure — or raise mid-submit — on a transfer + # that actually included; without this the next poll re-sends and double-pays. hash '' = submit + # raised before the hash was learned (resolved by triple scan); seen_block 0 = head unreadable at + # record time (backfilled on the next poll). + self.broadcasted_txids: Broadcasts = {} @property def chain_def(self) -> ChainDefinition: @@ -218,7 +226,7 @@ def get_block_events(self, block_hash: str) -> list: return events @staticmethod - def _event_extrinsic_idx(record: Any) -> Optional[int]: + def event_extrinsic_idx(record: Any) -> Optional[int]: """Index of the extrinsic that emitted this event record, or None if not extrinsic-applied.""" if not isinstance(record, dict): return None @@ -277,8 +285,8 @@ def unreadable(detail: str) -> ProviderUnreachableError: else: raise unreadable(f'{type(attributes).__name__}') - sender = cls._as_ss58(sender) - dest = cls._as_ss58(dest) + sender = cls.as_ss58(sender) + dest = cls.as_ss58(dest) if not sender or not dest: raise unreadable('unresolved from/to') try: @@ -287,7 +295,7 @@ def unreadable(detail: str) -> ProviderUnreachableError: raise unreadable('non-numeric amount') from e @staticmethod - def _as_ss58(value: Any) -> str: + def as_ss58(value: Any) -> str: """Normalise an AccountId event field (ss58 str, {'Id': ...}, or raw bytes) to ss58.""" if isinstance(value, dict): value = value.get('Id', value.get('value')) @@ -322,7 +330,7 @@ def settled_credit(self, block_num: int, extrinsic_idx: int, recipient: str) -> sender = '' indexed = 0 for record in events: - idx = self._event_extrinsic_idx(record) + idx = self.event_extrinsic_idx(record) if idx is None: continue indexed += 1 @@ -363,6 +371,15 @@ def get_block_time(self, block_num: int) -> Optional[int]: bt.logging.debug(f'{LOG_SUB} block_time fetch failed for block {block_num}: {e}') return None + def settled_transfer(self, block_num: int, ext_idx: int, transfer: Transfer) -> Optional[Tuple[str, int]]: + """Balances.Transfer settlement of a decoded transfer call: (sender, credited_rao) or None.""" + return self.settled_credit(block_num, ext_idx, transfer[1]) + + @staticmethod + def extrinsic_position(ext: Any, position: int, is_raw: bool) -> int: + """Index of ``ext`` within the full block: raw blocks are pre-filtered, so they carry it explicitly.""" + return ext.get('extrinsic_idx', position) if is_raw else position + def fetch_matching_tx( self, tx_hash: str, @@ -371,15 +388,31 @@ def fetch_matching_tx( block_hint: int = 0, max_scan_blocks: int = 150, ) -> Optional[TransactionInfo]: - """Scan for a TAO transfer matching recipient + amount. - - If block_hint > 0, checks the hinted block ±3. Otherwise scans - ``max_scan_blocks`` back from current (newest first). The ±3 window - covers small clock/finality skews between the caller's hint and the - block the transfer actually landed in. + """Scan for a TAO transfer matching recipient + amount. Raises ProviderUnreachableError if unreachable.""" + return self.scan_for_tx( + tx_hash, + expected_recipient, + expected_amount, + block_hint, + max_scan_blocks, + self.decode_transfer, + self.settled_transfer, + ) - Raises ProviderUnreachableError if subtensor is unreachable. - """ + def scan_for_tx( + self, + tx_hash: str, + expected_recipient: str, + expected_amount: int, + block_hint: int, + max_scan_blocks: int, + decode: Decoder, + settle: Settler, + ) -> Optional[TransactionInfo]: + """Locate ``tx_hash`` in the hinted block ±3 (or ``max_scan_blocks`` back, newest first) and return it + as a settled transfer to ``expected_recipient`` of >= ``expected_amount``, else None. ``decode`` reads + the asset's transfer call off an extrinsic; ``settle`` proves its funds moved and yields what did. + The ±3 window covers small clock/finality skews between the caller's hint and the landing block.""" try: current_block = self.subtensor.get_current_block() except Exception as e: @@ -422,20 +455,18 @@ def fetch_matching_tx( is_raw = block.get('_raw', False) for position, ext in enumerate(block['extrinsics']): - match = self.match_transfer(ext, tx_hash, is_raw) - if match is None: + transfer = decode(ext, is_raw) + if transfer is None or transfer[0] != tx_hash: continue tx_hash_seen = True - dest, amount, _ = match + _, dest, amount, _ = transfer confs = current_block - block_num if dest != expected_recipient or amount < expected_amount: continue - # The call only states intent. Require the Balances.Transfer event before - # treating this as a deposit, and take the amount/sender from the event. - ext_idx = ext.get('extrinsic_idx', position) if is_raw else position - settled = self.settled_credit(block_num, ext_idx, expected_recipient) + # The call only states intent: settlement decides, and supplies the amount/sender. + settled = settle(block_num, self.extrinsic_position(ext, position, is_raw), transfer) if settled is None: bt.logging.warning( f'{LOG_SUB} tx {tx_hash[:16]}... is in block {block_num} but moved no funds ' @@ -489,30 +520,26 @@ def fetch_matching_tx( raise ProviderUnreachableError(f'TAO block scan failed: {e}') from e @staticmethod - def match_transfer(ext, tx_hash: str, is_raw: bool) -> Optional[Tuple[str, int, str]]: - """Try to match an extrinsic against a tx hash. Returns (dest, amount, sender) or None.""" - decoded = Tao.decode_transfer(ext, is_raw) - if decoded is None or decoded[0] != tx_hash: - return None - _, dest, amount, sender = decoded - return dest, amount, sender + def extrinsic_hash(ext: Any) -> str: + """Hex hash of a decoded extrinsic (GenericExtrinsic or its value dict), '' when absent.""" + ext_hash = getattr(ext, 'extrinsic_hash', None) or ( + ext.get('extrinsic_hash', '') if isinstance(ext, dict) else '' + ) + if isinstance(ext_hash, bytes): + ext_hash = '0x' + ext_hash.hex() + return ext_hash or '' @staticmethod - def decode_transfer(ext, is_raw: bool) -> Optional[Tuple[str, str, int, str]]: - """Decode a transfer extrinsic into (tx_hash, dest, amount, sender), or None if it - isn't a transfer. The single decode shared by the by-hash verifier (match_transfer) - and the by-content deposit scanner (find_recent_outgoing).""" + def decode_transfer(ext, is_raw: bool) -> Optional[Transfer]: + """Decode a Balances transfer extrinsic into (tx_hash, dest, amount, sender), or None if it + isn't one. The single decode shared by the by-hash verifier and the by-content scanner.""" if is_raw: ext_hash = ext.get('extrinsic_hash', '') if not ext_hash: return None return ext_hash, ext.get('dest', ''), ext.get('amount', 0), ext.get('sender', '') - ext_hash = getattr(ext, 'extrinsic_hash', None) or ( - ext.get('extrinsic_hash', '') if isinstance(ext, dict) else '' - ) - if isinstance(ext_hash, bytes): - ext_hash = '0x' + ext_hash.hex() + ext_hash = Tao.extrinsic_hash(ext) if not ext_hash: return None @@ -551,41 +578,54 @@ def decode_transfer(ext, is_raw: bool) -> Optional[Tuple[str, str, int, str]]: _MAX_SCAN_CURSORS = 64 def find_recent_outgoing(self, from_addr: str, to_addr: str, amount: int) -> Optional[str]: - """Extrinsic hash of a recent transfer ``from_addr`` → ``to_addr`` of >= ``amount`` rao, - else None. The TAO sibling of the BTC/SOL deposit scanners: a hash-finder only — the - seam's confirm re-verifies everything by hash, so a miss here just means the manual - rescue paths. An unretrievable block is skipped and not revisited (the cursor moves on).""" - head = self.chain.get_current_block_height() + """Extrinsic hash of a recent transfer ``from_addr`` → ``to_addr`` of >= ``amount`` rao, else None.""" + return self.find_outgoing( + self.scan_cursors, from_addr, to_addr, amount, self.decode_transfer, self.settled_transfer + ) + + def find_outgoing( + self, + cursors: Dict[Tuple[str, str, int], int], + from_addr: str, + to_addr: str, + amount: int, + decode: Decoder, + settle: Settler, + ) -> Optional[str]: + """Extrinsic hash of a settled ``from_addr`` → ``to_addr`` transfer of >= ``amount`` in the blocks + minted since this triple's cursor, else None. The subtensor sibling of the BTC/SOL deposit scanners: + a hash-finder only — the seam's confirm re-verifies everything by hash, so a miss here just means + the manual rescue paths. An unretrievable block is skipped and not revisited (the cursor moves on).""" + head = self.get_current_block_height() if head is None: return None key = (from_addr, to_addr, int(amount)) floor = max(head - self.SCAN_LOOKBACK_BLOCKS, 0) - last = self.scan_cursors.get(key, floor) + last = cursors.get(key, floor) for block_num in range(max(last, floor) + 1, head + 1): block = self.get_block(block_num) if not block or 'extrinsics' not in block: continue is_raw = block.get('_raw', False) for position, ext in enumerate(block['extrinsics']): - decoded = self.decode_transfer(ext, is_raw) - if decoded is None: + transfer = decode(ext, is_raw) + if transfer is None: continue - ext_hash, dest, amt, sender = decoded + ext_hash, dest, amt, sender = transfer if dest != to_addr or sender != from_addr or int(amt) < int(amount): continue # Call-level match is only a candidate; confirm the funds actually moved. - ext_idx = ext.get('extrinsic_idx', position) if is_raw else position try: - settled = self.settled_credit(block_num, ext_idx, to_addr) + settled = settle(block_num, self.extrinsic_position(ext, position, is_raw), transfer) except ProviderUnreachableError: continue if settled is None or settled[1] < int(amount): continue - self.scan_cursors.pop(key, None) + cursors.pop(key, None) return ext_hash - self.scan_cursors[key] = head - if len(self.scan_cursors) > self._MAX_SCAN_CURSORS: - self.scan_cursors.pop(next(iter(self.scan_cursors))) + cursors[key] = head + if len(cursors) > self._MAX_SCAN_CURSORS: + cursors.pop(next(iter(cursors))) return None def get_current_block_height(self) -> Optional[int]: @@ -636,20 +676,33 @@ def verify_from_proof(self, address: str, message: str, signature: str) -> bool: def _own_transfer_landed( self, tx_hash: str, from_addr: str, to_addr: str, amount: int, seen_head: Optional[int] = None + ) -> Optional[SendResult]: + return self.own_send_landed( + tx_hash, from_addr, to_addr, amount, seen_head, self.decode_transfer, self.settled_transfer + ) + + def own_send_landed( + self, + tx_hash: str, + from_addr: str, + to_addr: str, + amount: int, + seen_head: Optional[int], + decode: Decoder, + settle: Settler, ) -> Optional[SendResult]: """``(tx_hash, block_num)`` if THIS exact recorded extrinsic is on-chain AND its funds provably moved, else None. Matches on the extrinsic hash (not first-match), so it can never reuse a - different swap's transfer. Inclusion is NOT settlement: an included-but-failed transfer (e.g. - insufficient balance) still occupies a block and decodes to the intended dest/amount, so the - match is gated on ``settled_credit`` (the Balances.Transfer event) exactly as the deposit - scanner is — otherwise a genuinely-failed send is mistaken for paid, never retried, and rides to - a slash. Reliable because ``send`` uses wait_for_inclusion: after it returns the extrinsic is - included-or-not, never still-pending. RAISES (ProviderUnreachableError) when the head, any - in-window block, or settlement can't be read: an unreadable chain can hide a landed transfer, - so "couldn't check" waits rather than clearing a re-send into a double pay. ``seen_head`` - anchors the scan window on the block the send was recorded at, so an RPC outage spanning polls - can't slide the landed transfer out of the lookback and read as "never landed".""" - head = self.chain.get_current_block_height() + different swap's transfer. Inclusion is NOT settlement: an included-but-failed transfer still + occupies a block and decodes to the intended dest/amount, so the match is gated on ``settle`` + exactly as the deposit scanner is — otherwise a genuinely-failed send is mistaken for paid, never + retried, and rides to a slash. Reliable because sends use wait_for_inclusion: after they return + the extrinsic is included-or-not, never still-pending. RAISES (ProviderUnreachableError) when the + head, any in-window block, or settlement can't be read: an unreadable chain can hide a landed + transfer, so "couldn't check" waits rather than clearing a re-send into a double pay. + ``seen_head`` anchors the scan window on the block the send was recorded at, so an RPC outage + spanning polls can't slide the landed transfer out of the lookback and read as "never landed".""" + head = self.get_current_block_height() if head is None: raise ProviderUnreachableError('TAO head unavailable — cannot resolve the prior send') anchor = min(int(seen_head), head) if seen_head else head @@ -661,15 +714,14 @@ def _own_transfer_landed( raise ProviderUnreachableError(f'TAO block {block_num} unreadable — cannot rule out the prior send') is_raw = block.get('_raw', False) for position, ext in enumerate(block['extrinsics']): - decoded = self.decode_transfer(ext, is_raw) - if decoded is None: + transfer = decode(ext, is_raw) + if transfer is None: continue - ext_hash, dest, amt, sender = decoded + ext_hash, dest, amt, sender = transfer if ext_hash != tx_hash or dest != to_addr or sender != from_addr or int(amt) < int(amount): continue # Exact-hash call match is only a candidate; confirm the funds actually moved. - ext_idx = ext.get('extrinsic_idx', position) if is_raw else position - settled = self.settled_credit(block_num, ext_idx, to_addr) + settled = settle(block_num, self.extrinsic_position(ext, position, is_raw), transfer) if settled is None or settled[1] < int(amount): return None # included but the transfer failed → moved nothing → safe to re-send return (tx_hash, block_num) @@ -678,14 +730,28 @@ def _own_transfer_landed( def _lost_hash_prior_landed( self, from_addr: str, to_addr: str, amount: int, seen_head: int ) -> Optional[SendResult]: - """Resolve a prior send whose extrinsic hash was never learned: ``subtensor.transfer`` raised - mid-submit (e.g. a websocket drop during wait_for_inclusion), so the extrinsic may have reached - the chain even though the call reported nothing. ``(hash, block)`` if a settled - ``from → to >= amount`` transfer is found since just before the attempt; None only once the - inclusion window since the attempt has fully elapsed AND every block in it scanned clean, so the - lost extrinsic provably never landed. RAISES (ProviderUnreachableError) while the window is - still open or any block is unreadable — the caller must wait, not re-send.""" - head = self.chain.get_current_block_height() + return self.lost_send_landed( + from_addr, to_addr, amount, seen_head, self.decode_transfer, self.settled_transfer, self.broadcasted_txids + ) + + def lost_send_landed( + self, + from_addr: str, + to_addr: str, + amount: int, + seen_head: int, + decode: Decoder, + settle: Settler, + broadcasted: Broadcasts, + ) -> Optional[SendResult]: + """Resolve a prior send whose extrinsic hash was never learned: the submit raised mid-flight + (e.g. a websocket drop during wait_for_inclusion), so the extrinsic may have reached the chain + even though the call reported nothing. ``(hash, block)`` if a settled ``from → to >= amount`` + transfer is found since just before the attempt; None only once the inclusion window since the + attempt has fully elapsed AND every block in it scanned clean, so the lost extrinsic provably + never landed. RAISES (ProviderUnreachableError) while the window is still open or any block is + unreadable — the caller must wait, not re-send.""" + head = self.get_current_block_height() if head is None: raise ProviderUnreachableError('TAO head unavailable — cannot resolve the prior send') floor = max(min(int(seen_head), head) - self.SCAN_LOOKBACK_BLOCKS, 0) @@ -695,24 +761,67 @@ def _lost_hash_prior_landed( raise ProviderUnreachableError(f'TAO block {block_num} unreadable — cannot rule out the prior send') is_raw = block.get('_raw', False) for position, ext in enumerate(block['extrinsics']): - decoded = self.decode_transfer(ext, is_raw) - if decoded is None: + transfer = decode(ext, is_raw) + if transfer is None: continue - ext_hash, dest, amt, sender = decoded + ext_hash, dest, amt, sender = transfer if dest != to_addr or sender != from_addr or int(amt) < int(amount): continue # A hash already recorded under any scope belongs to a DIFFERENT obligation this # process sent — the lost extrinsic's hash was, by definition, never recorded. - if any(rec[2] == ext_hash for rec in self.broadcasted_txids.values()): + if any(rec[2] == ext_hash for rec in broadcasted.values()): continue - ext_idx = ext.get('extrinsic_idx', position) if is_raw else position - settled = self.settled_credit(block_num, ext_idx, to_addr) + settled = settle(block_num, self.extrinsic_position(ext, position, is_raw), transfer) if settled is not None and settled[1] >= int(amount): return (ext_hash, block_num) if head - int(seen_head) > self.LOST_SEND_DEAD_BLOCKS: return None # mortal era elapsed and scanned clean: the lost extrinsic can never land raise ProviderUnreachableError('prior TAO send (hash lost mid-submit) not yet resolvable — waiting a pass') + def prior_send_landed( + self, + broadcasted: Broadcasts, + scope: str, + from_addr: str, + to_addr: str, + amount: int, + decode: Decoder, + settle: Settler, + ) -> Optional[SendResult]: + """``(hash, block)`` of the send already recorded under ``scope`` for this obligation if it settled + on-chain; None when nothing matching is recorded or the record provably moved nothing (safe to + send). RAISES while the answer is unknown — the caller must not send. Anchors the record on the + landing block for later reuse.""" + prior = broadcasted.get(scope) + if prior is None or prior[0] != to_addr or prior[1] != int(amount): + return None + amount = int(amount) + tx_hash, seen = prior[2], int(prior[3]) + if seen <= 0: + # Head was unreadable at record time: backfill with the current head so the + # resolution window counts from now — unknown age must read recent, never stale. + seen = self.get_current_block_height() + if seen is None: + raise ProviderUnreachableError('TAO head unavailable — cannot resolve the prior send') + broadcasted[scope] = (to_addr, amount, tx_hash, seen) + if tx_hash: + landed = self.own_send_landed(tx_hash, from_addr, to_addr, amount, seen, decode, settle) + else: + landed = self.lost_send_landed(from_addr, to_addr, amount, seen, decode, settle, broadcasted) + if landed is not None: + broadcasted[scope] = (to_addr, amount, landed[0], int(landed[1] or seen)) + return landed + + def record_send_attempt(self, broadcasted: Broadcasts, scope: str, to_addr: str, amount: int) -> int: + """Mark the attempt BEFORE the send and return the head it was seen at (0 when unreadable): the + extrinsic can reach the chain even when the call raises, and only a marker makes the next poll + resolve it by scan instead of blindly re-sending.""" + attempt_head = self.get_current_block_height() or 0 + broadcasted[scope] = (to_addr, int(amount), '', attempt_head) + if len(broadcasted) > 256: + broadcasted.pop(next(iter(broadcasted))) + return attempt_head + def send_amount( self, to_address: str, amount: int, from_address: Optional[str] = None, dedup_key: Optional[str] = None ) -> SendResult: @@ -733,39 +842,25 @@ def send_amount( # would otherwise double-pay. from_ss58 = self.wallet.coldkeypub.ss58_address scope = dedup_key or '' - prior = self.broadcasted_txids.get(scope) - if prior is not None and prior[0] == to_address and prior[1] == int(amount): - try: - seen = int(prior[3]) if len(prior) > 3 else 0 - if seen <= 0: - # Head was unreadable at record time: backfill with the current head so the - # resolution window counts from now — unknown age must read recent, never stale. - head_now = self.chain.get_current_block_height() - if head_now is None: - raise ProviderUnreachableError('TAO head unavailable — cannot resolve the prior send') - seen = head_now - self.broadcasted_txids[scope] = (prior[0], prior[1], prior[2], seen) - if prior[2]: - landed = self._own_transfer_landed(prior[2], from_ss58, to_address, amount, seen) - else: - landed = self._lost_hash_prior_landed(from_ss58, to_address, amount, seen) - except Exception as e: - bt.logging.error(f'TAO prior send unresolved ({e}) — not re-sending, would risk a double pay') - return None - if landed is not None: - self.broadcasted_txids[scope] = (to_address, int(amount), landed[0], int(landed[1] or seen)) - bt.logging.info(f'TAO reusing prior tx {landed[0]} to {to_address} ({amount} rao)') - return landed - # landed is None → the prior send provably moved nothing; fall through to a fresh send. - - # Record the attempt BEFORE the send: the extrinsic hash only exists after subtensor.transfer - # returns, but the extrinsic can reach the chain even when the call raises (websocket drop - # during wait_for_inclusion). A pre-send marker means a raise leaves the next poll resolving - # the attempt by scan instead of blindly re-sending. - attempt_head = self.chain.get_current_block_height() - self.broadcasted_txids[scope] = (to_address, int(amount), '', attempt_head or 0) - if len(self.broadcasted_txids) > 256: - self.broadcasted_txids.pop(next(iter(self.broadcasted_txids))) + try: + landed = self.prior_send_landed( + self.broadcasted_txids, + scope, + from_ss58, + to_address, + amount, + self.decode_transfer, + self.settled_transfer, + ) + except Exception as e: + bt.logging.error(f'TAO prior send unresolved ({e}) — not re-sending, would risk a double pay') + return None + if landed is not None: + bt.logging.info(f'TAO reusing prior tx {landed[0]} to {to_address} ({amount} rao)') + return landed + # landed is None → nothing recorded, or the prior send provably moved nothing: fresh send. + + attempt_head = self.record_send_attempt(self.broadcasted_txids, scope, to_address, amount) try: response = self.subtensor.transfer( wallet=self.wallet, diff --git a/tests/test_block_time.py b/tests/test_block_time.py index 27ba5255..92776c58 100644 --- a/tests/test_block_time.py +++ b/tests/test_block_time.py @@ -438,7 +438,7 @@ def test_settled_credit_sums_multiple_credits_from_one_extrinsic(): ) def test_transfer_event_shapes_are_all_understood(record): """scalecodec emits several shapes across runtime versions; all must decode identically.""" - assert Tao._event_extrinsic_idx(record) == 0 + assert Tao.event_extrinsic_idx(record) == 0 assert Tao._transfer_from_event(record) == ('A', 'B', 7) @@ -458,7 +458,7 @@ def test_non_transfer_events_are_not_read_as_transfers(): }, }, ): - assert Tao._transfer_from_event(record) is None or Tao._event_extrinsic_idx(record) is None + assert Tao._transfer_from_event(record) is None or Tao.event_extrinsic_idx(record) is None def test_events_unavailable_raises_rather_than_reading_as_absent(): @@ -502,9 +502,3 @@ def test_raw_extrinsic_rejects_non_id_multiaddress_rather_than_shifting(): """A non-Id signer variant has no bare AccountId to read, so it must fail closed.""" body = bytes([0x84, 0x01]) + bytes(range(32)) + bytes([0x01]) + b'\x11' * 64 + bytes([0x00] * 3) assert Tao.parse_raw_extrinsic((_compact(len(body)) + body).hex()) is None - - -def test_match_transfer_still_matches_by_hash_through_shared_decode(): - ext = {'extrinsic_hash': '0xabc', 'dest': 'minerTAO', 'amount': 7, 'sender': 'userTAO'} - assert Tao.match_transfer(ext, '0xabc', True) == ('minerTAO', 7, 'userTAO') - assert Tao.match_transfer(ext, '0xother', True) is None From bc29d66536373a0243918b6da3dc2a4b73f42f81 Mon Sep 17 00:00:00 2001 From: Landyn Date: Mon, 24 Aug 2026 19:21:16 -0500 Subject: [PATCH 05/15] Add the Alpha asset: transfer_stake settlement on the shared Tao chain --- allways/assets/alpha.py | 245 ++++++++++++++++++++++++++++++++++++++++ allways/constants.py | 3 + 2 files changed, 248 insertions(+) create mode 100644 allways/assets/alpha.py diff --git a/allways/assets/alpha.py b/allways/assets/alpha.py new file mode 100644 index 00000000..6daeb086 --- /dev/null +++ b/allways/assets/alpha.py @@ -0,0 +1,245 @@ +from typing import Any, Dict, List, Optional, Tuple + +import bittensor as bt + +from allways.assets.asset import Asset, ProviderUnreachableError, SendResult, TransactionInfo +from allways.assets.tao import Broadcasts, Decoder, Settler, Tao, Transfer +from allways.chains import ChainDefinition +from allways.constants import CANCEL_REASON_ALPHA_TRANSFER_DISABLED + +LOG_ALPHA = '[Alpha]' +# Matched by name: SubtensorModule's indices move on runtime upgrades. +TRANSFER_STAKE = ('SubtensorModule', 'transfer_stake') +SETTLED_EVENTS = {('System', 'ExtrinsicSuccess'), ('SubtensorModule', 'StakeTransferred')} + + +def event_name(record: Any) -> Optional[Tuple[str, str]]: + """(pallet, event) of a System.Events record across the shapes scalecodec emits, else None.""" + event = record.get('event', record) if isinstance(record, dict) else None + if not isinstance(event, dict): + return None + module = event.get('module_id') or event.get('module') or event.get('pallet') + name = event.get('event_id') or event.get('event') or event.get('name') + if isinstance(module, str) and isinstance(name, str): + return module, name + if len(event) == 1: + ((module, inner),) = event.items() + if isinstance(module, str) and isinstance(inner, dict) and len(inner) == 1: + return module, next(iter(inner)) + return None + + +class Alpha(Asset): + """A subnet alpha token: transfer_stake on the shared Tao chain, settled by ExtrinsicSuccess + StakeTransferred.""" + + def __init__(self, chain_def: ChainDefinition, subtensor: bt.Subtensor, wallet: Optional[bt.Wallet] = None): + self._chain_def = chain_def + self._chain = Tao(subtensor, wallet) + # Per asset, not on the shared chain: a TAO send and an alpha send must never collide. + self.scan_cursors: Dict[Tuple[str, str, int], int] = {} + self.broadcasted_txids: Broadcasts = {} + + @property + def chain_def(self) -> ChainDefinition: + return self._chain_def + + @property + def netuid(self) -> int: + return self._chain_def.netuid + + @property + def subtensor(self) -> bt.Subtensor: + return self.chain.subtensor + + @property + def wallet(self) -> Optional[bt.Wallet]: + return self.chain.wallet + + def describe(self) -> str: + return f'{self.chain.describe()} — netuid {self.netuid}' + + def can_send_from(self, address: str) -> bool: + return self.chain.can_send_from(address) + + def check_connection(self, require_send: bool = True, **kwargs) -> None: + self.chain.check_connection(**kwargs) + if require_send and self.wallet is None: + raise ConnectionError(f'{self.chain_def.id} send requires a wallet') + + def clear_cache(self) -> None: + self.chain.clear_cache() + + def value_rao(self, amount: int) -> int: + """Spot value in rao of ``amount`` alpha base units at the pool's current price, floored.""" + try: + price = self.subtensor.get_subnet_price(self.netuid) + return int(amount) * int(price.rao) // 10**self.chain_def.decimals + except Exception as e: + raise ProviderUnreachableError(f'{self.chain_def.id} price unavailable: {e}') from e + + def decode_transfer_stake(self, ext: Any, is_raw: bool) -> Optional[Transfer]: + """(hash, dest_coldkey, alpha, sender) of a top-level transfer_stake onto this netuid, else None.""" + if is_raw: + return None # the raw fallback only parses Balances transfers + ext_data = ext.value if hasattr(ext, 'value') else ext + if not isinstance(ext_data, dict): + return None + call = ext_data.get('call') or {} + if (call.get('call_module'), call.get('call_function')) != TRANSFER_STAKE: + return None + args = {a.get('name'): a.get('value') for a in call.get('call_args') or [] if isinstance(a, dict)} + try: + if int(args['destination_netuid']) != self.netuid: + return None + alpha = int(args['alpha_amount']) + except (KeyError, TypeError, ValueError): + return None + dest = Tao.as_ss58(args.get('destination_coldkey')) + return Tao.extrinsic_hash(ext), dest, alpha, Tao.as_ss58(ext_data.get('address')) + + def stake_moved(self, block_num: int, extrinsic_idx: int) -> bool: + """True iff the extrinsic dispatched successfully AND emitted StakeTransferred; raises when unreadable.""" + block_hash = self.chain.get_block_hash(block_num) + if not block_hash: + raise ProviderUnreachableError(f'{self.chain_def.id} block hash unavailable for {block_num}') + events = self.chain.get_block_events(block_hash) + if not events: + raise ProviderUnreachableError(f'no events returned for block {block_num}, which holds extrinsics') + indexed = [(Tao.event_extrinsic_idx(r), event_name(r)) for r in events] + if all(idx is None for idx, _ in indexed): + raise ProviderUnreachableError(f'no ApplyExtrinsic phase recognised in {len(events)} events at {block_num}') + return SETTLED_EVENTS <= {name for idx, name in indexed if idx == extrinsic_idx} + + def settled_transfer_stake(self, block_num: int, ext_idx: int, transfer: Transfer) -> Optional[Tuple[str, int]]: + """(sender, alpha) from the CALL once settled — the event's amount is the TAO-equivalent.""" + _, _, alpha, sender = transfer + return (sender, alpha) if self.stake_moved(block_num, ext_idx) else None + + @property + def ledger(self) -> Tuple[Decoder, Settler]: + """What the chain's scan mechanics need from this asset: its decoder and its settlement proof.""" + return self.decode_transfer_stake, self.settled_transfer_stake + + def fetch_matching_tx( + self, + tx_hash: str, + expected_recipient: str, + expected_amount: int, + block_hint: int = 0, + max_scan_blocks: int = 150, + ) -> Optional[TransactionInfo]: + info = self.chain.scan_for_tx( + tx_hash, expected_recipient, expected_amount, block_hint, max_scan_blocks, *self.ledger + ) + if info is not None and info.block_time is None: + raise ProviderUnreachableError(f'{self.chain_def.id} block time unavailable for {info.block_number}') + return info + + def stakes(self, coldkey: str) -> List[Tuple[str, int]]: + """(hotkey, alpha) held by ``coldkey`` on this netuid; raises on a read failure.""" + infos = self.subtensor.get_stake_info_for_coldkey(coldkey) + return [(info.hotkey_ss58, int(info.stake.rao)) for info in infos if int(info.netuid) == self.netuid] + + def get_balance(self, address: str) -> int: + try: + return sum(alpha for _, alpha in self.stakes(address)) + except Exception as e: + bt.logging.error(f'{LOG_ALPHA} get_balance failed: {e}') + return 0 + + def transfers_enabled(self) -> bool: + """TransferToggle ∧ SubtokenEnabled for this netuid; raises on a read failure.""" + flags = ( + self.subtensor.substrate.query('SubtensorModule', name, [self.netuid]) + for name in ('TransferToggle', 'SubtokenEnabled') + ) + return all(bool(getattr(flag, 'value', flag)) for flag in flags) + + def can_deliver_to(self, address: str, amount: int, from_address: Optional[str] = None) -> bool: + try: + return self.transfers_enabled() + except Exception: + return True + + def delivery_refused(self, address: str, since_unix: int) -> bool: + try: + return not self.transfers_enabled() + except Exception: + return False + + def cancel_evidence( + self, address: str, amount: int, tx_hash: Optional[str] = None, from_address: Optional[str] = None + ) -> Optional[int]: + """A subnet that disabled transfers strands every miner on it — no-fault.""" + try: + return None if self.transfers_enabled() else CANCEL_REASON_ALPHA_TRANSFER_DISABLED + except Exception: + return None + + def find_recent_outgoing(self, from_addr: str, to_addr: str, amount: int) -> Optional[str]: + return self.chain.find_outgoing(self.scan_cursors, from_addr, to_addr, amount, *self.ledger) + + def send_amount( + self, to_address: str, amount: int, from_address: Optional[str] = None, dedup_key: Optional[str] = None + ) -> SendResult: + """transfer_stake from the hotkey holding the most of this alpha; dedup and hash handling mirror Tao.""" + if self.wallet is None: + bt.logging.error(f'{LOG_ALPHA} send_amount called on a read-only {self.chain_def.id} (no wallet)') + return None + from_ss58 = self.wallet.coldkeypub.ss58_address + if from_address is not None and from_ss58 != str(from_address): + bt.logging.error(f'{LOG_ALPHA} committed sender {from_address} != wallet {from_ss58} — not sending') + return None + + scope = dedup_key or '' + try: + landed = self.chain.prior_send_landed( + self.broadcasted_txids, scope, from_ss58, to_address, amount, *self.ledger + ) + except Exception as e: + bt.logging.error(f'{LOG_ALPHA} prior send unresolved ({e}) — not re-sending, would risk a double pay') + return None + if landed is not None: + bt.logging.info(f'{LOG_ALPHA} reusing prior tx {landed[0]} to {to_address} ({amount} alpha)') + return landed + + try: + stakes = self.stakes(from_ss58) + except Exception as e: + bt.logging.error(f'{LOG_ALPHA} cannot read {from_ss58} stakes: {e} — not sending') + return None + if not stakes: + bt.logging.error(f'{LOG_ALPHA} {from_ss58} holds no netuid {self.netuid} alpha — not sending') + return None + hotkey = max(stakes, key=lambda stake: stake[1])[0] + + attempt_head = self.chain.record_send_attempt(self.broadcasted_txids, scope, to_address, amount) + # The SDK never raises here: every failure comes back as a response, possibly without a hash. + response = self.subtensor.transfer_stake( + wallet=self.wallet, + destination_coldkey_ss58=to_address, + hotkey_ss58=hotkey, + origin_netuid=self.netuid, + destination_netuid=self.netuid, + amount=bt.Balance.from_rao(int(amount)), + mev_protection=False, + wait_for_inclusion=True, + wait_for_finalization=False, + ) + # The signed extrinsic exists before broadcast, so its hash outlives a lost receipt. + receipt = getattr(response, 'extrinsic_receipt', None) + tx_hash = getattr(receipt, 'extrinsic_hash', None) or Tao.extrinsic_hash(getattr(response, 'extrinsic', None)) + if tx_hash: + self.broadcasted_txids[scope] = (to_address, int(amount), tx_hash, attempt_head) + if not response.success: + bt.logging.error(f'{LOG_ALPHA} transfer_stake failed: {response.message} — recorded, resolved next poll') + return None + try: + block_num = int(self.subtensor.substrate.get_block_number(receipt.block_hash)) + except Exception: + block_num = attempt_head + self.broadcasted_txids[scope] = (to_address, int(amount), tx_hash, block_num) + bt.logging.info( + f'{LOG_ALPHA} sent {amount} alpha (netuid {self.netuid}) to {to_address} (tx: {tx_hash}, block: {block_num})' + ) + return (tx_hash, block_num) diff --git a/allways/constants.py b/allways/constants.py index 36b5f500..1bf0af4e 100644 --- a/allways/constants.py +++ b/allways/constants.py @@ -61,6 +61,9 @@ # The issuer froze the destination's SPL token account (USDC's mint carries a freeze authority): # undeliverable through no fault of the miner. Python-side first; mirror into constants.rs next release. CANCEL_REASON_SPL_FROZEN = 5 +# The subnet owner/root disabled alpha transfers (TransferToggle / SubtokenEnabled): strands every +# miner on that subnet at once — no-fault. Python-side first; mirror into constants.rs next release. +CANCEL_REASON_ALPHA_TRANSFER_DISABLED = 6 CANCEL_REASON_OTHER = 255 BTC_MIN_FEE_RATE = 5 From 4a8011315f5a25d93ab31cf4a937140210636e72 Mon Sep 17 00:00:00 2001 From: Landyn Date: Mon, 24 Aug 2026 19:21:16 -0500 Subject: [PATCH 06/15] Register sn7 and sn74 as Alpha bindings --- allways/assets/__init__.py | 8 ++++++++ allways/assets/sn7.py | 11 +++++++++++ allways/assets/sn74.py | 11 +++++++++++ 3 files changed, 30 insertions(+) create mode 100644 allways/assets/sn7.py create mode 100644 allways/assets/sn74.py diff --git a/allways/assets/__init__.py b/allways/assets/__init__.py index 8cf34934..1da554b8 100644 --- a/allways/assets/__init__.py +++ b/allways/assets/__init__.py @@ -2,6 +2,7 @@ import bittensor as bt +from allways.assets.alpha import Alpha from allways.assets.arbusdc import ArbUsdc from allways.assets.asset import Asset, MissingTestnetDeployment, SendResult, TransactionInfo from allways.assets.aster import Aster @@ -20,6 +21,8 @@ from allways.assets.pol import Pol from allways.assets.polusdc import PolUsdc from allways.assets.qnt import Qnt +from allways.assets.sn7 import Sn7 +from allways.assets.sn74 import Sn74 from allways.assets.sol import Sol, SolanaChain from allways.assets.solusdc import SolUsdc from allways.assets.spl_token import SplToken @@ -54,6 +57,9 @@ 'SolanaChain', 'SplToken', 'SolUsdc', + 'Alpha', + 'Sn7', + 'Sn74', 'create_assets', ] @@ -86,6 +92,8 @@ class AssetSpec(NamedTuple): AssetSpec('polusdc', PolUsdc, ()), AssetSpec('paxg', Paxg, ()), AssetSpec('solusdc', SolUsdc, ('solana_rpc_url', 'solana_keypair')), + AssetSpec('sn7', Sn7, ('subtensor', 'wallet')), + AssetSpec('sn74', Sn74, ('subtensor', 'wallet')), ) diff --git a/allways/assets/sn7.py b/allways/assets/sn7.py new file mode 100644 index 00000000..a7205ecb --- /dev/null +++ b/allways/assets/sn7.py @@ -0,0 +1,11 @@ +from typing import Optional + +import bittensor as bt + +from allways.assets.alpha import Alpha +from allways.chains import CHAIN_SN7 + + +class Sn7(Alpha): + def __init__(self, subtensor: bt.Subtensor, wallet: Optional[bt.Wallet] = None): + super().__init__(CHAIN_SN7, subtensor, wallet) diff --git a/allways/assets/sn74.py b/allways/assets/sn74.py new file mode 100644 index 00000000..02a50c83 --- /dev/null +++ b/allways/assets/sn74.py @@ -0,0 +1,11 @@ +from typing import Optional + +import bittensor as bt + +from allways.assets.alpha import Alpha +from allways.chains import CHAIN_SN74 + + +class Sn74(Alpha): + def __init__(self, subtensor: bt.Subtensor, wallet: Optional[bt.Wallet] = None): + super().__init__(CHAIN_SN74, subtensor, wallet) From 955dd6741082d0d8120bb4f0226be079a6d167af Mon Sep 17 00:00:00 2001 From: Landyn Date: Mon, 24 Aug 2026 19:21:16 -0500 Subject: [PATCH 07/15] Test the Alpha asset against the spec's settlement, dedup, and send traps --- tests/test_alpha_provider.py | 269 +++++++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 tests/test_alpha_provider.py diff --git a/tests/test_alpha_provider.py b/tests/test_alpha_provider.py new file mode 100644 index 00000000..3dd58cbd --- /dev/null +++ b/tests/test_alpha_provider.py @@ -0,0 +1,269 @@ +"""Alpha legs settle on a top-level transfer_stake whose extrinsic succeeded AND emitted StakeTransferred, +with the alpha amount read from the CALL (the event carries the TAO-equivalent). Backends are stubbed.""" + +from types import SimpleNamespace + +import pytest + +from allways.assets import ASSET_REGISTRY, Sn7, Sn74 +from allways.assets.alpha import Alpha +from allways.assets.asset import ProviderUnreachableError +from allways.assets.tao import Tao +from allways.chains import CHAIN_SN7 +from allways.constants import CANCEL_REASON_ALPHA_TRANSFER_DISABLED + +MINER = 'minerCold' +USER = 'userCold' +HOTKEY = 'hotkeyA' +TXID_BYTES = bytes.fromhex('ab' * 32) +TXID = '0x' + TXID_BYTES.hex() +BLOCK = 500 +HEAD = BLOCK + 10 +NETUID = CHAIN_SN7.netuid + + +def _ext(alpha=5_000, netuid=NETUID, dest=MINER, sender=USER, module='SubtensorModule', function='transfer_stake'): + """The real substrate.get_block shape: a GenericExtrinsic with bytes .extrinsic_hash and a .value dict.""" + value = { + 'address': sender, + 'call': { + 'call_module': module, + 'call_function': function, + 'call_args': [ + {'name': 'destination_coldkey', 'value': dest}, + {'name': 'hotkey', 'value': HOTKEY}, + {'name': 'origin_netuid', 'value': netuid}, + {'name': 'destination_netuid', 'value': netuid}, + {'name': 'alpha_amount', 'value': alpha}, + ], + }, + } + return SimpleNamespace(extrinsic_hash=TXID_BYTES, value=value) + + +def _batched(inner): + value = { + 'address': USER, + 'call': { + 'call_module': 'Utility', + 'call_function': 'batch', + 'call_args': [{'name': 'calls', 'value': [inner.value]}], + }, + } + return SimpleNamespace(extrinsic_hash=TXID_BYTES, value=value) + + +def _event(module, name, attributes=None, idx=0): + return {'extrinsic_idx': idx, 'event': {'module_id': module, 'event_id': name, 'attributes': attributes or {}}} + + +def _settled_events(idx=0, tao_amount=123): + return [ + _event('SubtensorModule', 'StakeTransferred', (USER, MINER, HOTKEY, NETUID, NETUID, tao_amount), idx), + _event('System', 'ExtrinsicSuccess', idx=idx), + ] + + +def _provider(*, exts=None, events=None, block_time=1_700_000_000, wallet=None): + p = Sn7(SimpleNamespace(get_current_block=lambda: HEAD), wallet) + block = {'extrinsics': [_ext()] if exts is None else exts} + p.chain.get_block = lambda n: block if n == BLOCK else {'extrinsics': []} + p.chain.get_block_hash = lambda n: f'0xblock{n}' + p.chain.get_block_events = lambda h: _settled_events() if events is None else events + p.chain.get_block_time = lambda n: block_time + return p + + +def _verify(p, amount=5_000): + return p.fetch_matching_tx(TXID, MINER, amount, block_hint=BLOCK) + + +# ─── registry + seam ──────────────────────────────────────────────────────── + + +def test_alphas_are_registered_and_bind_the_tao_chain(): + ids = {spec.chain_id: spec for spec in ASSET_REGISTRY} + assert ids['sn7'].cls is Sn7 and ids['sn74'].cls is Sn74 + assert ids['sn7'].kwarg_names == ids['tao'].kwarg_names + p = Sn7(SimpleNamespace()) + assert isinstance(p, Alpha) and isinstance(p.chain, Tao) and p.netuid == 7 + assert Sn74(SimpleNamespace()).netuid == 74 + + +# ─── verification ─────────────────────────────────────────────────────────── + + +def test_real_extrinsic_shape_decodes_with_its_hash(): + """substrate.get_block yields GenericExtrinsic objects: the hash lives on the object, not in .value.""" + assert Sn7(SimpleNamespace()).decode_transfer_stake(_ext(), False) == (TXID, MINER, 5_000, USER) + + +def test_amount_comes_from_the_call_not_the_event(): + """StakeTransferred carries the TAO-equivalent (123); the leg is worth the call's 5000 alpha.""" + info = _verify(_provider(events=_settled_events(tao_amount=123))) + assert info is not None + assert (info.sender, info.recipient, info.amount, info.block_number) == (USER, MINER, 5_000, BLOCK) + assert info.block_time == 1_700_000_000 + + +def test_batched_transfer_stake_is_rejected(): + assert _verify(_provider(exts=[_batched(_ext())])) is None + + +def test_included_but_failed_transfer_stake_is_not_settled(): + failed = [_event('System', 'ExtrinsicFailed', {'dispatch_error': {'Module': 'TransferDisallowed'}})] + assert _verify(_provider(events=failed)) is None + assert _verify(_provider(events=[_event('System', 'ExtrinsicSuccess')])) is None + assert _verify(_provider(events=[_settled_events()[0]])) is None + + +def test_wrong_netuid_or_underpay_do_not_match(): + assert _verify(_provider(exts=[_ext(netuid=NETUID + 1)])) is None + assert _verify(_provider(exts=[_ext(alpha=4_999)])) is None + assert _verify(_provider(exts=[_ext(alpha=9_000)])).amount == 9_000 + + +def test_unreadable_events_raise_rather_than_reading_as_absent(): + p = _provider() + + def boom(_): + raise ProviderUnreachableError('events unavailable') + + p.chain.get_block_events = boom + with pytest.raises(ProviderUnreachableError): + _verify(p) + + +def test_missing_block_time_raises(): + """is_tx_fresh fails closed on None, which would ride a paid leg to a TIMEOUT slash.""" + with pytest.raises(ProviderUnreachableError): + _verify(_provider(block_time=None)) + + +# ─── balances + price ─────────────────────────────────────────────────────── + + +def _stake(hotkey, rao, netuid=NETUID): + return SimpleNamespace(hotkey_ss58=hotkey, netuid=netuid, stake=SimpleNamespace(rao=rao)) + + +def test_get_balance_sums_this_netuid_across_hotkeys(): + stakes = [_stake('hk1', 100), _stake('hk2', 250), _stake('hk3', 999, netuid=NETUID + 1)] + assert Sn7(SimpleNamespace(get_stake_info_for_coldkey=lambda ck: stakes)).get_balance(MINER) == 350 + + +def test_value_rao_floors_and_raises_on_failure(): + p = Sn7(SimpleNamespace(get_subnet_price=lambda netuid: SimpleNamespace(rao=333_333_333))) + assert p.value_rao(3) == 0 + assert p.value_rao(3_000_000_000) == 999_999_999 + + def boom(netuid): + raise RuntimeError('rpc down') + + with pytest.raises(ProviderUnreachableError): + Sn7(SimpleNamespace(get_subnet_price=boom)).value_rao(1) + + +# ─── delivery gates ───────────────────────────────────────────────────────── + + +def _toggles(transfer=True, subtoken=True): + flags = {'TransferToggle': transfer, 'SubtokenEnabled': subtoken} + return SimpleNamespace(substrate=SimpleNamespace(query=lambda m, name, params: flags[name])) + + +def test_cancel_evidence_on_transfer_toggle_off(): + assert Sn7(_toggles(transfer=False)).cancel_evidence(MINER, 1) == CANCEL_REASON_ALPHA_TRANSFER_DISABLED + assert Sn7(_toggles(subtoken=False)).cancel_evidence(MINER, 1) == CANCEL_REASON_ALPHA_TRANSFER_DISABLED + assert Sn7(_toggles()).cancel_evidence(MINER, 1) is None + assert Sn7(_toggles(transfer=False)).can_deliver_to(MINER, 1) is False + assert Sn7(_toggles(transfer=False)).delivery_refused(MINER, 0) is True + + +def test_unreadable_toggle_is_not_evidence(): + def boom(*a, **k): + raise RuntimeError('rpc down') + + p = Sn7(SimpleNamespace(substrate=SimpleNamespace(query=boom))) + assert p.can_deliver_to(MINER, 1) is True + assert p.delivery_refused(MINER, 0) is False + assert p.cancel_evidence(MINER, 1) is None + + +# ─── sending ──────────────────────────────────────────────────────────────── + + +class _Wallet: + coldkeypub = SimpleNamespace(ss58_address=MINER) + + +def _sender(stakes, *, response=None, calls=None): + calls = [] if calls is None else calls + receipt = SimpleNamespace(extrinsic_hash=TXID, block_hash='0xincl') + landed = SimpleNamespace(success=True, message='', extrinsic=_ext(), extrinsic_receipt=receipt) + + def transfer_stake(**kwargs): + calls.append(kwargs) + return landed if response is None else response + + subtensor = SimpleNamespace( + get_current_block=lambda: HEAD, + get_stake_info_for_coldkey=lambda ck: stakes, + transfer_stake=transfer_stake, + substrate=SimpleNamespace(get_block_number=lambda h: BLOCK), + ) + p = Sn7(subtensor, _Wallet()) + p.chain.get_block = lambda n: {'extrinsics': []} + p.chain.get_block_hash = lambda n: f'0xblock{n}' + return p, calls + + +def _payout_lands(p): + """The chain now shows the miner's settled transfer_stake to the user in BLOCK.""" + p.chain.get_block = lambda n: {'extrinsics': [_ext(dest=USER, sender=MINER)]} if n == BLOCK else {'extrinsics': []} + p.chain.get_block_events = lambda h: _settled_events() + + +def test_send_picks_the_largest_hotkey_and_disables_mev_protection(): + p, calls = _sender([_stake('small', 100), _stake('big', 9_000), _stake('other-subnet', 99_999, NETUID + 1)]) + assert p.send_amount(USER, 5_000, dedup_key='swap-1') == (TXID, BLOCK) + (call,) = calls + assert call['hotkey_ss58'] == 'big' + assert call['mev_protection'] is False + assert (call['origin_netuid'], call['destination_netuid'], call['destination_coldkey_ss58']) == ( + NETUID, + NETUID, + USER, + ) + assert call['amount'].rao == 5_000 + + +def test_send_reuses_a_prior_broadcast_per_dedup_key(): + """Dedup state lives on this asset, keyed per obligation — never on the shared Tao chain.""" + p, calls = _sender([_stake('hk', 9_000)]) + assert p.send_amount(USER, 5_000, dedup_key='swap-1') == (TXID, BLOCK) + _payout_lands(p) + assert p.send_amount(USER, 5_000, dedup_key='swap-1') == (TXID, BLOCK) + assert len(calls) == 1 + assert p.send_amount(USER, 5_000, dedup_key='swap-2') == (TXID, BLOCK) + assert len(calls) == 2 + assert not p.chain.broadcasted_txids + + +def test_ambiguous_response_records_the_attempt_and_the_next_call_adopts_the_landed_send(): + """The SDK folds a mid-submit exception into a hash-less failed response: never re-pay, resolve by content.""" + ambiguous = SimpleNamespace(success=False, message='ws dropped', extrinsic=None, extrinsic_receipt=None) + p, calls = _sender([_stake('hk', 9_000)], response=ambiguous) + assert p.send_amount(USER, 5_000, dedup_key='swap-1') is None + assert p.broadcasted_txids['swap-1'] == (USER, 5_000, '', HEAD) + _payout_lands(p) + assert p.send_amount(USER, 5_000, dedup_key='swap-1') == (TXID, BLOCK) + assert len(calls) == 1 + + +def test_failed_response_with_a_signed_extrinsic_keeps_its_hash(): + """A submit that raised after signing still names the extrinsic: the next poll probes that exact hash.""" + signed_only = SimpleNamespace(success=False, message='ws dropped', extrinsic=_ext(), extrinsic_receipt=None) + p, _ = _sender([_stake('hk', 9_000)], response=signed_only) + assert p.send_amount(USER, 5_000, dedup_key='swap-1') is None + assert p.broadcasted_txids['swap-1'][2] == TXID From 63d02fab64f0364c414a136e236473d094f81781 Mon Sep 17 00:00:00 2001 From: Landyn Date: Mon, 24 Aug 2026 19:24:21 -0500 Subject: [PATCH 08/15] Trim the lifted scan docstrings to one line --- allways/assets/tao.py | 16 +++++----------- tests/test_alpha_provider.py | 3 +-- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/allways/assets/tao.py b/allways/assets/tao.py index 356610a4..6c4c1439 100644 --- a/allways/assets/tao.py +++ b/allways/assets/tao.py @@ -409,10 +409,8 @@ def scan_for_tx( decode: Decoder, settle: Settler, ) -> Optional[TransactionInfo]: - """Locate ``tx_hash`` in the hinted block ±3 (or ``max_scan_blocks`` back, newest first) and return it - as a settled transfer to ``expected_recipient`` of >= ``expected_amount``, else None. ``decode`` reads - the asset's transfer call off an extrinsic; ``settle`` proves its funds moved and yields what did. - The ±3 window covers small clock/finality skews between the caller's hint and the landing block.""" + """Locate ``tx_hash`` (hinted block ±3, else ``max_scan_blocks`` back) as a settled transfer paying + ``expected_recipient`` >= ``expected_amount``, via the asset's ``decode`` and ``settle``.""" try: current_block = self.subtensor.get_current_block() except Exception as e: @@ -788,10 +786,8 @@ def prior_send_landed( decode: Decoder, settle: Settler, ) -> Optional[SendResult]: - """``(hash, block)`` of the send already recorded under ``scope`` for this obligation if it settled - on-chain; None when nothing matching is recorded or the record provably moved nothing (safe to - send). RAISES while the answer is unknown — the caller must not send. Anchors the record on the - landing block for later reuse.""" + """``(hash, block)`` if the send recorded under ``scope`` settled; None if none is recorded or it provably + did not land; raises while that is unknown.""" prior = broadcasted.get(scope) if prior is None or prior[0] != to_addr or prior[1] != int(amount): return None @@ -813,9 +809,7 @@ def prior_send_landed( return landed def record_send_attempt(self, broadcasted: Broadcasts, scope: str, to_addr: str, amount: int) -> int: - """Mark the attempt BEFORE the send and return the head it was seen at (0 when unreadable): the - extrinsic can reach the chain even when the call raises, and only a marker makes the next poll - resolve it by scan instead of blindly re-sending.""" + """Record the attempt BEFORE the send (an extrinsic can land even when the call raises); returns the head seen, 0 if unreadable.""" attempt_head = self.get_current_block_height() or 0 broadcasted[scope] = (to_addr, int(amount), '', attempt_head) if len(broadcasted) > 256: diff --git a/tests/test_alpha_provider.py b/tests/test_alpha_provider.py index 3dd58cbd..b83d9529 100644 --- a/tests/test_alpha_provider.py +++ b/tests/test_alpha_provider.py @@ -1,5 +1,4 @@ -"""Alpha legs settle on a top-level transfer_stake whose extrinsic succeeded AND emitted StakeTransferred, -with the alpha amount read from the CALL (the event carries the TAO-equivalent). Backends are stubbed.""" +"""Alpha settlement: a top-level transfer_stake that succeeded AND emitted StakeTransferred, amount from the CALL.""" from types import SimpleNamespace From 645b438a4f0abf663ac67e67219d03b8e252bf45 Mon Sep 17 00:00:00 2001 From: Landyn Date: Mon, 24 Aug 2026 19:27:45 -0500 Subject: [PATCH 09/15] Replace collateral_leg_amount with leg_value, pricing a declared alpha leg at spot --- allways/cli/swap_commands/swap_intake.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/allways/cli/swap_commands/swap_intake.py b/allways/cli/swap_commands/swap_intake.py index b4f123f3..4d63045a 100644 --- a/allways/cli/swap_commands/swap_intake.py +++ b/allways/cli/swap_commands/swap_intake.py @@ -1,7 +1,7 @@ """Taker swap-intake — miner selection + on-chain amount derivation. No click, no owned RPC config. Mirrors the contract: ``collateral_amount`` is the leg denominated in the quote's BACKING (the -bounded, collateral-backed notional) — ``backing.rs::collateral_leg_amount``, so a "sol"-backed +bounded, collateral-backed notional) — ``backing.rs::collateral_leg_bind``, so a "sol"-backed quote is sized against its SOL leg and a "tao"-backed one against its TAO leg, in rao. Uses the shared ``calculate_to_amount`` so the CLI's pinned amounts agree with the miner + validator byte-for-byte. Every launch pair has a hub leg (sol↔spoke / tao↔spoke); a spoke↔spoke pair is @@ -19,6 +19,7 @@ COLLATERAL_REQUIREMENT_BPS, NUMERAIRE_CHAIN, RATE_PRECISION, + family, hub_leg, required_collateral, ) @@ -167,13 +168,20 @@ def _bounds_for( return bounds_by_backing.get(backing, (min_swap, max_swap)) -def collateral_leg_amount(backing: str, from_chain: str, from_amount: int, to_chain: str, to_amount: int) -> int: - """The leg denominated in ``backing`` — the amount its collateral is sized against. Mirrors - ``backing.rs::collateral_leg_amount``: validity is "backing ∈ legs", nothing about the pair.""" +def leg_value(backing: str, from_chain: str, from_amount: int, to_chain: str, to_amount: int, providers=None) -> int: + """The backing's leg in the backing's units — twin of ``backing.rs::collateral_leg_bind``. Exact + when a leg IS the backing; a leg of the backing's family (an alpha) is DECLARED and priced at spot.""" if backing == from_chain: return from_amount if backing == to_chain: return to_amount + for leg, amount in ((from_chain, from_amount), (to_chain, to_amount)): + if family(leg) != backing: + continue + provider = (providers or {}).get(leg) + if provider is None: + raise ValueError(f'{leg} leg is declared: a {leg} provider is needed to price it in {backing}') + return provider.value_rao(amount) raise ValueError(f'{from_chain}->{to_chain}: no leg is denominated in the "{backing}" backing') @@ -183,12 +191,13 @@ def compute_intake_amounts( from_amount: int, rate_display: str, backing: str = NUMERAIRE_CHAIN, + providers=None, ) -> IntakeAmounts: """Derive (collateral_amount, from_amount, to_amount) for a swap of ``from_amount`` (source smallest-units). ``rate_display`` is the miner's canonical 'dest per 1 hub' rate. Requires one leg to be a hub. ``collateral_amount`` is the ``backing``'s leg, in that asset's own units — the figure - ``finalize_reservation`` bounds and collateralizes. + ``finalize_reservation`` bounds and collateralizes. ``providers`` prices a declared alpha leg. """ if hub_leg(from_chain, to_chain) is None: raise ValueError(f'{from_chain}->{to_chain}: a hub leg (sol or tao) is required (every pair is hub<->spoke)') @@ -197,7 +206,7 @@ def compute_intake_amounts( to_amount = calculate_to_amount( from_amount, rate_display, is_reverse, get_chain_def(canon_to).decimals, get_chain_def(canon_from).decimals ) - collateral_amount = collateral_leg_amount(backing, from_chain, from_amount, to_chain, to_amount) + collateral_amount = leg_value(backing, from_chain, from_amount, to_chain, to_amount, providers) return IntakeAmounts(collateral_amount=collateral_amount, from_amount=from_amount, to_amount=to_amount) From 46f5f7339285cd859f0e258d8f4293f7fc7c87a1 Mon Sep 17 00:00:00 2001 From: Landyn Date: Mon, 24 Aug 2026 19:28:48 -0500 Subject: [PATCH 10/15] Gate reserve and vote_initiate on the declared alpha leg covering at spot --- allways/validator/reserve_engine.py | 12 +++++++----- allways/validator/solana_swap_loop.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/allways/validator/reserve_engine.py b/allways/validator/reserve_engine.py index d52f4b4c..33ba32c7 100644 --- a/allways/validator/reserve_engine.py +++ b/allways/validator/reserve_engine.py @@ -113,7 +113,8 @@ def reserve_on_behalf( # Canonical source form at intake: the finalize hash + source-lock PDA are byte-keyed on this # string (V-C2), so a case variant of a live source would mint a second lock over one deposit. - src_asset = (getattr(validator, 'axon_assets', None) or {}).get(from_chain) + providers = getattr(validator, 'axon_assets', None) or {} + src_asset = providers.get(from_chain) if src_asset is not None: user_from_addr = src_asset.chain.normalize_address(user_from_addr) @@ -160,8 +161,10 @@ def reserve_on_behalf( return ReserveResult(False, 'miner is busy with another swap on that hub; try again shortly') try: - amts = compute_intake_amounts(from_chain, to_chain, from_amount, rate_display_from_fixed(rate_fixed), backing) - except ValueError as e: + amts = compute_intake_amounts( + from_chain, to_chain, from_amount, rate_display_from_fixed(rate_fixed), backing, providers + ) + except (ValueError, ProviderUnreachableError) as e: return ReserveResult(False, str(e)) if amts.to_amount <= 0: return ReserveResult(False, 'non-positive dest amount for that source amount') @@ -177,7 +180,6 @@ def reserve_on_behalf( # Deliverability gates — BEFORE any funds move: a dest that can't take delivery (malformed # address, or one that provably refuses transfers) must bounce here, not strand a paid swap # later. Format first: it's offline and a malformed address can never be delivered to. - providers = getattr(validator, 'axon_assets', {}) provider = providers.get(to_chain) miner_quote = quote or client.get_quote(miner_pk, from_chain, to_chain, backing) verified = getattr(validator, 'assets', None) @@ -409,7 +411,7 @@ def _collides(addr: str) -> bool: # The reservation lives at the queue's backing-seeded address, so the stored chain can # only agree; the fill is sized against THAT leg or the purse gate reads the wrong side. fill = compute_intake_amounts( - from_chain, to_chain, req['from_amount'], rate_display_from_fixed(resv.rate), backing + from_chain, to_chain, req['from_amount'], rate_display_from_fixed(resv.rate), backing, providers ) client.finalize_reservation( Pubkey.from_string(miner), diff --git a/allways/validator/solana_swap_loop.py b/allways/validator/solana_swap_loop.py index aca613f5..c1966e97 100644 --- a/allways/validator/solana_swap_loop.py +++ b/allways/validator/solana_swap_loop.py @@ -19,6 +19,7 @@ from allways import dev_signal from allways.assets.asset import ProviderUnreachableError from allways.chains import compute_extension_target_secs, get_chain_def +from allways.cli.swap_commands.swap_intake import leg_value from allways.constants import CANCEL_REASON_OTHER, EXTENSION_PADDING_SECONDS from allways.solana import pdas from allways.solana.client import benign_marker, swap_from_solana, swap_key_from_tx_hash @@ -383,6 +384,21 @@ def _decide_pending_attestation(self, swap: Any, now: int) -> SwapAction: return SwapAction( SwapDecision.REJECT, reason='dest == miner delivery address (poisoned) — refusing to attest' ) + # A declared alpha leg is bound off-chain (spec §5): the router's collateral_amount must cover it + # at spot, or the user's refund shrinks. An exact leg reads nothing — the program bound it. + try: + cover = leg_value( + str(swap.collateral_chain), + swap.from_chain, + int(swap.from_amount), + swap.to_chain, + int(swap.to_amount), + self.providers, + ) + except (ProviderUnreachableError, ValueError): + return SwapAction(SwapDecision.SKIP, reason='alpha price unreachable') + if int(swap.collateral_amount) < cover: + return SwapAction(SwapDecision.REJECT, reason='collateral does not cover the alpha leg at spot') # Source deposit must exist, confirm, be sent BY the reserved user, AND be fresh vs the # Reservation before we'd attest — sender pin matches the relay's confirm_deposit check. s_status, info = self._fetch_leg( From 7cef65d50ef0bc6594c3c5981f7b4e06f150254b Mon Sep 17 00:00:00 2001 From: Landyn Date: Mon, 24 Aug 2026 19:30:39 -0500 Subject: [PATCH 11/15] Refuse the send when the seat's collateral does not cover a declared alpha leg at spot --- allways/cli/swap_commands/swap.py | 43 +++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/allways/cli/swap_commands/swap.py b/allways/cli/swap_commands/swap.py index b504be0d..98d7c4e1 100644 --- a/allways/cli/swap_commands/swap.py +++ b/allways/cli/swap_commands/swap.py @@ -16,6 +16,7 @@ import click +from allways.assets.asset import ProviderUnreachableError from allways.chains import SUPPORTED_CHAINS, get_chain_def, uses_solana_wallet from allways.cli.dendrite_lite import ( broadcast_synapse, @@ -44,6 +45,7 @@ candidate_miners, compute_intake_amounts, hub_bounds, + leg_value, rate_display_from_fixed, select_best_miner, swap_viable, @@ -52,7 +54,7 @@ viable_intakes, ) from allways.cli.validator_rejections import render_and_aggregate -from allways.constants import FEE_DIVISOR, NETUID_FINNEY, NUMERAIRE_CHAIN, hub_leg +from allways.constants import FEE_DIVISOR, NETUID_FINNEY, NUMERAIRE_CHAIN, family, hub_leg from allways.solana import pdas from allways.solana.client import benign_marker, contract_reject_reason from allways.solana.rpc import TransientRpcError @@ -576,6 +578,7 @@ def swap_now_command( f'[green] Seat filled[/green] — receiving ~[cyan]{recv:.8g} {to_chain.upper()}[/cyan], ' f'[cyan]{backing_label(resv_backing)}[/cyan].' ) + _refuse_uncovered(client, config, resv, from_chain, to_chain) # Never instruct a send the reservation can't outlive: a deposit that lands after reserved_until # yields no claim, and the funds are stranded (straight to the miner — no escrow, no Swap, no # timeout, no refund). Confirmations accrue *after* the claim, so they don't belong in this margin. @@ -641,11 +644,41 @@ def _gate_provider(chain: str, client, config): return None avail = {'solana_rpc_url': client.rpc.url, 'solana_keypair': client.keypair} try: + if 'subtensor' in spec.kwarg_names: + avail['subtensor'] = get_cli_context(need_wallet=False)[2] return spec.cls(**{k: avail[k] for k in spec.kwarg_names if k in avail}) except Exception: # noqa: BLE001 - unbuildable provider (missing env) → screens fail open return None +def _declared_leg_providers(client, config, backing, from_chain, to_chain) -> dict: + """The provider that prices a DECLARED alpha leg, keyed by chain — empty when the backing's leg is + exact, so today's pairs build nothing and read nothing.""" + if not backing or backing in (from_chain, to_chain): + return {} + leg = from_chain if family(from_chain) == backing else to_chain + return {leg: _gate_provider(leg, client, config)} + + +def _refuse_uncovered(client, config, resv, from_chain, to_chain) -> None: + """A declared alpha leg is bound off-chain (spec §5): never send into a seat whose collateral does + not cover it at spot — that collateral is the refund. An exact leg was bound by the program.""" + backing = str(getattr(resv, 'collateral_chain', '') or '') + providers = _declared_leg_providers(client, config, backing, from_chain, to_chain) + if not providers: + return + (leg,) = providers + try: + cover = leg_value(backing, from_chain, int(resv.from_amount), to_chain, int(resv.to_amount), providers) + except (ValueError, ProviderUnreachableError) as e: + fail(f' Cannot price your {leg.upper()} leg ({e}). Do NOT send funds; re-run when the price is readable.') + if int(resv.collateral_amount) < cover: + fail( + f' The seat pins {int(resv.collateral_amount)} rao of collateral, under your {leg.upper()} leg at ' + f'spot ({cover} rao). Do NOT send funds; re-run for a fresh reservation.' + ) + + def _screen_deliverability(client, config, cand, from_chain, to_chain, receive_addr, user_from_addr, from_amount): """Pre-reserve deliverability screens — bounce BEFORE any fee is spent. @@ -941,7 +974,13 @@ def _reserve_self_represented( ) # Phase 3 — FINALIZE against the PINNED rate (not the live quote, which can drift after the bid). - fill = compute_intake_amounts(from_chain, to_chain, from_amount, rate_display_from_fixed(drawn.rate), backing) + providers = _declared_leg_providers(client, None, backing, from_chain, to_chain) + try: + fill = compute_intake_amounts( + from_chain, to_chain, from_amount, rate_display_from_fixed(drawn.rate), backing, providers + ) + except (ValueError, ProviderUnreachableError) as e: + fail(f' Cannot price the swap ({e}). Do NOT send funds; re-run shortly.') try: client.finalize_reservation( miner, From ceeb0ac42113d4ddadaef5196a01a446aed8b535 Mon Sep 17 00:00:00 2001 From: Landyn Date: Mon, 24 Aug 2026 19:34:29 -0500 Subject: [PATCH 12/15] Test leg_value and the cover gate against exact, declared, short, and unreachable legs --- tests/test_solana_swap_loop.py | 88 +++++++++++++++++++++++++++++++--- tests/test_tao_hub_pairs.py | 23 +++++++++ 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/tests/test_solana_swap_loop.py b/tests/test_solana_swap_loop.py index b07444f2..7bd5a2fd 100644 --- a/tests/test_solana_swap_loop.py +++ b/tests/test_solana_swap_loop.py @@ -33,7 +33,10 @@ def make_swap( from_chain='btc', to_chain='sol', collateral_chain='sol', + collateral_amount=None, ): + if collateral_amount is None: # the program's invariant: an exact backing leg IS collateral_amount + collateral_amount = from_amount if collateral_chain == from_chain else to_amount return SimpleNamespace( swap_key=key, miner='minerPK', @@ -41,6 +44,7 @@ def make_swap( from_chain=from_chain, to_chain=to_chain, collateral_chain=collateral_chain, + collateral_amount=collateral_amount, from_tx_hash='srctx', to_tx_hash='dsttx', miner_from_addr='minerBTC', @@ -356,6 +360,54 @@ def test_pending_attestation_absurd_to_amount_rejected(): assert loop.decide(swap, now=1500).decision == SwapDecision.REJECT +def _alpha_loop(value_rao): + """sol→sn7, tao-backed: the sn7 leg is DECLARED, so cover is read through the sn7 provider's spot.""" + loop, providers = loop_with(result=True) + providers['sn7'] = SimpleNamespace(value_rao=value_rao, chain=SimpleNamespace(normalize_address=str)) + swap = make_swap( + status='PendingAttestation', + from_chain='sol', + to_chain='sn7', + collateral_chain='tao', + from_amount=1_000_000_000, + to_amount=5_000_000_000, + collateral_amount=10**9, + ) + return loop, providers, swap + + +def test_pending_attestation_covered_alpha_leg_attests(): + loop, providers, swap = _alpha_loop(lambda amount: 10**9) + assert loop.decide(swap, now=1500).decision == SwapDecision.ATTEST + assert providers['sol'].calls # cover held → on to the source fetch + + +def test_pending_attestation_short_alpha_cover_rejected(): + loop, providers, swap = _alpha_loop(lambda amount: 10**9 + 1) # one rao short — binary, no band + action = loop.decide(swap, now=1500) + assert action.decision == SwapDecision.REJECT and 'cover' in action.reason + assert providers['sol'].calls == [] # refused before any source fetch + + +def test_pending_attestation_unreachable_alpha_price_skips(): + def down(amount): + raise ProviderUnreachableError('price') + + loop, _, swap = _alpha_loop(down) + action = loop.decide(swap, now=1500) + assert action.decision == SwapDecision.SKIP and 'price' in action.reason + + +def test_pending_attestation_exact_leg_never_reads_a_price(): + def never(amount): + raise AssertionError('an exact leg was priced') + + loop, providers = loop_with(result=True) + for provider in providers.values(): + provider.value_rao = never + assert loop.decide(make_swap(status='PendingAttestation'), now=1500).decision == SwapDecision.ATTEST + + def test_pending_attestation_to_amount_off_by_two_rejected(): loop, _ = loop_with(result=True) assert loop.decide(make_swap(status='PendingAttestation', to_amount=1002), now=1500).decision == SwapDecision.REJECT @@ -639,12 +691,23 @@ def timeout_swap(self, swap_key, miner, user): def test_run_once_casts_votes_per_decision(): swaps = [ - ('pk1', make_swap(status='PendingAttestation', key=b'\x01' * 32)), + # regression: the initiate vote must carry the swap's backing (a tao-backed sol→tao swap) + ( + 'pk1', + make_swap( + status='PendingAttestation', + key=b'\x01' * 32, + from_chain='sol', + to_chain='tao', + collateral_chain='tao', + from_amount=1_000_000_000, + to_amount=5_000_000_000, + ), + ), ('pk2', make_swap(status='Active', timeout_at=1000, key=b'\x02' * 32)), ('pk3', make_swap(status='Fulfilled', key=b'\x03' * 32)), ] swaps[1][1].user = 'USERPK' # timeout vote needs the user pubkey - swaps[0][1].collateral_chain = 'tao' # regression: the initiate vote must carry the swap's backing providers = {'btc': RecordingProvider(True), 'sol': RecordingProvider(True)} client = VoteRecordingClient(swaps) loop = SolanaSwapLoop(client, providers, fee_divisor=100) @@ -762,8 +825,15 @@ def has_pending_debit(self, miner): def _loop_with_relay(relay, backing='tao'): - swap = make_swap(status='PendingAttestation') - swap.collateral_chain = backing + # sol→tao: the one pair both purses can legally back. + swap = make_swap( + status='PendingAttestation', + from_chain='sol', + to_chain='tao', + collateral_chain=backing, + from_amount=1_000_000_000, + to_amount=5_000_000_000, + ) providers = {'btc': RecordingProvider(True), 'sol': RecordingProvider(True)} client = SimpleNamespace( get_swaps=lambda: [('pda', swap)], @@ -802,8 +872,14 @@ def test_the_loop_shows_the_relay_every_live_swap_it_walks(): def test_a_loop_with_no_relay_configured_decides_exactly_as_before(): - swap = make_swap(status='PendingAttestation') - swap.collateral_chain = 'tao' + swap = make_swap( + status='PendingAttestation', + from_chain='sol', + to_chain='tao', + collateral_chain='tao', + from_amount=1_000_000_000, + to_amount=5_000_000_000, + ) loop, _ = loop_with() assert loop.relay is None assert loop.decide(swap, now=1500).decision == SwapDecision.ATTEST diff --git a/tests/test_tao_hub_pairs.py b/tests/test_tao_hub_pairs.py index ce1a255f..9df6aa48 100644 --- a/tests/test_tao_hub_pairs.py +++ b/tests/test_tao_hub_pairs.py @@ -23,6 +23,7 @@ candidate_miners, compute_intake_amounts, hub_bounds, + leg_value, max_intake_from_amount, required_collateral, select_best_miner, @@ -44,6 +45,7 @@ from allways.validator.state_store import ValidatorStateStore TAO = 1_000_000_000 # 1 TAO in rao (9 dec) +SOL = 1_000_000_000 # 1 SOL in lamports (9 dec) ETH = 10**18 # 1 ETH in wei (18 dec) RATE = '0.05' # canonical 'ETH per 1 TAO' (~$200 TAO vs ~$4000 ETH) TAO_MIN, TAO_MAX = TAO // 10, TAO # deploy-config shape: 0.1 τ / 1 τ, in rao @@ -148,6 +150,27 @@ def test_spoke_spoke_pair_rejected(self): with pytest.raises(ValueError, match='hub leg'): compute_intake_amounts('btc', 'eth', 100, '20', backing='btc') + def test_leg_value_binds_an_exact_leg_without_a_provider(self): + # Exact first, either side — and sn7<->tao keeps the exact TAO leg, never a spot read. + assert leg_value('tao', 'tao', TAO, 'eth', ETH // 20) == TAO + assert leg_value('tao', 'eth', ETH // 20, 'tao', TAO) == TAO + assert leg_value('tao', 'sn7', 5 * TAO, 'tao', TAO) == TAO + assert leg_value('sol', 'sol', SOL, 'sn7', 5 * TAO) == SOL + + def test_leg_value_prices_a_declared_alpha_leg_at_spot(self): + sn7 = SimpleNamespace(value_rao=lambda amount: amount * 3) + assert leg_value('tao', 'sol', SOL, 'sn7', 5 * TAO, {'sn7': sn7}) == 15 * TAO + with pytest.raises(ValueError, match='provider'): + leg_value('tao', 'sol', SOL, 'sn7', 5 * TAO) + with pytest.raises(ValueError, match='no leg'): + leg_value('tao', 'sol', SOL, 'avax', 1, {'sn7': sn7}) + + def test_sol_to_sn7_is_sized_by_its_backing(self): + sn7 = SimpleNamespace(value_rao=lambda amount: 7 * TAO) + declared = compute_intake_amounts('sol', 'sn7', SOL, RATE, backing='tao', providers={'sn7': sn7}) + assert declared.collateral_amount == 7 * TAO + assert compute_intake_amounts('sol', 'sn7', SOL, RATE, backing='sol').collateral_amount == SOL + def test_viability_gates_on_rao_bounds(self): bounds = {'sol': (0, 0), 'tao': (TAO_MIN, TAO_MAX)} funded = MinerCandidate(object(), RATE, required_collateral(TAO), backing='tao') From ca0a9058b31008cda863c3ebb1e04d18b47d6b22 Mon Sep 17 00:00:00 2001 From: Landyn Date: Mon, 24 Aug 2026 19:42:36 -0500 Subject: [PATCH 13/15] Thread declared-leg providers through the miner selectors --- allways/cli/swap_commands/helpers.py | 38 +++++++++++- allways/cli/swap_commands/quote.py | 37 ++++------- allways/cli/swap_commands/swap.py | 75 +++++++++-------------- allways/cli/swap_commands/swap_intake.py | 21 ++++--- allways/validator/reserve_engine.py | 21 ++++--- tests/test_swap_now_backing_disclosure.py | 2 +- tests/test_swap_now_reservation.py | 8 +-- tests/test_swap_routed.py | 4 +- tests/test_tao_hub_pairs.py | 19 ++++++ 9 files changed, 132 insertions(+), 93 deletions(-) diff --git a/allways/cli/swap_commands/helpers.py b/allways/cli/swap_commands/helpers.py index 0d01666d..abea618e 100644 --- a/allways/cli/swap_commands/helpers.py +++ b/allways/cli/swap_commands/helpers.py @@ -18,7 +18,7 @@ from allways.chains import SUPPORTED_CHAINS, ChainDefinition from allways.classes import SwapStatus from allways.cli.swap_commands.swap_intake import backing_purse, floors_from_config -from allways.constants import NETUID_FINNEY, TAO_TO_RAO, declarable_backings +from allways.constants import NETUID_FINNEY, TAO_TO_RAO, declarable_backings, family from allways.solana import pdas from allways.solana.client import SolanaClientError from allways.solana.layouts import hub_busy_until, hub_swap_on, lock_max @@ -892,3 +892,39 @@ def _underfunded(state: PurseState) -> str: ) fix = 'alw collateral deposit' if state.backing == pdas.BACKING_CHAIN_SOL else 'alw vault post-collateral' return f'Your {state.backing.upper()} purse holds {state.purse} < the {state.floor} floor (`{fix}`).' + + +def gate_provider(chain: str, client, config): + """Read-only provider for deliverability screens (no send creds, no startup check). + None when it can't be built — the screens fail open; routed flows are re-gated by the + validator either way.""" + from allways.assets import ASSET_REGISTRY + + spec = next((s for s in ASSET_REGISTRY if s.chain_id == chain), None) + if spec is None: + return None + avail = {'solana_rpc_url': client.rpc.url, 'solana_keypair': client.keypair} + try: + if 'subtensor' in spec.kwarg_names: + avail['subtensor'] = get_cli_context(need_wallet=False)[2] + return spec.cls(**{k: avail[k] for k in spec.kwarg_names if k in avail}) + except Exception: # noqa: BLE001 - unbuildable provider (missing env) → screens fail open + return None + + +def declared_leg_providers(client, config, backing, from_chain, to_chain) -> dict: + """The provider that prices a DECLARED alpha leg, keyed by chain — empty when the backing's leg is + exact, so today's pairs build nothing and read nothing.""" + if not backing or backing in (from_chain, to_chain): + return {} + leg = from_chain if family(from_chain) == backing else to_chain + return {leg: gate_provider(leg, client, config)} + + +def candidate_providers(client, config, candidates, from_chain, to_chain) -> dict: + """The declared-leg providers every selector shares, built once per distinct backing on offer — + an exact-leg market (sol<->btc) builds nothing.""" + providers: dict = {} + for backing in dict.fromkeys(c.backing for c in candidates): + providers.update(declared_leg_providers(client, config, backing, from_chain, to_chain)) + return providers diff --git a/allways/cli/swap_commands/quote.py b/allways/cli/swap_commands/quote.py index 80b9c8ee..a62c523c 100644 --- a/allways/cli/swap_commands/quote.py +++ b/allways/cli/swap_commands/quote.py @@ -15,6 +15,7 @@ from allways.cli.swap_commands.helpers import ( FINITE_DECIMAL, backing_label, + candidate_providers, console, fail, get_solana_cli_context, @@ -27,15 +28,14 @@ MinerCandidate, backing_purse, bounds_from_config, - compute_intake_amounts, hub_bounds, rate_display_from_fixed, select_best_miner, - swap_viable, to_smallest_units, + viable_intakes, ) from allways.constants import FEE_DIVISOR, hub_leg -from allways.utils.rate import apply_fee_deduction, directional_rate, is_executable_rate +from allways.utils.rate import apply_fee_deduction, directional_rate # The failure guarantee each backing carries. It differs in TIMING, not in whether you are made # whole — say that plainly rather than making a taker infer it from the asset name. @@ -117,28 +117,15 @@ def quote_command(from_chain: str, to_chain: str, amount: Decimal, as_json: bool ) ) - # Build the viable set with the same guards the contract enforces, and identify the best offer. - viable = [] # (candidate, receive_units) - for c in candidates: - try: - rate = float(c.rate_display) - except (TypeError, ValueError): - continue - if not is_executable_rate(rate, from_chain, to_chain, min_swap, max_swap): - continue - try: - amts = compute_intake_amounts(from_chain, to_chain, from_amount, c.rate_display, c.backing) - except ValueError: - continue - if amts.to_amount <= 0: - continue - lo, hi = bounds.get(c.backing, (min_swap, max_swap)) - ok, _reason = swap_viable(amts.collateral_amount, c.collateral, lo, hi, c.backing) - if not ok: - continue - viable.append((c, apply_fee_deduction(amts.to_amount, FEE_DIVISOR))) - - best = select_best_miner(candidates, from_chain, to_chain, from_amount, min_swap, max_swap, bounds) + # The same gates the contract enforces, priced with the same providers the origination path uses. + providers = candidate_providers(client, {}, candidates, from_chain, to_chain) + viable = [ + (c, apply_fee_deduction(amts.to_amount, FEE_DIVISOR)) + for c, amts in viable_intakes( + candidates, from_chain, to_chain, from_amount, min_swap, max_swap, bounds, providers + ) + ] + best = select_best_miner(candidates, from_chain, to_chain, from_amount, min_swap, max_swap, bounds, providers) best_miner = str(best[0].miner) if best else None if as_json: diff --git a/allways/cli/swap_commands/swap.py b/allways/cli/swap_commands/swap.py index 98d7c4e1..87b7b925 100644 --- a/allways/cli/swap_commands/swap.py +++ b/allways/cli/swap_commands/swap.py @@ -31,8 +31,11 @@ FINITE_DECIMAL, PENDING_SWAP_FILE, backing_label, + candidate_providers, console, + declared_leg_providers, fail, + gate_provider, get_cli_context, get_solana_cli_context, hotkey_bytes_to_ss58, @@ -48,13 +51,12 @@ leg_value, rate_display_from_fixed, select_best_miner, - swap_viable, to_smallest_units, unviable_reason, viable_intakes, ) from allways.cli.validator_rejections import render_and_aggregate -from allways.constants import FEE_DIVISOR, NETUID_FINNEY, NUMERAIRE_CHAIN, family, hub_leg +from allways.constants import FEE_DIVISOR, NETUID_FINNEY, NUMERAIRE_CHAIN, hub_leg from allways.solana import pdas from allways.solana.client import benign_marker, contract_reject_reason from allways.solana.rpc import TransientRpcError @@ -200,7 +202,9 @@ def _net_receive(to_amount: int, to_chain: str) -> float: return apply_fee_deduction(to_amount, FEE_DIVISOR) / 10 ** get_chain_def(to_chain).decimals -def _named_intake(miner_opt, candidates, viable, from_chain, to_chain, from_amount, min_swap, max_swap, bounds=None): +def _named_intake( + miner_opt, candidates, viable, from_chain, to_chain, from_amount, min_swap, max_swap, bounds, providers +): """Resolve an explicit --miner pubkey against the same gates auto-select uses. Hard-fails with the specific reason — never silently falls back to another miner.""" chosen = next((p for p in viable if str(p[0].miner) == miner_opt), None) @@ -209,10 +213,8 @@ def _named_intake(miner_opt, candidates, viable, from_chain, to_chain, from_amou cand = next((c for c in candidates if str(c.miner) == miner_opt), None) if cand is None: fail(f'Miner {miner_opt[:8]}… is not active or not quoting {from_chain}->{to_chain}.') - amts = compute_intake_amounts(from_chain, to_chain, from_amount, cand.rate_display, cand.backing) - lo, hi = (bounds or {}).get(cand.backing, (min_swap, max_swap)) - _, reason = swap_viable(amts.collateral_amount, cand.collateral, lo, hi, cand.backing) - fail(f'Miner {miner_opt[:8]}… cannot take this swap: {reason or "rate not executable"}.') + reason = unviable_reason([cand], from_chain, to_chain, from_amount, min_swap, max_swap, bounds, providers) + fail(f'Miner {miner_opt[:8]}… cannot take this swap: {reason}.') def _pick_intake(viable, from_chain, to_chain): @@ -408,7 +410,7 @@ def swap_now_command( fail(f'--from-address (your source-chain address) is required for a non-{NUMERAIRE_CHAIN.upper()} source.') # Canonical source form before anything commits it: the finalize hash + source-lock PDA are # byte-keyed on this string (V-C2), so a cased variant would mint a divergent lock on-chain. - _src_gate = _gate_provider(from_chain, client, config) + _src_gate = gate_provider(from_chain, client, config) if _src_gate is not None: user_from_addr = _src_gate.chain.normalize_address(user_from_addr) @@ -422,17 +424,20 @@ def swap_now_command( candidates = candidate_miners(client, from_chain, to_chain) if not candidates: fail(f'No miners quoting {from_chain}->{to_chain} right now.') + providers = candidate_providers(client, config, candidates, from_chain, to_chain) if miner_opt: - viable = viable_intakes(candidates, from_chain, to_chain, from_amount, min_swap, max_swap, bounds) + viable = viable_intakes(candidates, from_chain, to_chain, from_amount, min_swap, max_swap, bounds, providers) if not viable: - reason = unviable_reason(candidates, from_chain, to_chain, from_amount, min_swap, max_swap, bounds) + reason = unviable_reason( + candidates, from_chain, to_chain, from_amount, min_swap, max_swap, bounds, providers + ) fail(f'No miner can take this swap: {reason}.') best_to = max(p[1].to_amount for p in viable) if miner_opt == _MINER_PICK: cand, amts = _pick_intake(viable, from_chain, to_chain) else: cand, amts = _named_intake( - miner_opt, candidates, viable, from_chain, to_chain, from_amount, min_swap, max_swap, bounds + miner_opt, candidates, viable, from_chain, to_chain, from_amount, min_swap, max_swap, bounds, providers ) if amts.to_amount < best_to * (1 - MINER_RATE_WARN_FRACTION): pct = (1 - amts.to_amount / best_to) * 100 @@ -441,9 +446,11 @@ def swap_now_command( f'(~{_net_receive(best_to, to_chain):.8g} {to_chain.upper()}).' ) else: - best = select_best_miner(candidates, from_chain, to_chain, from_amount, min_swap, max_swap, bounds) + best = select_best_miner(candidates, from_chain, to_chain, from_amount, min_swap, max_swap, bounds, providers) if best is None: - reason = unviable_reason(candidates, from_chain, to_chain, from_amount, min_swap, max_swap, bounds) + reason = unviable_reason( + candidates, from_chain, to_chain, from_amount, min_swap, max_swap, bounds, providers + ) fail(f'No miner can take this swap: {reason}.') cand, amts = best @@ -466,7 +473,12 @@ def swap_now_command( # quote (which can drift after the pool opened and would show a receive the fill won't honor). pinned = contention.is_open and (contention.from_chain, contention.to_chain) == (from_chain, to_chain) if pinned and contention.rate > 0: - amts = compute_intake_amounts(from_chain, to_chain, from_amount, rate_display_from_fixed(contention.rate)) + try: + amts = compute_intake_amounts( + from_chain, to_chain, from_amount, rate_display_from_fixed(contention.rate), cand.backing, providers + ) + except (ValueError, ProviderUnreachableError) as e: + fail(f' Cannot price the pinned pool rate ({e}). Re-run shortly.') # Quote the NET dest leg — the miner delivers `to_amount` less the protocol fee, same as # `alw swap quote`. The gross `to_amount` is what gets pinned on-chain, not what you receive. recv = _net_receive(amts.to_amount, to_chain) @@ -633,38 +645,11 @@ def _deadline_lines(reserved_until: int, want_send: bool, now: Optional[int] = N return lines -def _gate_provider(chain: str, client, config): - """Read-only provider for deliverability screens (no send creds, no startup check). - None when it can't be built — the screens fail open; routed flows are re-gated by the - validator either way.""" - from allways.assets import ASSET_REGISTRY - - spec = next((s for s in ASSET_REGISTRY if s.chain_id == chain), None) - if spec is None: - return None - avail = {'solana_rpc_url': client.rpc.url, 'solana_keypair': client.keypair} - try: - if 'subtensor' in spec.kwarg_names: - avail['subtensor'] = get_cli_context(need_wallet=False)[2] - return spec.cls(**{k: avail[k] for k in spec.kwarg_names if k in avail}) - except Exception: # noqa: BLE001 - unbuildable provider (missing env) → screens fail open - return None - - -def _declared_leg_providers(client, config, backing, from_chain, to_chain) -> dict: - """The provider that prices a DECLARED alpha leg, keyed by chain — empty when the backing's leg is - exact, so today's pairs build nothing and read nothing.""" - if not backing or backing in (from_chain, to_chain): - return {} - leg = from_chain if family(from_chain) == backing else to_chain - return {leg: _gate_provider(leg, client, config)} - - def _refuse_uncovered(client, config, resv, from_chain, to_chain) -> None: """A declared alpha leg is bound off-chain (spec §5): never send into a seat whose collateral does not cover it at spot — that collateral is the refund. An exact leg was bound by the program.""" backing = str(getattr(resv, 'collateral_chain', '') or '') - providers = _declared_leg_providers(client, config, backing, from_chain, to_chain) + providers = declared_leg_providers(client, config, backing, from_chain, to_chain) if not providers: return (leg,) = providers @@ -688,14 +673,14 @@ def _screen_deliverability(client, config, cand, from_chain, to_chain, receive_a miner's receive address must accept the source funds (T18). The source-address probe is a courtesy warning only — a frozen source just means the deposit fails and the reservation lapses unclaimed. A leg whose provider can't be built read-only fails open, as before.""" - dest_provider = _gate_provider(to_chain, client, config) + dest_provider = gate_provider(to_chain, client, config) quote = client.get_quote(cand.miner, from_chain, to_chain, cand.backing) if dest_provider is not None: # Validity only — deliverability is NOT predicted at reserve time (not a boundary; the sound # check is the delivery-time reverted-tx proof). A malformed address can never be delivered to. if not dest_provider.chain.is_valid_address(receive_addr): fail(f' {receive_addr!r} is not a valid {to_chain.upper()} address. No funds moved.') - src_provider = _gate_provider(from_chain, client, config) + src_provider = gate_provider(from_chain, client, config) if src_provider is None: return miner_addr = getattr(quote, 'miner_from_addr', '') if quote else '' @@ -974,7 +959,7 @@ def _reserve_self_represented( ) # Phase 3 — FINALIZE against the PINNED rate (not the live quote, which can drift after the bid). - providers = _declared_leg_providers(client, None, backing, from_chain, to_chain) + providers = declared_leg_providers(client, None, backing, from_chain, to_chain) try: fill = compute_intake_amounts( from_chain, to_chain, from_amount, rate_display_from_fixed(drawn.rate), backing, providers diff --git a/allways/cli/swap_commands/swap_intake.py b/allways/cli/swap_commands/swap_intake.py index 4d63045a..d6092dfd 100644 --- a/allways/cli/swap_commands/swap_intake.py +++ b/allways/cli/swap_commands/swap_intake.py @@ -14,6 +14,7 @@ from decimal import Decimal from typing import Dict, List, Optional, Tuple +from allways.assets.asset import ProviderUnreachableError from allways.chains import canonical_pair, get_chain_def from allways.constants import ( COLLATERAL_REQUIREMENT_BPS, @@ -258,13 +259,15 @@ def viable_intakes( min_swap: int, max_swap: int, bounds_by_backing: Optional[BoundsByBacking] = None, + providers=None, ) -> List[Tuple[MinerCandidate, IntakeAmounts]]: """Every candidate passing the executable-rate + viability gates, with derived amounts. Stable input order. The single gating path shared by auto-select and --miner. ``min_swap``/``max_swap`` are the pair's HUB-leg bounds (``hub_bounds``): ``is_executable_rate`` is the crown/squat heuristic about a rate nobody can route, defined on the hub leg. The purse + - size gate below is the per-backing one.""" + size gate below is the per-backing one. ``providers`` prices a declared alpha leg; an offer whose + leg cannot be priced is unviable, never a crash.""" out: List[Tuple[MinerCandidate, IntakeAmounts]] = [] for c in candidates: try: @@ -274,9 +277,9 @@ def viable_intakes( if not is_executable_rate(rate, from_chain, to_chain, min_swap, max_swap): continue try: - amts = compute_intake_amounts(from_chain, to_chain, from_amount, c.rate_display, c.backing) - except ValueError: - continue # backing not in this pair's legs — the contract would refuse it too + amts = compute_intake_amounts(from_chain, to_chain, from_amount, c.rate_display, c.backing, providers) + except (ValueError, ProviderUnreachableError): + continue # unpriceable leg, or a backing outside this pair — the contract would refuse it too if amts.to_amount <= 0: continue lo, hi = _bounds_for(c.backing, bounds_by_backing, min_swap, max_swap) @@ -343,6 +346,7 @@ def unviable_reason( min_swap: int, max_swap: int, bounds_by_backing: Optional[BoundsByBacking] = None, + providers=None, ) -> str: """Why nothing was quotable — the gates of ``viable_intakes``, spelled out for the taker. @@ -359,8 +363,8 @@ def unviable_reason( reasons.append('rate not executable') continue try: - amts = compute_intake_amounts(from_chain, to_chain, from_amount, c.rate_display, c.backing) - except ValueError as e: + amts = compute_intake_amounts(from_chain, to_chain, from_amount, c.rate_display, c.backing, providers) + except (ValueError, ProviderUnreachableError) as e: reasons.append(str(e)) continue if amts.to_amount <= 0: @@ -385,6 +389,7 @@ def select_best_miner( min_swap: int, max_swap: int, bounds_by_backing: Optional[BoundsByBacking] = None, + providers=None, ) -> Optional[Tuple[MinerCandidate, IntakeAmounts]]: """Among executable + viable miners, pick the one giving the user the most dest (``to_amount``). @@ -392,5 +397,7 @@ def select_best_miner( tie only, toward "sol": at identical value the instant-SOL-refund guarantee is strictly better for the taker than a TAO reimbursement that lands shortly after the timeout. Remaining ties fall back to first-seen (stable input order).""" - viable = viable_intakes(candidates, from_chain, to_chain, from_amount, min_swap, max_swap, bounds_by_backing) + viable = viable_intakes( + candidates, from_chain, to_chain, from_amount, min_swap, max_swap, bounds_by_backing, providers + ) return max(viable, key=lambda p: (p[1].to_amount, p[0].backing == NUMERAIRE_CHAIN), default=None) diff --git a/allways/validator/reserve_engine.py b/allways/validator/reserve_engine.py index 33ba32c7..b43f8dcc 100644 --- a/allways/validator/reserve_engine.py +++ b/allways/validator/reserve_engine.py @@ -55,12 +55,13 @@ def resolve_miner_pubkey(validator, miner_hotkey: str) -> Optional[Pubkey]: return hk_binding.miner -def _best_offer(client, miner_pk, miner_state, from_chain: str, to_chain: str, from_amount: int, bounds): +def _best_offer(client, miner_pk, miner_state, from_chain, to_chain, from_amount, bounds, providers=None): """The offer of this miner's that gives the user the most — one market per pair, mixed by rate (D2), NOT a preference for either purse. Reuses the taker's selector, so a routed user and a self-represented one pick the same offer, including its exact-tie preference for "sol". - Returns ``((quote, backing), '')`` or ``(None, reason)`` — the reason is the taker-facing one - from the same gate set, so a routed rejection reads like a self-represented one.""" + ``providers`` prices a declared alpha leg. Returns ``((quote, backing), '')`` or ``(None, reason)`` + — the reason is the taker-facing one from the same gate set, so a routed rejection reads like a + self-represented one.""" offers = {} candidates = [] for q in client.get_quotes_for_direction(miner_pk, from_chain, to_chain) or []: @@ -75,9 +76,10 @@ def _best_offer(client, miner_pk, miner_state, from_chain: str, to_chain: str, f if not candidates: return None, f'miner has no quote for {from_chain}->{to_chain}' hub_min, hub_max = hub_bounds(bounds, from_chain, to_chain) - best = select_best_miner(candidates, from_chain, to_chain, from_amount, hub_min, hub_max, bounds) + best = select_best_miner(candidates, from_chain, to_chain, from_amount, hub_min, hub_max, bounds, providers) if best is None: - return None, unviable_reason(candidates, from_chain, to_chain, from_amount, hub_min, hub_max, bounds) + why = unviable_reason(candidates, from_chain, to_chain, from_amount, hub_min, hub_max, bounds, providers) + return None, why return (offers[best[0].backing], best[0].backing), '' @@ -150,7 +152,7 @@ def reserve_on_behalf( rate_fixed = pool.rate # pinned at open — joiners must quote against it quote = client.get_quote(miner_pk, from_chain, to_chain, backing) else: - offer, why = _best_offer(client, miner_pk, miner_state, from_chain, to_chain, from_amount, bounds) + offer, why = _best_offer(client, miner_pk, miner_state, from_chain, to_chain, from_amount, bounds, providers) if offer is None: return ReserveResult(False, why) quote, backing = offer @@ -667,9 +669,12 @@ def rate_quote(validator, from_chain: str, to_chain: str, from_amount: int) -> R bounds = bounds_from_config(cfg) min_swap, max_swap = hub_bounds(bounds, from_chain, to_chain) cands = candidate_miners(client, from_chain, to_chain) - best = select_best_miner(cands, from_chain, to_chain, from_amount, min_swap, max_swap, bounds) + providers = getattr(validator, 'axon_assets', None) or {} + best = select_best_miner(cands, from_chain, to_chain, from_amount, min_swap, max_swap, bounds, providers) bq = _best_quote_result(validator, best) if best else None - reason = '' if bq else unviable_reason(cands, from_chain, to_chain, from_amount, min_swap, max_swap, bounds) + reason = ( + '' if bq else unviable_reason(cands, from_chain, to_chain, from_amount, min_swap, max_swap, bounds, providers) + ) depth: dict = {} for cand in cands: cap = max_intake_from_amount(cand, from_chain, to_chain, min_swap, max_swap, bounds) diff --git a/tests/test_swap_now_backing_disclosure.py b/tests/test_swap_now_backing_disclosure.py index a5919b13..38dfaf6d 100644 --- a/tests/test_swap_now_backing_disclosure.py +++ b/tests/test_swap_now_backing_disclosure.py @@ -41,7 +41,7 @@ def _run(client, *, argv_extra=(), confirm_input=None): argv += ['--receive-address', USER, *argv_extra] with ( patch('allways.cli.swap_commands.swap.get_solana_cli_context', return_value=({}, client)), - patch('allways.cli.swap_commands.swap._gate_provider', return_value=None), + patch('allways.cli.swap_commands.swap.gate_provider', return_value=None), patch('allways.cli.swap_commands.swap.candidate_miners', return_value=[cand]), patch('allways.cli.swap_commands.swap.select_best_miner', return_value=(cand, amts)), patch('allways.cli.swap_commands.swap._save_pending'), diff --git a/tests/test_swap_now_reservation.py b/tests/test_swap_now_reservation.py index 52f7d529..e6f4bf25 100644 --- a/tests/test_swap_now_reservation.py +++ b/tests/test_swap_now_reservation.py @@ -134,7 +134,7 @@ def _run_swap_now(reserved_until, from_chain='btc'): with ( patch('allways.cli.swap_commands.swap.get_solana_cli_context', return_value=(None, client)), - patch('allways.cli.swap_commands.swap._gate_provider', return_value=None), + patch('allways.cli.swap_commands.swap.gate_provider', return_value=None), patch('allways.cli.swap_commands.swap.candidate_miners', return_value=[cand]), patch('allways.cli.swap_commands.swap.select_best_miner', return_value=(cand, amts)), patch('allways.cli.swap_commands.swap._poll_drawn', return_value=drawn), @@ -321,7 +321,7 @@ def _run_resume(existing, poll_resv): ] with ( patch('allways.cli.swap_commands.swap.get_solana_cli_context', return_value=(None, client)), - patch('allways.cli.swap_commands.swap._gate_provider', return_value=None), + patch('allways.cli.swap_commands.swap.gate_provider', return_value=None), patch('allways.cli.swap_commands.swap.candidate_miners', return_value=[cand]), patch('allways.cli.swap_commands.swap.select_best_miner', return_value=(cand, amts)), patch( @@ -496,7 +496,7 @@ def _screen(gate, from_chain, to_chain, client=None): from allways.cli.swap_commands.swap import _screen_deliverability cand = types.SimpleNamespace(miner='miner-pk', rate_display='150', backing='sol') - with patch('allways.cli.swap_commands.swap._gate_provider', return_value=gate): + with patch('allways.cli.swap_commands.swap.gate_provider', return_value=gate): _screen_deliverability(client or MagicMock(), {}, cand, from_chain, to_chain, 'recvaddr', 'useraddr', 10**6) return gate @@ -569,7 +569,7 @@ def test_screen_rejection_aborts_swap_now_before_any_bid(): argv = ['--from', 'sol', '--to', 'btc', '--amount', '0.001', '--receive-address', 'userBTCaddr', '--yes'] with ( patch('allways.cli.swap_commands.swap.get_solana_cli_context', return_value=(None, client)), - patch('allways.cli.swap_commands.swap._gate_provider', return_value=_Gate(reject={'minerSOLaddr'})), + patch('allways.cli.swap_commands.swap.gate_provider', return_value=_Gate(reject={'minerSOLaddr'})), patch('allways.cli.swap_commands.swap.candidate_miners', return_value=[cand]), patch('allways.cli.swap_commands.swap.select_best_miner', return_value=(cand, amts)), patch('allways.cli.swap_commands.swap._save_pending'), diff --git a/tests/test_swap_routed.py b/tests/test_swap_routed.py index bc3a9274..a10861a6 100644 --- a/tests/test_swap_routed.py +++ b/tests/test_swap_routed.py @@ -63,7 +63,7 @@ def _run(client, *, argv_extra=(), responses=None, axon=AXON, config=None, confi info = types.SimpleNamespace(headline='router unreachable', accepted=0) with ( patch('allways.cli.swap_commands.swap.get_solana_cli_context', return_value=(config, client)), - patch('allways.cli.swap_commands.swap._gate_provider', return_value=None), + patch('allways.cli.swap_commands.swap.gate_provider', return_value=None), patch('allways.cli.swap_commands.swap.candidate_miners', return_value=[cand]), patch('allways.cli.swap_commands.swap.select_best_miner', return_value=(cand, amts)), patch('allways.cli.swap_commands.swap.find_validator_axon', return_value=axon) as find_axon, @@ -266,7 +266,7 @@ def test_stale_cached_axon_refreshes_once_then_succeeds(): info = types.SimpleNamespace(headline='no response', accepted=0) with ( patch('allways.cli.swap_commands.swap.get_solana_cli_context', return_value=(None, client)), - patch('allways.cli.swap_commands.swap._gate_provider', return_value=None), + patch('allways.cli.swap_commands.swap.gate_provider', return_value=None), patch('allways.cli.swap_commands.swap.candidate_miners', return_value=[cand]), patch('allways.cli.swap_commands.swap.select_best_miner', return_value=(cand, amts)), patch('allways.cli.swap_commands.swap.find_validator_axon', side_effect=[stale, fresh]) as find_axon, diff --git a/tests/test_tao_hub_pairs.py b/tests/test_tao_hub_pairs.py index 9df6aa48..0336692e 100644 --- a/tests/test_tao_hub_pairs.py +++ b/tests/test_tao_hub_pairs.py @@ -27,6 +27,7 @@ max_intake_from_amount, required_collateral, select_best_miner, + unviable_reason, viable_intakes, ) from allways.constants import ( @@ -171,6 +172,24 @@ def test_sol_to_sn7_is_sized_by_its_backing(self): assert declared.collateral_amount == 7 * TAO assert compute_intake_amounts('sol', 'sn7', SOL, RATE, backing='sol').collateral_amount == SOL + def test_selectors_route_a_declared_leg_only_with_its_provider(self): + sn7 = SimpleNamespace(value_rao=lambda amount: 7 * TAO) + offer = MinerCandidate(object(), RATE, required_collateral(7 * TAO), backing='tao') + bounds = {'tao': (TAO_MIN, 10 * TAO)} + best = select_best_miner([offer], 'sol', 'sn7', SOL, 0, 0, bounds, {'sn7': sn7}) + assert best is not None and best[1].collateral_amount == 7 * TAO + assert select_best_miner([offer], 'sol', 'sn7', SOL, 0, 0, bounds) is None + assert 'provider' in unviable_reason([offer], 'sol', 'sn7', SOL, 0, 0, bounds) + + def test_exact_leg_selection_never_reads_a_price(self): + def boom(amount): + raise AssertionError('exact leg priced at spot') + + offer = MinerCandidate(object(), RATE, required_collateral(TAO), backing='tao') + assert select_best_miner( + [offer], 'tao', 'eth', TAO, TAO_MIN, TAO_MAX, None, {'tao': SimpleNamespace(value_rao=boom)} + ) + def test_viability_gates_on_rao_bounds(self): bounds = {'sol': (0, 0), 'tao': (TAO_MIN, TAO_MAX)} funded = MinerCandidate(object(), RATE, required_collateral(TAO), backing='tao') From 2e0a704fb47e5b5d9b923f830c252e3fbac1a88d Mon Sep 17 00:00:00 2001 From: Landyn Date: Mon, 24 Aug 2026 19:44:33 -0500 Subject: [PATCH 14/15] Trim cover-gate docstrings and test the routed alpha offer --- allways/cli/swap_commands/helpers.py | 10 +++------- allways/cli/swap_commands/swap.py | 3 +-- allways/cli/swap_commands/swap_intake.py | 6 ++---- allways/validator/reserve_engine.py | 5 ++--- allways/validator/seam_http.py | 24 ++++++------------------ tests/test_tao_hub_pairs.py | 12 +++++++++++- 6 files changed, 25 insertions(+), 35 deletions(-) diff --git a/allways/cli/swap_commands/helpers.py b/allways/cli/swap_commands/helpers.py index abea618e..a4fa30b6 100644 --- a/allways/cli/swap_commands/helpers.py +++ b/allways/cli/swap_commands/helpers.py @@ -895,9 +895,7 @@ def _underfunded(state: PurseState) -> str: def gate_provider(chain: str, client, config): - """Read-only provider for deliverability screens (no send creds, no startup check). - None when it can't be built — the screens fail open; routed flows are re-gated by the - validator either way.""" + """Read-only provider for the CLI's screens; None when unbuildable (screens fail open, the validator re-gates).""" from allways.assets import ASSET_REGISTRY spec = next((s for s in ASSET_REGISTRY if s.chain_id == chain), None) @@ -913,8 +911,7 @@ def gate_provider(chain: str, client, config): def declared_leg_providers(client, config, backing, from_chain, to_chain) -> dict: - """The provider that prices a DECLARED alpha leg, keyed by chain — empty when the backing's leg is - exact, so today's pairs build nothing and read nothing.""" + """The provider pricing a DECLARED alpha leg, keyed by chain — empty for an exact leg (builds nothing).""" if not backing or backing in (from_chain, to_chain): return {} leg = from_chain if family(from_chain) == backing else to_chain @@ -922,8 +919,7 @@ def declared_leg_providers(client, config, backing, from_chain, to_chain) -> dic def candidate_providers(client, config, candidates, from_chain, to_chain) -> dict: - """The declared-leg providers every selector shares, built once per distinct backing on offer — - an exact-leg market (sol<->btc) builds nothing.""" + """Declared-leg providers shared by every selector, built once per backing on offer.""" providers: dict = {} for backing in dict.fromkeys(c.backing for c in candidates): providers.update(declared_leg_providers(client, config, backing, from_chain, to_chain)) diff --git a/allways/cli/swap_commands/swap.py b/allways/cli/swap_commands/swap.py index 87b7b925..624d5b85 100644 --- a/allways/cli/swap_commands/swap.py +++ b/allways/cli/swap_commands/swap.py @@ -646,8 +646,7 @@ def _deadline_lines(reserved_until: int, want_send: bool, now: Optional[int] = N def _refuse_uncovered(client, config, resv, from_chain, to_chain) -> None: - """A declared alpha leg is bound off-chain (spec §5): never send into a seat whose collateral does - not cover it at spot — that collateral is the refund. An exact leg was bound by the program.""" + """Never send into a seat whose collateral does not cover a declared alpha leg at spot — that collateral is the refund.""" backing = str(getattr(resv, 'collateral_chain', '') or '') providers = declared_leg_providers(client, config, backing, from_chain, to_chain) if not providers: diff --git a/allways/cli/swap_commands/swap_intake.py b/allways/cli/swap_commands/swap_intake.py index d6092dfd..8bf4b3ea 100644 --- a/allways/cli/swap_commands/swap_intake.py +++ b/allways/cli/swap_commands/swap_intake.py @@ -170,8 +170,7 @@ def _bounds_for( def leg_value(backing: str, from_chain: str, from_amount: int, to_chain: str, to_amount: int, providers=None) -> int: - """The backing's leg in the backing's units — twin of ``backing.rs::collateral_leg_bind``. Exact - when a leg IS the backing; a leg of the backing's family (an alpha) is DECLARED and priced at spot.""" + """The backing leg in backing units (twin of ``backing.rs::collateral_leg_bind``): exact, or a declared alpha leg priced at spot.""" if backing == from_chain: return from_amount if backing == to_chain: @@ -266,8 +265,7 @@ def viable_intakes( ``min_swap``/``max_swap`` are the pair's HUB-leg bounds (``hub_bounds``): ``is_executable_rate`` is the crown/squat heuristic about a rate nobody can route, defined on the hub leg. The purse + - size gate below is the per-backing one. ``providers`` prices a declared alpha leg; an offer whose - leg cannot be priced is unviable, never a crash.""" + size gate below is the per-backing one. ``providers`` prices a declared alpha leg (unpriceable = unviable).""" out: List[Tuple[MinerCandidate, IntakeAmounts]] = [] for c in candidates: try: diff --git a/allways/validator/reserve_engine.py b/allways/validator/reserve_engine.py index b43f8dcc..5d7bd99f 100644 --- a/allways/validator/reserve_engine.py +++ b/allways/validator/reserve_engine.py @@ -59,9 +59,8 @@ def _best_offer(client, miner_pk, miner_state, from_chain, to_chain, from_amount """The offer of this miner's that gives the user the most — one market per pair, mixed by rate (D2), NOT a preference for either purse. Reuses the taker's selector, so a routed user and a self-represented one pick the same offer, including its exact-tie preference for "sol". - ``providers`` prices a declared alpha leg. Returns ``((quote, backing), '')`` or ``(None, reason)`` - — the reason is the taker-facing one from the same gate set, so a routed rejection reads like a - self-represented one.""" + Returns ``((quote, backing), '')`` or ``(None, reason)`` — the reason is the taker-facing one + from the same gate set, so a routed rejection reads like a self-represented one.""" offers = {} candidates = [] for q in client.get_quotes_for_direction(miner_pk, from_chain, to_chain) or []: diff --git a/allways/validator/seam_http.py b/allways/validator/seam_http.py index 58ec6a92..525c78b1 100644 --- a/allways/validator/seam_http.py +++ b/allways/validator/seam_http.py @@ -28,32 +28,20 @@ SEAM_HOST = os.environ.get('ALLWAYS_SEAM_HOST', '127.0.0.1') -# The offering polls /status and /deposit-scan per active swap on a tight loop, and its -# list view reconciles every live row per poll — so identical reads arrive from many tabs and -# many rows at once, each costing uncached getAccountInfo. Sustained hard enough that earns the -# validator an RPC 429, and a validator that cannot read the chain cannot verify a swap: the -# miner holding the reservation is the one who eats the timeout. A chatty consumer must not be -# able to price the validator out of its own RPC budget. -# -# Sized to the consumer's fastest poll, not longer: past that the TTL — not the caller's -# cadence — sets how fast a stage transition surfaces, buying no extra dedup (a burst of tabs -# and reconciled rows lands within ~1s) at the cost of latency on every transition. Cadence is -# the lever for fewer reads; this one only collapses simultaneous ones. +# Dedupes the identical reads the offering fires at once (many tabs, and its list view +# reconciles every live row per poll) — uncached, that earned the validator an RPC 429. +# Held at the consumer's fastest poll: longer would delay stage transitions without deduping more. SEAM_READ_TTL_SECS = 3.0 def _ttl_bucket() -> int: - """Cache key component that rolls every TTL — a new bucket is a miss, old ones age out of - the LRU. Far shorter than any transition the offering reacts to (reservations live minutes, - dest legs need tens of seconds of confirmations), so a read this stale changes no decision.""" + """Key component that rolls every TTL: a new bucket misses, old ones age out of the LRU.""" return int(time.monotonic() / SEAM_READ_TTL_SECS) def _make_handler(validator, secret: str): - # Memos are per server, closing over this validator rather than keying on it: the validator - # is not required to be hashable, and no module-level table pins it alive. lru_cache does not - # store exceptions, so a transient RPC fault is retried rather than pinned for the bucket; - # maxsize caps the table so a long-lived seam can't grow an entry per swap ever polled. + # Closed over, not keyed on: the validator need not be hashable. lru_cache skips storing + # exceptions, so a transient RPC fault retries instead of pinning for the bucket. @lru_cache(maxsize=512) def cached_status(miner_hotkey: str, swap_key: str, _bucket: int): return swap_status(validator, miner_hotkey, swap_key) diff --git a/tests/test_tao_hub_pairs.py b/tests/test_tao_hub_pairs.py index 0336692e..628a8766 100644 --- a/tests/test_tao_hub_pairs.py +++ b/tests/test_tao_hub_pairs.py @@ -42,7 +42,7 @@ is_hub, ) from allways.utils.rate import is_executable_rate, min_executable_hub_leg -from allways.validator.reserve_engine import reserve_on_behalf +from allways.validator.reserve_engine import _best_offer, reserve_on_behalf from allways.validator.state_store import ValidatorStateStore TAO = 1_000_000_000 # 1 TAO in rao (9 dec) @@ -308,6 +308,16 @@ def test_quote_prices_and_selector_routes(self): assert amts.to_amount == ETH // 20 # priced off the canonical 'ETH per 1 TAO' quote assert amts.collateral_amount == TAO # the contract's backing-leg notional, in rao + def test_routed_offer_prices_a_declared_alpha_leg_through_the_validator_providers(self): + client = TaoHubClient() + client.quote.from_chain, client.quote.to_chain = 'sol', 'sn7' + sn7 = SimpleNamespace(value_rao=lambda amount: TAO // 2) # inside the fixture's TAO bounds + bounds = bounds_from_config(client.get_config()) + offer, _ = _best_offer(client, MINER_PK, client.miner_state, 'sol', 'sn7', SOL, bounds, {'sn7': sn7}) + assert offer == (client.quote, 'tao') + offer, why = _best_offer(client, MINER_PK, client.miner_state, 'sol', 'sn7', SOL, bounds) + assert offer is None and 'provider' in why + def test_routed_reservation_bids_the_tao_backing(self): client = TaoHubClient() store = ValidatorStateStore(db_path=Path(tempfile.mkdtemp()) / 'state.db') From 8ee30c5b9d24a692c49228d2db352a0349801703 Mon Sep 17 00:00:00 2001 From: Landyn Date: Mon, 24 Aug 2026 19:55:04 -0500 Subject: [PATCH 15/15] Retire hub-only wording and the dead config parameter after the family rule --- allways/assets/alpha.py | 6 ++++-- allways/cli/swap_commands/helpers.py | 10 +++++----- allways/cli/swap_commands/quote.py | 2 +- allways/cli/swap_commands/swap.py | 16 ++++++++-------- allways/cli/swap_commands/swap_intake.py | 10 +++++----- allways/constants.py | 8 ++++---- allways/validator/solana_swap_loop.py | 4 ++-- tests/test_tao_hub_pairs.py | 2 +- 8 files changed, 30 insertions(+), 28 deletions(-) diff --git a/allways/assets/alpha.py b/allways/assets/alpha.py index 6daeb086..e19cd963 100644 --- a/allways/assets/alpha.py +++ b/allways/assets/alpha.py @@ -231,8 +231,10 @@ def send_amount( tx_hash = getattr(receipt, 'extrinsic_hash', None) or Tao.extrinsic_hash(getattr(response, 'extrinsic', None)) if tx_hash: self.broadcasted_txids[scope] = (to_address, int(amount), tx_hash, attempt_head) - if not response.success: - bt.logging.error(f'{LOG_ALPHA} transfer_stake failed: {response.message} — recorded, resolved next poll') + if not response.success or not tx_hash: + bt.logging.error( + f'{LOG_ALPHA} transfer_stake unresolved: {response.message} — recorded, resolved next poll' + ) return None try: block_num = int(self.subtensor.substrate.get_block_number(receipt.block_hash)) diff --git a/allways/cli/swap_commands/helpers.py b/allways/cli/swap_commands/helpers.py index a4fa30b6..82dbd487 100644 --- a/allways/cli/swap_commands/helpers.py +++ b/allways/cli/swap_commands/helpers.py @@ -894,7 +894,7 @@ def _underfunded(state: PurseState) -> str: return f'Your {state.backing.upper()} purse holds {state.purse} < the {state.floor} floor (`{fix}`).' -def gate_provider(chain: str, client, config): +def gate_provider(chain: str, client): """Read-only provider for the CLI's screens; None when unbuildable (screens fail open, the validator re-gates).""" from allways.assets import ASSET_REGISTRY @@ -910,17 +910,17 @@ def gate_provider(chain: str, client, config): return None -def declared_leg_providers(client, config, backing, from_chain, to_chain) -> dict: +def declared_leg_providers(client, backing, from_chain, to_chain) -> dict: """The provider pricing a DECLARED alpha leg, keyed by chain — empty for an exact leg (builds nothing).""" if not backing or backing in (from_chain, to_chain): return {} leg = from_chain if family(from_chain) == backing else to_chain - return {leg: gate_provider(leg, client, config)} + return {leg: gate_provider(leg, client)} -def candidate_providers(client, config, candidates, from_chain, to_chain) -> dict: +def candidate_providers(client, candidates, from_chain, to_chain) -> dict: """Declared-leg providers shared by every selector, built once per backing on offer.""" providers: dict = {} for backing in dict.fromkeys(c.backing for c in candidates): - providers.update(declared_leg_providers(client, config, backing, from_chain, to_chain)) + providers.update(declared_leg_providers(client, backing, from_chain, to_chain)) return providers diff --git a/allways/cli/swap_commands/quote.py b/allways/cli/swap_commands/quote.py index a62c523c..6fe29f61 100644 --- a/allways/cli/swap_commands/quote.py +++ b/allways/cli/swap_commands/quote.py @@ -118,7 +118,7 @@ def quote_command(from_chain: str, to_chain: str, amount: Decimal, as_json: bool ) # The same gates the contract enforces, priced with the same providers the origination path uses. - providers = candidate_providers(client, {}, candidates, from_chain, to_chain) + providers = candidate_providers(client, candidates, from_chain, to_chain) viable = [ (c, apply_fee_deduction(amts.to_amount, FEE_DIVISOR)) for c, amts in viable_intakes( diff --git a/allways/cli/swap_commands/swap.py b/allways/cli/swap_commands/swap.py index 624d5b85..91921a73 100644 --- a/allways/cli/swap_commands/swap.py +++ b/allways/cli/swap_commands/swap.py @@ -410,7 +410,7 @@ def swap_now_command( fail(f'--from-address (your source-chain address) is required for a non-{NUMERAIRE_CHAIN.upper()} source.') # Canonical source form before anything commits it: the finalize hash + source-lock PDA are # byte-keyed on this string (V-C2), so a cased variant would mint a divergent lock on-chain. - _src_gate = gate_provider(from_chain, client, config) + _src_gate = gate_provider(from_chain, client) if _src_gate is not None: user_from_addr = _src_gate.chain.normalize_address(user_from_addr) @@ -424,7 +424,7 @@ def swap_now_command( candidates = candidate_miners(client, from_chain, to_chain) if not candidates: fail(f'No miners quoting {from_chain}->{to_chain} right now.') - providers = candidate_providers(client, config, candidates, from_chain, to_chain) + providers = candidate_providers(client, candidates, from_chain, to_chain) if miner_opt: viable = viable_intakes(candidates, from_chain, to_chain, from_amount, min_swap, max_swap, bounds, providers) if not viable: @@ -590,7 +590,7 @@ def swap_now_command( f'[green] Seat filled[/green] — receiving ~[cyan]{recv:.8g} {to_chain.upper()}[/cyan], ' f'[cyan]{backing_label(resv_backing)}[/cyan].' ) - _refuse_uncovered(client, config, resv, from_chain, to_chain) + _refuse_uncovered(client, resv, from_chain, to_chain) # Never instruct a send the reservation can't outlive: a deposit that lands after reserved_until # yields no claim, and the funds are stranded (straight to the miner — no escrow, no Swap, no # timeout, no refund). Confirmations accrue *after* the claim, so they don't belong in this margin. @@ -645,10 +645,10 @@ def _deadline_lines(reserved_until: int, want_send: bool, now: Optional[int] = N return lines -def _refuse_uncovered(client, config, resv, from_chain, to_chain) -> None: +def _refuse_uncovered(client, resv, from_chain, to_chain) -> None: """Never send into a seat whose collateral does not cover a declared alpha leg at spot — that collateral is the refund.""" backing = str(getattr(resv, 'collateral_chain', '') or '') - providers = declared_leg_providers(client, config, backing, from_chain, to_chain) + providers = declared_leg_providers(client, backing, from_chain, to_chain) if not providers: return (leg,) = providers @@ -672,14 +672,14 @@ def _screen_deliverability(client, config, cand, from_chain, to_chain, receive_a miner's receive address must accept the source funds (T18). The source-address probe is a courtesy warning only — a frozen source just means the deposit fails and the reservation lapses unclaimed. A leg whose provider can't be built read-only fails open, as before.""" - dest_provider = gate_provider(to_chain, client, config) + dest_provider = gate_provider(to_chain, client) quote = client.get_quote(cand.miner, from_chain, to_chain, cand.backing) if dest_provider is not None: # Validity only — deliverability is NOT predicted at reserve time (not a boundary; the sound # check is the delivery-time reverted-tx proof). A malformed address can never be delivered to. if not dest_provider.chain.is_valid_address(receive_addr): fail(f' {receive_addr!r} is not a valid {to_chain.upper()} address. No funds moved.') - src_provider = gate_provider(from_chain, client, config) + src_provider = gate_provider(from_chain, client) if src_provider is None: return miner_addr = getattr(quote, 'miner_from_addr', '') if quote else '' @@ -958,7 +958,7 @@ def _reserve_self_represented( ) # Phase 3 — FINALIZE against the PINNED rate (not the live quote, which can drift after the bid). - providers = declared_leg_providers(client, None, backing, from_chain, to_chain) + providers = declared_leg_providers(client, backing, from_chain, to_chain) try: fill = compute_intake_amounts( from_chain, to_chain, from_amount, rate_display_from_fixed(drawn.rate), backing, providers diff --git a/allways/cli/swap_commands/swap_intake.py b/allways/cli/swap_commands/swap_intake.py index 8bf4b3ea..28ad78d8 100644 --- a/allways/cli/swap_commands/swap_intake.py +++ b/allways/cli/swap_commands/swap_intake.py @@ -4,8 +4,8 @@ bounded, collateral-backed notional) — ``backing.rs::collateral_leg_bind``, so a "sol"-backed quote is sized against its SOL leg and a "tao"-backed one against its TAO leg, in rao. Uses the shared ``calculate_to_amount`` so the CLI's pinned amounts agree with the miner + validator -byte-for-byte. Every launch pair has a hub leg (sol↔spoke / tao↔spoke); a spoke↔spoke pair is -rejected here. The one network-touching helper (``candidate_miners``) takes the Solana client as a +byte-for-byte. Every launch pair has an anchor leg (``hub_leg``: a hub or an alpha); a spoke↔spoke pair +is rejected here. The one network-touching helper (``candidate_miners``) takes the Solana client as a parameter, so the CLI taker path and the validator reserve engine build the same candidate set from the same reads. """ @@ -153,7 +153,7 @@ def bounds_from_config(cfg) -> BoundsByBacking: def hub_bounds(bounds: BoundsByBacking, from_chain: str, to_chain: str) -> Tuple[int, int]: """The pair's HUB-leg swap bounds, in the hub's own smallest unit — what the rate-executability gates (``is_executable_rate`` / selection scalars) anchor on. (0, 0) = unset/permissive for a - pair with no hub leg. Distinct from the per-BACKING size gate: sol↔tao is SOL-anchored here even + pair whose anchor is not a hub (an alpha anchor has no hub bounds — see the PR3 gate). Distinct from the per-BACKING size gate: sol↔tao is SOL-anchored here even when a tao-backed quote's size is gated on the TAO bounds.""" hub = hub_leg(from_chain, to_chain) return bounds.get(hub, (0, 0)) if hub else (0, 0) @@ -195,12 +195,12 @@ def compute_intake_amounts( ) -> IntakeAmounts: """Derive (collateral_amount, from_amount, to_amount) for a swap of ``from_amount`` (source smallest-units). - ``rate_display`` is the miner's canonical 'dest per 1 hub' rate. Requires one leg to be a hub. + ``rate_display`` is the miner's canonical 'dest per 1 anchor' rate. Requires an anchor leg (``hub_leg``). ``collateral_amount`` is the ``backing``'s leg, in that asset's own units — the figure ``finalize_reservation`` bounds and collateralizes. ``providers`` prices a declared alpha leg. """ if hub_leg(from_chain, to_chain) is None: - raise ValueError(f'{from_chain}->{to_chain}: a hub leg (sol or tao) is required (every pair is hub<->spoke)') + raise ValueError(f'{from_chain}->{to_chain}: no anchor leg (a hub or an alpha) — not a valid pair') canon_from, canon_to = canonical_pair(from_chain, to_chain) is_reverse = from_chain != canon_from to_amount = calculate_to_amount( diff --git a/allways/constants.py b/allways/constants.py index 1bf0af4e..58ac2e30 100644 --- a/allways/constants.py +++ b/allways/constants.py @@ -62,7 +62,7 @@ # undeliverable through no fault of the miner. Python-side first; mirror into constants.rs next release. CANCEL_REASON_SPL_FROZEN = 5 # The subnet owner/root disabled alpha transfers (TransferToggle / SubtokenEnabled): strands every -# miner on that subnet at once — no-fault. Python-side first; mirror into constants.rs next release. +# miner on that subnet at once — no-fault. Mirrored in constants.rs. CANCEL_REASON_ALPHA_TRANSFER_DISABLED = 6 CANCEL_REASON_OTHER = 255 @@ -90,7 +90,7 @@ REWARD_MINER_STATES: frozenset[MinerActivity] = frozenset({MinerActivity.AVAILABLE}) # Hub (collateral-capable) chains, PRIORITY-ORDERED: the earlier hub anchors a hub↔hub pair, so # sol↔tao stays SOL-anchored (grandfathered — existing quotes keep their stored convention). -# A pair is valid iff one leg is a hub; that leg is its pricing + bounds anchor ('dest per 1 hub'). +# A pair is valid iff one leg is a hub or an alpha; hub_leg() names that anchor ('dest per 1 anchor'). HUB_CHAINS = ('sol', 'tao') # The SOL constant — the Solana ledger's own asset (reservation fee, local collateral purse, # the `alw miner quotes` default hub). "Is this the pair's hub" reads go through hub_leg() instead. @@ -99,11 +99,11 @@ def family(chain: str) -> str: """The backing family a chain settles in (twin of ``backing.rs::family``): an sn alpha settles in TAO.""" - return 'tao' if re.fullmatch(r'sn\d+', chain) else chain + return 'tao' if re.fullmatch(r'sn[0-9]+', chain) else chain def is_hub(chain: str) -> bool: - """True iff ``chain`` can anchor a pair (and back quotes with its own collateral purse).""" + """True iff ``chain`` backs quotes with its own collateral purse (a literal hub, not an alpha).""" return chain in HUB_CHAINS diff --git a/allways/validator/solana_swap_loop.py b/allways/validator/solana_swap_loop.py index c1966e97..eaa3afe0 100644 --- a/allways/validator/solana_swap_loop.py +++ b/allways/validator/solana_swap_loop.py @@ -395,8 +395,8 @@ def _decide_pending_attestation(self, swap: Any, now: int) -> SwapAction: int(swap.to_amount), self.providers, ) - except (ProviderUnreachableError, ValueError): - return SwapAction(SwapDecision.SKIP, reason='alpha price unreachable') + except (ProviderUnreachableError, ValueError) as e: + return SwapAction(SwapDecision.SKIP, reason=f'alpha leg unpriceable: {e}') if int(swap.collateral_amount) < cover: return SwapAction(SwapDecision.REJECT, reason='collateral does not cover the alpha leg at spot') # Source deposit must exist, confirm, be sent BY the reserved user, AND be fresh vs the diff --git a/tests/test_tao_hub_pairs.py b/tests/test_tao_hub_pairs.py index 628a8766..9e6fda87 100644 --- a/tests/test_tao_hub_pairs.py +++ b/tests/test_tao_hub_pairs.py @@ -148,7 +148,7 @@ def test_eth_to_tao_amounts(self): assert a.collateral_amount == TAO def test_spoke_spoke_pair_rejected(self): - with pytest.raises(ValueError, match='hub leg'): + with pytest.raises(ValueError, match='anchor leg'): compute_intake_amounts('btc', 'eth', 100, '20', backing='btc') def test_leg_value_binds_an_exact_leg_without_a_provider(self):