Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
28eeb4d
Drop redundant sender check and back-compat cache shim
LandynDev Apr 15, 2026
7d0b04a
Wrap verify_transaction with shared confirmed/sender checks
LandynDev Apr 15, 2026
2ab6eac
Factor null-retry counter out of SwapTracker refresh loop
LandynDev Apr 15, 2026
8864846
Tighten expected_sender to strict and fold SwapVerifier into it
LandynDev Apr 15, 2026
f8accc5
Move event pruning from per-tick poll to scoring round
LandynDev Apr 15, 2026
47155d1
Batch commitment reads, drop poll gate and rate throttle
LandynDev Apr 15, 2026
58eb61a
Shorten V1 scoring window from 12h to 4h
LandynDev Apr 15, 2026
a2d628f
Roll credibility ledger on a 30-day window
LandynDev Apr 15, 2026
d999f96
Add type hints for class-instance params across hot paths
LandynDev Apr 15, 2026
3e6c8e9
Track per-miner busy intervals from swap events
LandynDev Apr 15, 2026
aab9bac
Exclude busy miners from crown-time credit
LandynDev Apr 15, 2026
2fdebac
Add unit coverage for the query_map commitment read
LandynDev Apr 15, 2026
55d0591
Refactor crown-time replay with a typed event shape
LandynDev Apr 15, 2026
07bc78a
Rewrite crown_holders_at_instant to read the way the incentive reads
LandynDev Apr 15, 2026
d6c6f47
Drop leading-underscore "private" naming from helpers
LandynDev Apr 15, 2026
1550988
Extract scoring pipeline into its own module, rewrite forward() flow
LandynDev Apr 15, 2026
32391b4
Replace sent-cache tuples with a SentSwap dataclass
LandynDev Apr 15, 2026
31a51f5
Backfill event watcher on cold start, halve retention to 1x window
LandynDev Apr 15, 2026
883c8cd
Drop EVENT_RETENTION_BLOCKS and SCORING_INTERVAL_STEPS aliases
LandynDev Apr 16, 2026
aab0eb5
Rename helpers, reorder scoring pass, drop redundant size guard
LandynDev Apr 16, 2026
bb8c5dd
Skip extend_reservation on confirmed tx, inline EXTEND_THRESHOLD into…
LandynDev Apr 16, 2026
0cb1164
Track extend votes to skip redundant extrinsics
LandynDev Apr 16, 2026
0781f2d
Strip overdocumentation across validator modules
LandynDev Apr 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 60 additions & 3 deletions allways/chain_providers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from dataclasses import dataclass
from typing import Any, Optional, Tuple

import bittensor as bt

from allways.chains import ChainDefinition


Expand Down Expand Up @@ -42,17 +44,72 @@ def check_connection(self, **kwargs) -> None:
...

@abstractmethod
def verify_transaction(
def fetch_matching_tx(
self, tx_hash: str, expected_recipient: str, expected_amount: int, block_hint: int = 0
) -> Optional[TransactionInfo]:
"""Verify a transaction; returns TransactionInfo if found, None if not found,
raises ProviderUnreachableError on transient failures.
"""Chain-specific fetch — return TransactionInfo if the tx exists and matches
recipient + amount, otherwise None. Raises ProviderUnreachableError on
transient backend failures.

Uses >= for amount (overpayment is acceptable on-chain).
block_hint: If > 0, providers can use this for O(1) lookup instead of scanning.

Not called directly by application code — use ``verify_transaction``,
which wraps this with the common confirmed/sender post-checks.
"""
...

def verify_transaction(
self,
tx_hash: str,
expected_recipient: str,
expected_amount: int,
block_hint: int = 0,
expected_sender: Optional[str] = None,
require_confirmed: bool = False,
) -> Optional[TransactionInfo]:
"""Verify a transaction against the shared post-fetch checklist.

Dispatches to the provider's ``fetch_matching_tx`` for the chain-specific
scan, then applies the common checks every caller cares about:

- ``require_confirmed`` — if True, reject txs that don't have enough
confirmations for the chain. Default False, because axon/pending-confirm
flows want the partial TransactionInfo so they can queue and retry.
- ``expected_sender`` — if provided, reject txs whose sender doesn't
match. Strict: an empty/unparseable sender from the provider also
fails the check, since we can't prove the tx came from the reserved
address. Closing this gap prevents a "malformed-input evades the
defense" class of attack.

Rejections are logged once in the base so observability for the defense
is in one place instead of duplicated at every call site.
"""
tx_info = self.fetch_matching_tx(
tx_hash=tx_hash,
expected_recipient=expected_recipient,
expected_amount=expected_amount,
block_hint=block_hint,
)
if tx_info is None:
return None

if require_confirmed and not tx_info.confirmed:
bt.logging.debug(
f'verify_transaction: tx {tx_hash[:16]}... not yet confirmed '
f'({tx_info.confirmations}/{self.get_chain().min_confirmations})'
)
return None

if expected_sender and tx_info.sender != expected_sender:
bt.logging.warning(
f'verify_transaction: sender mismatch on tx {tx_hash[:16]}... '
f'(expected {expected_sender}, got {tx_info.sender!r})'
)
return None

return tx_info

@abstractmethod
def get_balance(self, address: str) -> int: ...

Expand Down
4 changes: 2 additions & 2 deletions allways/chain_providers/bitcoin.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,10 @@ def rpc_call(self, method: str, params: Optional[list] = None) -> Optional[dict]
bt.logging.error(f'BTC RPC call failed ({method}): {e}')
return None

def verify_transaction(
def fetch_matching_tx(
self, tx_hash: str, expected_recipient: str, expected_amount: int, block_hint: int = 0
) -> Optional[TransactionInfo]:
"""Verify a Bitcoin transaction via RPC with Blockstream fallback."""
"""Look up a Bitcoin tx via RPC with Blockstream fallback."""
result = self.rpc_verify_transaction(tx_hash, expected_recipient, expected_amount)
if result is not None:
return result
Expand Down
6 changes: 4 additions & 2 deletions allways/chain_providers/subtensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,14 +165,16 @@ def get_block_raw(self, block_num: int, block_hash: str) -> Optional[dict]:
bt.logging.debug(f'Raw block fetch failed for block {block_num}: {e}')
return None

def verify_transaction(
def fetch_matching_tx(
self, tx_hash: str, expected_recipient: str, expected_amount: int, block_hint: int = 0
) -> Optional[TransactionInfo]:
"""Verify a TAO transfer; raises ProviderUnreachableError if subtensor is unreachable.
"""Scan for a TAO transfer matching recipient + amount.

If block_hint > 0, checks the hinted block ±3. Otherwise scans the last
150 blocks. The ±3 window covers small clock/finality skews between the
caller's block_hint and the block the transfer actually landed in.

Raises ProviderUnreachableError if subtensor is unreachable.
"""
try:
current_block = self.subtensor.get_current_block()
Expand Down
16 changes: 14 additions & 2 deletions allways/cli/dendrite_lite.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
"""

from pathlib import Path
from typing import List, Optional

import bittensor as bt

from allways.contract_client import AllwaysContractClient

EPHEMERAL_WALLET_DIR = Path.home() / '.allways' / 'ephemeral_wallet'
EPHEMERAL_WALLET_NAME = 'allways_ephemeral'
EPHEMERAL_HOTKEY_NAME = 'default'
Expand All @@ -34,7 +37,11 @@ def get_ephemeral_wallet() -> bt.Wallet:
return wallet


def discover_validators(subtensor: bt.Subtensor, netuid: int, contract_client=None) -> list:
def discover_validators(
subtensor: bt.Subtensor,
netuid: int,
contract_client: Optional[AllwaysContractClient] = None,
) -> List[bt.AxonInfo]:
"""Discover validator axon endpoints from metagraph.

Filters for UIDs with validator_permit=True and is_serving=True.
Expand All @@ -61,7 +68,12 @@ def discover_validators(subtensor: bt.Subtensor, netuid: int, contract_client=No
return axons


def broadcast_synapse(wallet: bt.Wallet, axons: list, synapse, timeout: float = 30.0) -> list:
def broadcast_synapse(
wallet: bt.Wallet,
axons: List[bt.AxonInfo],
synapse: bt.Synapse,
timeout: float = 30.0,
) -> list:
"""Broadcast a synapse to all validator axons via dendrite.

Returns list of response synapses.
Expand Down
34 changes: 25 additions & 9 deletions allways/commitments.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,17 +128,33 @@ def read_miner_commitment(


def read_miner_commitments(subtensor: bt.Subtensor, netuid: int) -> List[MinerPair]:
"""Read all miner commitments from chain, parse into MinerPair list."""
pairs = []
"""Read all miner commitments for the netuid in a single RPC call.

Uses substrate-interface's ``query_map`` over the ``CommitmentOf`` double map
keyed by ``(netuid, hotkey)``. One RPC round-trip returns every committed
hotkey on the subnet — cheaper than the old N-RPC for-loop, matters most
on full validator polling cadence.
"""
pairs: List[MinerPair] = []
try:
metagraph = subtensor.metagraph(netuid)
for uid in range(metagraph.n.item()):
hotkey = metagraph.hotkeys[uid]
commitment = get_commitment(subtensor, netuid, hotkey)
if commitment:
pair = parse_commitment_data(commitment, uid=uid, hotkey=hotkey)
if pair:
pairs.append(pair)
hotkey_to_uid = {metagraph.hotkeys[uid]: uid for uid in range(metagraph.n.item())}
result = subtensor.substrate.query_map(
module='Commitments',
storage_function='CommitmentOf',
params=[netuid],
)
for key, metadata in result:
hotkey = str(key.value) if hasattr(key, 'value') else str(key)
uid = hotkey_to_uid.get(hotkey)
if uid is None:
continue # miner dereg'd but commitment still in storage
commitment = decode_commitment_field(metadata)
if not commitment:
continue
pair = parse_commitment_data(commitment, uid=uid, hotkey=hotkey)
if pair:
pairs.append(pair)
except (ConnectionError, TimeoutError) as e:
bt.logging.warning(f'Transient error reading commitments: {e}')
except Exception as e:
Expand Down
72 changes: 27 additions & 45 deletions allways/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,81 +3,63 @@
NETUID_LOCAL = 2

# ─── Contract ──────────────────────────────────────────────
# Default mainnet address; override via CONTRACT_ADDRESS env var for testnets
# or alternate deployments.
# Mainnet default; override via CONTRACT_ADDRESS env var.
CONTRACT_ADDRESS = '5FTkUEhRmLPsALn4b7bJpVFhDQqohGbc6khnmA2aiYFLMZYP'

# ─── Polling ──────────────────────────────────────────────
# Bittensor base neuron loop heartbeatnot the scoring / forward cadence.
# Bittensor base-neuron heartbeat, not the scoring/forward cadence.
MINER_POLL_INTERVAL_SECONDS = 12
VALIDATOR_POLL_INTERVAL_SECONDS = 12

# ─── Commitment Format ────────────────────────────────────
COMMITMENT_VERSION = 1

# ─── Unit Conversions ────────────────────────────────────
TAO_TO_RAO = 1_000_000_000 # 1 TAO = 10^9 rao
BTC_TO_SAT = 100_000_000 # 1 BTC = 10^8 satoshi
TAO_TO_RAO = 1_000_000_000
BTC_TO_SAT = 100_000_000

# ─── Rate Encoding ───────────────────────────────────────
RATE_PRECISION = 10**18 # Fixed-point precision for on-chain rate storage
RATE_PRECISION = 10**18

# ─── Transaction Fees ────────────────────────────────────
MIN_BALANCE_FOR_TX_RAO = 250_000_000 # 0.25 TAO minimum for extrinsic fees
BTC_MIN_FEE_RATE = 2 # sat/vB — minimum BTC fee rate floor to avoid stuck txs
BTC_MIN_FEE_RATE = 2 # sat/vB — floor to avoid stuck txs

# ─── Miner ───────────────────────────────────────────────
# Default cushion the miner applies to every swap's timeout_block before
# deciding to fulfill. Protects against slow dest-chain inclusion eating into
# the timeout window. Overridable via MINER_TIMEOUT_CUSHION_BLOCKS env var.
# Cushion subtracted from each swap's timeout before the miner agrees to
# fulfill, protecting against slow dest-chain inclusion. Overridable via
# MINER_TIMEOUT_CUSHION_BLOCKS.
DEFAULT_MINER_TIMEOUT_CUSHION_BLOCKS = 5

# ─── Scoring ─────────────────────────────────────────────
SCORING_WINDOW_BLOCKS = 3600 # ~12 hours at 12s/block
SCORING_INTERVAL_STEPS = 300 # Score every 300 forward passes (~1 hour at 12s poll)
SCORING_EMA_ALPHA = 1.0 # Instantaneous — score based on current window only, no smoothing

# ─── V1 Crown-Time Scoring ───────────────────────────────
# Validator throttle: rate_events for a hotkey are only accepted when this many
# blocks have elapsed since the previous accepted event. Prevents rate-war games
# and keeps crown-time attribution stable.
RATE_UPDATE_MIN_INTERVAL_BLOCKS = 75
# Rate/collateral event retention. Must be >= SCORING_WINDOW_BLOCKS so the
# window-start state can always be reconstructed from history.
EVENT_RETENTION_BLOCKS = 2 * SCORING_WINDOW_BLOCKS
# How often the validator polls miner commitments from its local subtensor.
# 15 blocks ≈ 3 min — 1/5 of RATE_UPDATE_MIN_INTERVAL_BLOCKS for good responsiveness
# without hammering the RPC.
COMMITMENT_POLL_INTERVAL_BLOCKS = 15
# Emission allocation per swap direction. Sum of values is the portion of each
# scoring pass allocated to crown-time winners; 1 - sum() recycles to RECYCLE_UID.
SCORING_WINDOW_BLOCKS = 1200 # ~4 hours at 12s/block — also the scoring cadence
SCORING_EMA_ALPHA = 1.0 # Instantaneous — no smoothing across passes
CREDIBILITY_WINDOW_BLOCKS = 216_000 # ~30 days
DIRECTION_POOLS: dict[tuple[str, str], float] = {
('tao', 'btc'): 0.04,
('btc', 'tao'): 0.04,
}
# Harsh penalty for unreliable miners: success_rate ** SUCCESS_EXPONENT.
# 100% → 1.0, 90% → 0.729, 80% → 0.512, 50% → 0.125.
# 100% → 1.0, 90% → 0.729, 80% → 0.512, 50% → 0.125
SUCCESS_EXPONENT: int = 3

# ─── Emission Recycling ────────────────────────────────────
RECYCLE_UID = 53 # Subnet owner UID — emissions recycled on-chain
RECYCLE_UID = 53 # Subnet owner UID

# ─── Reservation ─────────────────────────────────────────
RESERVATION_COOLDOWN_BLOCKS = 150 # ~30 min base cooldown on failed reservation (validator-enforced)
RESERVATION_COOLDOWN_MULTIPLIER = 2 # Exponential backoff: 150 → 300 → 600 ...
MAX_RESERVATIONS_PER_ADDRESS = 1 # 1 active reservation per source address (validator-enforced)
EXTEND_THRESHOLD_BLOCKS = 20 # ~4 min — vote to extend reservation when this many blocks remain
RESERVATION_COOLDOWN_BLOCKS = 150 # ~30 min base cooldown on failed reservation
RESERVATION_COOLDOWN_MULTIPLIER = 2 # 150 → 300 → 600 ...
MAX_RESERVATIONS_PER_ADDRESS = 1
EXTEND_THRESHOLD_BLOCKS = 20 # ~4 min — vote to extend when this many blocks remain

# ─── Protocol Fee ──────────────────────────────────────────
# Hardcoded 1% protocol fee matching the smart contract's immutable
# FEE_DIVISOR constant. No longer read from chain — both sides pin to 100.
# Hardcoded 1% — matches the contract's immutable FEE_DIVISOR.
FEE_DIVISOR = 100

# ─── Display Only (real values enforced on-chain by contract) ─────
# For CLI display and fallback logic only. Actual values are managed
# via `alw admin` commands and read from the contract at runtime.
MIN_COLLATERAL_TAO = 0.1 # Fallback when the contract min_collateral read fails
DEFAULT_FULFILLMENT_TIMEOUT_BLOCKS = 30 # ~5 min — `alw admin set-timeout`
DEFAULT_MIN_SWAP_AMOUNT_RAO = 100_000_000 # 0.1 TAO — `alw admin set-min-swap`
DEFAULT_MAX_SWAP_AMOUNT_RAO = 500_000_000 # 0.5 TAO — `alw admin set-max-swap`
RESERVATION_TTL_BLOCKS = 30 # ~5 min — `alw admin set-reservation-ttl`
# ─── Display Only ─────────────────────────────────────────
# Fallbacks/defaults for CLI display. Live values are written by `alw admin`
# and read from the contract at runtime.
MIN_COLLATERAL_TAO = 0.1
DEFAULT_FULFILLMENT_TIMEOUT_BLOCKS = 30 # ~5 min
DEFAULT_MIN_SWAP_AMOUNT_RAO = 100_000_000 # 0.1 TAO
DEFAULT_MAX_SWAP_AMOUNT_RAO = 500_000_000 # 0.5 TAO
RESERVATION_TTL_BLOCKS = 30 # ~5 min
Loading
Loading