Skip to content

Persist per-round score snapshots and a live mid-round tip#529

Open
LandynDev wants to merge 53 commits into
testfrom
feat/score-snapshots
Open

Persist per-round score snapshots and a live mid-round tip#529
LandynDev wants to merge 53 commits into
testfrom
feat/score-snapshots

Conversation

@LandynDev

Copy link
Copy Markdown
Collaborator

Summary

The validator persists the factors it actually paid: miner_scores rows flush in the same transaction as the crown ledger each round, and current_miner_scores is wiped and rewritten every forward step from the same shared build_direction_score_rows math — neither table can disagree with the weights. Validator rate_history writes are removed; the indexer owns that table (real-time, per QuoteSet).

Changes

  • Shared build_direction_score_rows: the reward multiplication lives once, used by the round scorer and the live tip
  • flush_scoring_window gains miner_score_rows; halt flush clears the tip and writes no score rows
  • snapshot_current_miner_scores per forward step (storage-gated, failure-isolated)
  • store_rate_history_bulk / BULK_UPSERT_RATE_HISTORY / rate_snapshot cursor deleted
  • Tests: snapshot rows reproduce the reward math; ineligible rounds persist factors with reward 0; tip equals round rows for the same window; halt clears the tip

Stacked on #528. Verified live: rows land in miner_scores/current_miner_scores, tip updates ~12s, halt path covered by tests; run-suites fast matrix 3/3 PASS.

anderdc and others added 30 commits June 10, 2026 19:46
Phases 0-4 + 6: Anchor 1.0 program (native-SOL collateral vault, validator
consensus activate/deactivate, reservations with pinned immutable quote, full
swap lifecycle initiate/fulfill/confirm/timeout, admin treasury withdrawal).
26 LiteSVM unit tests + 11 on-chain integration tests over RPC.
Add a per-(miner, pair, direction) MinerQuote PDA written via permissionless
set_quote / remove_quote, moving the miner's quote book on-chain (replaces the
off-chain commitment string). Chains are opaque bounded strings; contract enforces
only non-empty / length / from_chain != to_chain (new SameChain, EmptyField errors).
set_quote overwrites in place; remove_quote closes the PDA and refunds rent.

Change Config.validators from Vec<Pubkey> to Vec<ValidatorInfo{key, weight}> with
admin set_validator_weight (stake-oracle seam); consensus matches on .key and stays
count-based. Bump schema version 1 -> 2.

Tests: new test_quote (8) + test_validator_weight (3) LiteSVM suites and 3 on-chain
integration tests; update add_validator call sites + version assertions. e2e 21/21.
Replace first-write-wins vote_reserve with a stake-weighted lottery over a
pooling window (SOLANA_RESERVATION_LOTTERY.md), gating self-reservation by
validator stake instead of latency.

- Pool PDA (per-miner; opener pins the pair + MinerQuote snapshot; reused
  across contests). open_or_request opens/joins; resolve_pool runs a Level-1
  SlotHash-seeded, stake-weighted draw (lottery::pick_weighted over
  ValidatorInfo.weight) and creates the existing Reservation for the winner;
  downstream initiate/fulfill/confirm/timeout unchanged. cancel_pool (admin)
  resets a stuck pool.
- Flat, non-refundable RESERVATION_FEE_LAMPORTS charged on every open_or_request
  (validator -> vault treasury): anti-spam gate so validators can't cheaply
  reserve every pool. Fee + pool window are compile-time constants.
- Deterministic fallback seed keeps resolve live if the pinned slot rolls off
  the SlotHashes window. Remove vote_reserve, REQ_RESERVE, reserve_hash; bump
  schema version 2 -> 3.

Tests: pure pick_weighted/draw_seed unit tests + rewritten lottery reservation
suite; swap/treasury/onchain helpers reserve via the lottery; new on-chain
pool-open/fee/weighted-draw tests. e2e 23/23.
Replace the admin-set ValidatorInfo.weight stub with validator-governed weights.

- New vote_set_weights(weights: Vec<u64>): a global singleton consensus round
  (seeds [VOTE_SEED, REQ_SET_WEIGHTS]). Validators submit the full weight vector
  index-aligned to Config.validators; consensus::weights_hash binds
  REQ_SET_WEIGHTS || keys || weights (binding keys invalidates a stale round if
  the set changed), so everyone votes the same snapshot via record_vote. On
  quorum it writes every validators[i].weight and stamps Config.last_weights_update.
- Cadence floor WEIGHTS_UPDATE_MIN_INTERVAL_SECS (anti-thrash; tunable).
  set_validator_weight (admin) kept as bootstrap/fallback. New errors
  InvalidWeightsVector / WeightsUpdateTooSoon, event ValidatorWeightsUpdated.
  Schema version 3 -> 4. The Phase 9 lottery draw consumes .weight unchanged.

Off-chain half (read all validators' subtensor voting power, assemble a
byte-identical vector, submit on a cadence) is out of scope here.

Tests: 5 new LiteSVM (quorum apply, hash-binding mismatch, wrong-length,
non-validator, cadence warp) + onchain_vote_set_weights_quorum. e2e 24/24.
Halt gates exactly the three entrypoints ink! gated:
post_collateral, vote_activate, and open_or_request (the
reservation-creation entry, mirroring vote_reserve). resolve_pool
is left ungated so pools opened before a halt can still settle.

Adds Config.halted, SystemHalted error, admin set_halted + HaltSet
event; bumps CONFIG_VERSION to 5.
…(Group 4) (#485)

- Drop cancel_reservation and cancel_pool (market-exit via remove_quote
  covers the former; active-tolerant resolve_pool removes the wedge the
  latter cleaned up).
- resolve_pool: drop the miner-active check. active is a pure entry gate
  (checked at open_or_request); a miner deactivated mid-window resolves
  into a reservation that simply expires.
- Non-bypassable busy lock: add MinerState.busy_until (timestamp, self-
  clearing). Set at pool-open (window+ttl), resolve (reservation expiry),
  and initiate (swap deadline); cleared on confirm/timeout. deactivate
  and withdraw_collateral now read busy_until from the mandatory
  MinerState instead of optional pool/reservation accounts that a caller
  could omit. State-based, can't be skipped.
- Rename PoolPairMismatch -> MinerBusyDifferentPair; add MinerBusy.

Net removal; Config layout unchanged (no version bump).
Ports the six ink! owner config setters absent from the port:
set_min_collateral, set_max_collateral, set_fulfillment_timeout,
set_min_swap_amount, set_max_swap_amount, set_reservation_ttl.
(set_consensus_threshold + set_halted already existed.)

Also promotes the three Phase 9/10 deployment-static tunables to
runtime Config fields with setters: reservation_fee_lamports,
pool_window_secs, weights_update_min_interval_secs. Consts now seed
the defaults at initialize; handlers read from Config.

Floors translated to sensible secs/lamports (timeout >= 60s,
min_swap 0 or >= 1000, ttl/window > 0). Bumps CONFIG_VERSION to 6.
…olidation (on contract-v2) (#484)

* feat(solana): over-collateralize failed swaps to 1.1x (v2 #4)

Add a dedicated tunables.rs for economic knobs. COLLATERAL_REQUIREMENT_BPS
(1.10x) is compile-time bounded to [1.0x, 2.0x] via a const assert plus a
unit test; required_collateral(sol_amount) is the shared helper.

- vote_initiate now gates on collateral >= required_collateral(sol_amount)
  (was 1:1), so a miner must hold 1.1x the swap size to take it.
- timeout_swap slashes 1.1x and refunds the entire slash to the user
  (the 0.1x is a penalty paid to the victim; no treasury/burn split).

Tests: 4 tunables unit tests, a vote_initiate boundary-rejection test
(2.1 SOL < 2.2 needed), and the 1.1x slash assertion in test_swap. Also
fixes test_initialize (version 4 -> 5) left stale by the halt commit.
Full LiteSVM suite green (58 passed); e2e.sh 24/24.

* feat(solana): decaying anti-flashing fee on quote updates

set_quote now charges a treasury-bound fee when overwriting a standing
quote (first creation stays free beyond rent). The fee decays stepwise the
longer the prior quote stood: 0.01 SOL if updated within 5 min, 0.001 SOL
within 10 min, 0 thereafter. Discourages rapid quote-flashing without
deterring miners from joining.

- New QUOTE_UPDATE_FEE_* tiers + quote_update_fee(elapsed) in tunables.rs,
  compile-time asserted to decay over increasing windows.
- Fee moves miner -> vault treasury (system CPI), preserving the vault
  invariant. SetQuote gains a vault account; QuoteSet gains update_fee.
- Tests: tunables tier unit test + a test_quote decay walk (create free
  -> tier-1 -> tier-2 -> free). Updated all SetQuote callers for the new
  vault account. Full LiteSVM suite green (60 passed); e2e.sh 24/24.

* refactor(solana): consolidate all economic levers into tunables.rs

Move FEE_DIVISOR, RESERVATION_FEE_LAMPORTS, POOL_WINDOW_SECS and
WEIGHTS_UPDATE_MIN_INTERVAL_SECS out of constants.rs into tunables.rs, so
every deploy-time economic/policy knob lives in one file alongside the
collateral-requirement and quote-fee tunables. constants.rs keeps only
structural values (PDA seeds, request-type bytes, max string lengths, and
SLOT_MS as a chain fact). Pure code-organization move — identical values,
no behavior change. Imports updated across instructions + tests.
Full LiteSVM suite green (60 passed); e2e.sh 24/24.

* feat(solana): 60s pool window + 0.02 SOL reservation fee defaults; tunables after v2 merge

Merge origin/contract-v2 (#485 busy-model reservation rework, #486 runtime
config setters) into the feature branch and reconcile with the economic-knob
consolidation:

- Keep all deploy-time economic levers in tunables.rs. The three values #486
  promoted to runtime Config fields (reservation_fee_lamports, pool_window_secs,
  weights_update_min_interval_secs) live here as the initialize seed defaults;
  handlers read the live Config value. FEE_DIVISOR stays compile-time.
- Set the deploy defaults: POOL_WINDOW_SECS 3 -> 60, RESERVATION_FEE_LAMPORTS
  0.001 -> 0.02 SOL (both runtime-tunable via the #486 setters).
- initialize.rs seeds from tunables; test_initialize version 5 -> 6.
- integration_onchain: shrink the pool window to 3s in the shared setup via the
  new set_pool_window setter so the wall-clock on-chain tests don't each sleep a
  full minute (keeps the 60s deploy default; e2e ~2min not ~7min).

Full LiteSVM suite green (60 passed); e2e.sh 24/24.
Tighten multi-line comment books across the swap-manager program to 1-2 lines,
keeping the why (invariants, security rationale) and dropping restated mechanics,
phase numbers, and doc cross-references. No code changes — verified each file is
byte-identical with comments stripped; host cargo check clean.
* fix(solana): PR review — entry over-collateral gate, busy-lock deactivation, admin cancels, shared validation

- consolidate tunables.rs into constants.rs (economic-levers section)
- open_or_request: gate on 1.10x required_collateral at pool entry so an
  under-collateralized miner can't strand a user at vote_initiate (#1)
- vote_deactivate: forbid deactivating a busy miner (busy => active invariant),
  so resolve_pool never arms a reservation on an inactive miner (#3)
- restore admin cancel_pool / cancel_reservation, clearing busy_until (#4)
- admin setters reject contradictory min/max bounds (#6)
- set_quote charges the churn fee on creation too, closing the
  remove_quote + set_quote dodge (#7)
- validate.rs: shared Config-field validators used by initialize + setters so
  the two write paths can't diverge (#8)
- DEFAULT_FULFILLMENT_TIMEOUT_SECS = 14400 (4h) canonical deploy default
- tests: 12 new (entry gate, busy deactivation, cancels, bounds, quote fee);
  LiteSVM 67/67, e2e.sh 24/24

* refactor(solana): separate subnet-revenue Treasury PDA from the collateral Vault

Collateral and subnet revenue no longer share an account. The Vault holds ONLY
miner collateral (trustless — leaves only via the owning miner's withdraw or a
slash to the wronged user); a new Treasury PDA holds ONLY subnet income.

- new Treasury { total, bump } PDA (seeds [b"treasury"]); Vault loses treasury_total
- confirm 1% fee, reservation fee, and quote churn fee all accrue to the Treasury
- timeout slash still pays the user from the Vault (never the treasury)
- withdraw_treasury drains the Treasury PDA (admin-only, caller-chosen recipient)
- split invariants: vault.lamports == rent + total_collateral;
  treasury.lamports == rent + total

Anti-flash fee follows the #488 mechanism (creation free; charge on remove_quote):
- set_quote creation is free again; updates still pay the decaying churn fee
- remove_quote charges the same decaying fee -> Treasury, closing the
  remove+recreate dodge without taxing first-time quotes

Tests updated for the split + new fee semantics. LiteSVM 67/67, e2e.sh 24/24.

* fix(solana): address pre-PR code-review findings

- cancel_reservation: require an active reservation (reserved_until != 0) so it
  can't clear busy_until on a miner whose pool is still open — that could let the
  miner be deactivated mid-contest and resolve_pool match a removed miner against
  a user (fund-safety regression caught in review)
- resolve_pool: restore the inactive-miner backstop (reset pool, never arm a
  reservation or busy lock for an inactive miner)
- vote_initiate: defensive active-miner check before initiating
- remove_quote: document the deliberate "removal can cost the churn fee" stance
- fix stale vault->treasury doc comments (confirm_swap, set_quote, lib, constants)
- tests: cancel_reservation open-pool rejection + treasury lamport conservation

LiteSVM 68/68, e2e.sh 24/24.

* refactor(solana): drop cancel_pool/cancel_reservation; rely on TTL self-expiry

No permanent stuck state exists: resolve_pool is permissionless (always progresses
an open pool) and a reservation's reserved_until is always now + reservation_ttl,
so a miner abandoned in a reservation self-frees at the TTL. The admin cancel ops
were only early-clear accelerators and were the sole paths that cleared busy_until
manually — the exact footgun behind the fund-safety bug. Removing them restores a
clean busy => active invariant without a resolve_pool backstop.

- remove cancel_pool / cancel_reservation instructions, their events + tests
- resolve_pool: no active check (documented invariant); vote_initiate keeps its
  defensive active check as the funds-commit backstop

LiteSVM 65/65, e2e.sh 24/24.
…lt) (#491)

Each miner's collateral now lives in its own CollateralVault PDA
(seeds [b"collateral", miner]) instead of one pooled Vault account.

Why: the shared Vault was a serialization point and a cheap-to-grief
congestion target — a miner spamming self-deposits could eat its per-block
CU budget and delay swap settlement (confirm/timeout shared the same account).
Per-miner vaults isolate that blast radius, parallelize collateral ops across
miners, and make custody literal (a miner's collateral is in their own account,
movable only by their own withdraw or a quorum slash).

- new CollateralVault { bump }; Vault struct + VAULT_SEED removed
- post/withdraw move lamports miner <-> their own collateral vault (lazily
  created on first deposit, miner-paid rent)
- confirm fee: miner's collateral vault -> Treasury; timeout slash: -> user
- apply_penalty drops the shared-total bookkeeping (MinerState.collateral is
  the authoritative ledger; per-account invariant lamports == rent + collateral)
- initialize no longer creates a shared vault (Treasury unchanged)

Tests updated to per-miner vaults. LiteSVM 65/65, e2e.sh 24/24.
… in-window (#492)

A repeat open_or_request from the same validator while the pool is still open now
UPDATES that validator's bid in place instead of being rejected (AlreadyRequested).
Frozen once the window closes / the pool resolves. The miner's rate is pinned at
open, so refining a bid is against a stable rate; lottery odds are stake-weighted,
so updates can't game the draw.

- open_or_request JOIN branch: upsert (replace existing request by validator) vs push
- reservation fee charged only on a fresh entry; same-validator in-window updates free
- ErrorCode::AlreadyRequested left defined (now unused) to avoid IDL churn
- tests: validator-can-update, update-reflected-in-reservation, update-is-free,
  update-after-window-close-fails

LiteSVM 68/68, e2e.sh 24/24.
* feat(solana): deadline extensions (reservation + fulfillment)

Single-validator, no-quorum push of reserved_until / timeout_at while a
swap waits on slow chain confirmation, so a correct miner isn't false-slashed.

- extend_reservation / extend_timeout: any validator slides the deadline
  forward; guards are monotonic (can't shorten a runway) + an absolute
  max_extend_at ceiling frozen at creation (can't hold a miner hostage).
  No quorum needed — money still only moves at confirm/timeout, so an
  extension can only delay a slash up to the ceiling, never cause one.
- Config.max_total_extension_secs (default 90m, admin-tunable [30m,120m]);
  ceiling = deadline + budget, frozen so a later retune can't move it.
- Reset DEFAULT_FULFILLMENT_TIMEOUT_SECS 4h -> 10m and add
  DEFAULT_RESERVATION_TTL_SECS 10m: tight base each side (mirrors ink!),
  extended adaptively as confirmations land.
- Extensions ignore halt (in-flight swaps must finish).

Tests: test_extensions.rs (happy path, ceiling, monotonic, validator-only,
Active+Fulfilled) + bounds unit test. lib tests pass; integration needs build-sbf.

* test(solana): bump version asserts 6->7 for CONFIG_VERSION
…495)

The on-chain `rate` was a free-form `String` parsed only off-chain. That
stringly-typed field is the root of the historical "lucrative rate scores
but is unreservable due to a parse bug" exploit class: a rate one parser
accepts and another rejects lets a miner be credited as available while
never being routable.

Store it instead as `u128` fixed-point = display_rate x RATE_PRECISION
(1e18) — the same scale the off-chain `rate_fixed` already uses, so the
stored value IS that integer (no decimal-string parse on either side) and
it's exact (no float drift, deterministic across validators).

Deliberately a pure representation change, no on-chain policy: the contract
never computes with the rate (only stores/copies it), and validity/
routability stays off-chain in `is_executable_rate`, which already gates
both scoring and reservation. So set_quote keeps storing whatever the miner
posts — a u128 can't be syntactic junk — and gains no new rate check. Chain
decimals are unaffected (orthogonal, applied in the amount math from the
chain registry, exactly as before).

- state: Reservation/Swap/MinerQuote/Pool `rate` String -> u128
- events: QuoteSet `rate` String -> u128
- set_quote: drop the rate empty/length checks; no rate policy added
- constants: drop MAX_RATE_LEN, add RATE_PRECISION (1e18)
- open_or_request/resolve_pool/vote_initiate: rate copy is now a Copy
- tests: rate args/asserts -> fixed-point integers

cargo check + lib tests green; LiteSVM integration tests updated (build-sbf
unavailable locally, verified in CI).
…for the off-chain refactor (#494)

* feat(solana): MinerState eligibility counters — successful_swaps/failed_swaps (v7)

confirm_swap bumps successful_swaps on quorum; timeout_swap bumps failed_swaps.
Monotonic saturating_add; CONFIG_VERSION -> 7 (scoring read-surface batch).
Tests: test_swap asserts bump-on-quorum (not before); test_initialize version -> 7.
68 LiteSVM green.

* feat(solana): A2 — per-direction realized volume/VWAP (MinerDirectionStats)

New MinerDirectionStats PDA (seeds [stats, miner, from_chain, to_chain]) accrued in
confirm_swap's quorum block (completed + total_sol/from/to_amount); realized VWAP =
total_to_amount / total_from_amount (exact integer math). confirm_swap takes from_chain/
to_chain args constrained == swap (new ChainMismatch); checked_add on the u128 totals.
SwapCompleted extended with chains/amounts/rate for off-chain per-swap history.

Tests: stats + VWAP assertions on the confirm path, no-accrual-on-timeout, two new
accumulation/separation tests; all confirm_ix sites updated. Also fixes a stale
version==6 assertion in integration_onchain.rs (left from A1). 70 LiteSVM + 24/24 e2e.

* feat(solana): A3 — on-chain source-tx claim (submit_swap_claim + PendingAttestation)

Split swap initiation: a permissionless submit_swap_claim records the user's source-tx
hash on-chain as a PendingAttestation swap (symmetric with mark_fulfilled), and
vote_initiate becomes a pure attestation (PendingAttestation -> Active on quorum) where
the miner's obligation begins. Lets the off-chain pending_confirms queue + its desync
class delete.

Front-run defense by pinning: user/user_to_addr are pinned into the Reservation at
resolve_pool (from the winning lottery Request, previously dropped), so the permissionless
claim copies the payout and can't redirect it. vote_initiate's consensus bind reduces to
swap_request_hash(REQ_INITIATE, swap_key); the old initiate_hash over user args is deleted.

One-live-claim-per-reservation via Reservation.claimed_swap_key; close_stale_claim reaps
an orphaned pending claim (rent -> caller). #493 extend_* unchanged: extend_reservation
runs during pending, extend_timeout after attestation (its ceiling freezes at attestation).

Boxed the String-heavy swap/reservation accounts off the BPF stack. SwapStatus +Debug.
Tests rewritten to claim->attest across all four suites + new claim/pending/reap tests.
81 LiteSVM + 24/24 e2e green.

* feat(solana): A5 — hotkey↔pubkey identity binding (bind_hotkey + Binding PDA)

bind_hotkey (miner-signed, permissionless) stores the miner's Bittensor hotkey + an
sr25519 signature (by the hotkey, over the miner's Solana pubkey) in a per-miner Binding
PDA (seeds [bind, miner]). The contract only STORES the sig — sr25519 verification is too
costly on-chain; the validator verifies off-chain and enforces 1:1 + first-seen pinning
(Phase B). Overwrites in place on re-bind; not halt-gated (identity, no value movement).

Separate Binding PDA (not fields on MinerState) keeps MinerState off the swap hot path
where A2/A3 already had to box accounts to avoid BPF stack overflows. CONFIG_VERSION -> 9.
HotkeyBound event. Tests: test_binding (create + re-bind overwrite) + onchain_bind_hotkey.
83 LiteSVM + 25/25 e2e green.

* feat(solana): A4 — freshness source-replay defense (remove the source TxMarker)

Replaces the permanent per-tx TxMarker dedup map (unbounded forever-rent) with a validator
freshness check: a source deposit must be mined after the swap's reservation was created.
Safe because the Swap PDA (keccak(from_tx_hash)) blocks reuse while a swap is open, so
replay is only attemptable after close — and a prior swap's deposit is always older than
any later reservation, so an old deposit can never out-date a newer one. The freshness
bound is an on-chain timestamp, so no head-anchor consensus (propose/tolerance) is needed.

Contract: removed the TxMarker (struct/seed/account/guard/writes); added Reservation.created_at
(the source bound; swap.initiated_at is the dest bound, already stored). CONFIG_VERSION -> 10.
The contract no longer blocks reused tx hashes (replay test repurposed to prove it).

The source/dest freshness CHECKS are load-bearing validator (Phase B) work — flagged in
SOLANA_VALIDATOR_CHANGES.md as a hard gate before mainnet. 83 LiteSVM + 25/25 e2e green.

* fix(solana): gate submit_swap_claim to validators (kill the front-run squat)

The permissionless claim let an attacker file a bogus from_tx_hash to occupy the
one-claim-per-reservation slot (claimed_swap_key), blocking the real claim — a near-free,
re-squattable DoS bounded only by the reservation TTL. (Theft was already impossible:
the payout is pinned in the reservation, not supplied by the claim.) Dropping the single
slot instead would just trade the squat for a junk-claim RPC-amplification grief.

Fix mirrors the proven ink! model: only a whitelisted validator can put a deposit on-chain.
ensure_validator(config, caller) on submit_swap_claim → no anonymous claim to squat or
RPC-chase. Participation stays open — anyone wins the pool draw, then flags down a validator
to relay + attest (validators are the BTC oracle, so every winner needs them anyway).
Residual shrinks to a rogue whitelisted validator (accountable, removable, TTL-bounded).
Resolves D5 (validator-relay) and D4 (post-expiry reaper suffices). 84 LiteSVM + 25/25 e2e.

* chore(solana): pre-PR cleanup — drop dead Reservation.bound_hash + stale comments

Pre-PR review sweep findings (no blockers, no correctness bugs):
- Remove Reservation.bound_hash: written [0;32] at resolve_pool, never read after the
  lottery (post-lottery has no consensus binding) — dead 32 bytes.
- Fix stale comments: 'same shape vote_reserve produced' (vote_reserve was removed in the
  lottery redesign) and 'permissionless claim' references (submit_swap_claim is now
  validator-gated).
- D7 confirmed a non-issue: confirm_swap accumulates the contracted to_amount/from_amount,
  which equals the delivered amount for a confirmed swap (off-chain verify_miner_fulfillment
  gates confirmation on exact delivery). Volume stats are realized.

Unused error variants (NotMiner, AlreadyRequested) left in place to avoid shifting error-code
indices (same rationale as the retained DuplicateSourceTx). 84 LiteSVM + 25/25 e2e.

* refactor(solana): rename Request.validator -> router (pool entry is permissionless)

open_or_request has no validator gate — the signer was merely *named* validator, but any
account (validator or plain user) can already open a pool / route a request: it pays the
reservation fee (anti-spam), gets lottery weight 0 if not whitelisted (resolve_pool ->
unwrap_or(0) -> uniform among non-validators / loses to validators), and if it wins, flags
down a validator to claim + attest (validator-gated). The weight-0 fallback was designed
for this. So entry was already permissionless; the name 'validator' was just misleading.

Pure clarity rename across the pool-entry path: Request.router, open_or_request's
router: Signer, ReservationRequested.router, resolve_pool weighting. No behavior change,
no CONFIG_VERSION bump (byte layout identical; only field names / IDL). Settlement stays
validator-relayed (does not reopen the squat). 84 LiteSVM + 25/25 e2e.

* fix(solana): PR #494 review — drop total_sol_amount, add set-once hotkey marker

Two review refinements from @LandynDev (both 'not a blocker', settled before the PDA shapes lock):

A2 — drop MinerDirectionStats.total_sol_amount. The PDA is keyed (miner, from_chain, to_chain),
so total_from_amount/total_to_amount are already asset-pure and the realized VWAP needs no common
unit. total_sol_amount baked collateral=SOL into permanent state (mixes units once split-collateral
lands); the validator re-derives SOL-notional off-chain from its price feed, and the at-time notional
stays in the SwapCompleted event. PDA is now asset-agnostic.

A5 — close the strike-dodge on-chain. Binding (pubkey-keyed) only enforced pubkey->1 hotkey; the
reverse was off-chain validator-policy, so a struck pubkey could rotate to a fresh pubkey and re-bind
the same hotkey to dodge strikes. Added a set-once HotkeyBinding marker [HOTKEY_BIND_SEED, hotkey]:
first pubkey to claim a hotkey owns it forever; a different pubkey is rejected (HotkeyAlreadyBound),
the owner may re-bind. First-seen pin now runtime-enforced. Tradeoff accepted: no legit Solana-key
rotation (key loss -> new UID). One small rent marker per identity, never closed.

85 LiteSVM + 25/25 e2e. CONFIG_VERSION stays 10 (refines PDAs within the same unreleased version).
… C) (#496)

* feat(validator): B0 — solana_client foundation (hand-rolled, sync)

First phase of the contract-driven validator rewrite. New allways/solana/ package — reads PDAs,
builds/sends instructions, ingests events for the allways_swap_manager program (Anchor 1.0.2),
replacing the ink!/Substrate path.

- rpc.py: thin sync JSON-RPC over requests (account/program reads, tx send/confirm, signatures, logs).
  Commitment matched to 'confirmed' across blockhash/preflight/signatures so a fresh validator doesn't
  reject not-yet-finalized blockhashes.
- layouts.py: borsh-construct CStructs for all 12 accounts + nested types (field order locked to
  state.rs; rate is u128 fixed-point per #495; discriminators copied verbatim from the IDL).
- pdas.py: PDA seed derivation (incl. composite quote/stats/vote/swap/hkbind seeds) + program id.
- keys.py: Solana keypair load/generate (solana-CLI json format).
- client.py: AllwaysSolanaClient — all PDA readers + getProgramAccounts discovery + tx build/sign/send
  + representative writes (initialize, bind_hotkey, set_quote, post/withdraw_collateral) + event-log
  ingest skeleton. Remaining ~25 instruction builders land in B1/B2.

Deps: solders + borsh-construct (resolve cleanly alongside bittensor under uv; no override needed).

Tests:
- tests/test_solana_client.py — 7 unit tests: borsh round-trips per type case (u128 rate, strings,
  enum, Vec<Request>, Vec<ValidatorInfo>) + an independent sha256 discriminator check + PDA derivation.
- tests/test_solana_client_integration.py (gated @pytest.mark.integration) — round-trips the whole
  client against a fresh local solana-test-validator: deploy -> initialize/post_collateral/set_quote/
  bind_hotkey -> read back + assert real on-chain bytes. Verified green (unit + integration).

* feat(validator): B1 (part 1) — read-only Solana swap-loop logic

First increment of the read-only contract-driven core. New allways/validator/solana_swap_loop.py:
discovers live swaps from the Solana contract (solana_client.get_swaps), decides per status, verifies
via the unchanged chain providers, and LOGS the decision (no on-chain votes — B2 un-stubs voting +
freshness). Decoupled from the old SwapVerifier so it unit-tests in isolation; reuses only the
chain-provider primitive (verify_transaction) + the fee math (apply_fee_deduction).

Fee model = Option A (decided 2026-06-25 with LandynDev): the user receives 99% of the on-chain
to_amount (ink!-style delivery haircut, transparent "user pays the 1%"); the protocol's 1% is skimmed
separately from the miner's SOL collateral by confirm_swap. So the dest leg is verified against
apply_fee_deduction(to_amount, FEE_DIVISOR) = 99%, on the pinned u128 to_amount — no rate-string
recompute (expected_swap_amounts' rate->amount path retires). Rejected Option B (verify full to_amount)
for the clearer "user pays" economics. Notes (incl. multi-collateral analysis) in
SOLANA_VALIDATOR_CHANGES.md.

Decisions: PendingAttestation+source-verified -> ATTEST; Active+past-timeout -> TIMEOUT; Fulfilled+
both-legs(99% dest) -> CONFIRM; provider-unreachable -> SKIP; else WAIT.

8 unit tests (mock client + providers): assert each decision + that the dest leg is checked against 99%.
Still to come in B1: repoint swap_tracker discovery, rewrite forward.py, gut event_watcher replay,
wire the client + Solana keypair into the neuron + a read-only localnet run.

* feat(validator): B1 step 1 — construct read-only SolanaSwapLoop in neuron

Wire AllwaysSolanaClient + SolanaSwapLoop into the validator init alongside
the existing (still-constructed) substrate components. Additive only: nothing
calls the new loop yet, and the old swap_tracker/swap_verifier/event_watcher
stay constructed so axon + scoring code keep compiling.

* feat(validator): B1 step 2 — drive forward loop via read-only SolanaSwapLoop

Replace the swap-lifecycle phases (tracker.poll, confirm_miner_fulfillments,
extend_fulfilled_near_timeout, enforce_swap_timeouts) with a single
solana_swap_loop.run_once(now) call (now = int(time.time()), since Solana
timeouts are unix seconds). Stop calling the substrate event-replay,
pending-confirm drain, reservation-pin expiry, and dest-tip observe phases;
defer poll_commitments + scoring + crown snapshot to B3 (they depend on the
event-replay history B1 no longer populates).

Additive: the old phase helpers stay defined (uncalled) so their imports and
the axon/scoring code keep compiling — physical deletion is the B2/B3 cleanup.

* feat(validator): B2.0 — Solana vote/claim instruction builders

Add the validator-side write set to AllwaysSolanaClient: submit_swap_claim (the
claim relay), vote_initiate/confirm_swap/timeout_swap/vote_activate (consensus
votes), mark_fulfilled (miner; for the test harness, prod wiring is B4), and
extend_timeout/extend_reservation (single-call slides, builders only). Plus the
get_vote_round reader + has_voted guard, swap_key_from_tx_hash (keccak256), and
an add_validator admin helper.

Discriminators copied verbatim from the IDL; arg layouts + account-meta order
locked to the contract's #[derive(Accounts)] structs. Unit tests recompute the
Anchor global: discriminator independently and assert byte-exact arg encoding +
account order/flags. A localnet integration test drives vote_activate to quorum
from 3 validator keypairs and asserts the miner activates — proving the shared
consensus-vote account pattern is runtime-correct against the deployed program.

The full swap lifecycle integration (needs a Reservation via the Phase-9 pool
flow) lands in B2.5; the Rust e2e onchain_* tests already cover the contract side.

* feat(validator): B2.1 — plumb block_time (unix seconds) onto TransactionInfo

Add block_time to TransactionInfo and populate it in both providers: BTC from
Esplora status.block_time (api path) + getrawtransaction blocktime (rpc path);
TAO via a new get_block_time() that reads the Timestamp pallet at the tx's block
hash (millis ÷ 1000). Prerequisite for the B2.2 replay-freshness checks, which
compare a tx's mined time against on-chain unix-seconds floors. Additive —
existing verify paths unchanged. Unit tests mock both backends.

* feat(validator): B2.2 — replay-freshness gates in the swap loop

Source freshness (ATTEST): refuse to attest a deposit whose block_time is not
after the Reservation.created_at floor. Dest freshness (CONFIRM): require the
payout's block_time after Swap.initiated_at. Both compare unix-seconds times
(not block heights) and fail closed when block_time is absent.

Refactors _verify_leg → _fetch_leg (returns the TransactionInfo so callers can
read block_time) with an ok/no/down tri-state. GRACE is a per-chain
ChainDefinition.replay_grace_secs (default 0, strict >), hard-capped well under
reservation_ttl. New unit tests: replayed source/dest rejected, missing
block_time rejected, grace window, no-reservation waits.

* feat(validator): B2.3 — cast on-chain votes from the swap loop

run_once now submits the consensus vote per decision: ATTEST→vote_initiate,
CONFIRM→confirm_swap, TIMEOUT→timeout_swap (swap_key=keccak(from_tx_hash); miner
and user read off the Swap account). A has_voted pre-check skips a re-vote and
any send error (incl. a lost race to quorum) is caught so one swap can't break
the pass. forward.py runs run_once via asyncio.to_thread (votes are network I/O).

read_only kill switch (SOLANA_VALIDATOR_READONLY=1) keeps the old log-only 'WOULD'
behavior for staged rollout. Unit tests cover per-decision dispatch + the
already-voted skip.

* feat(validator): B2.4 — repoint axon claim-relay + activation to Solana

handle_miner_activate: resolve the miner's Solana pubkey via the A5 HotkeyBinding,
gate on MinerState (registered, not active, collateral >= Config.min_collateral),
then vote_activate on the Solana contract. Drops the Phase-8 commitment/quote gate
(the contract's vote_activate requires no quote).

handle_swap_confirm becomes the claim relay: resolve pubkey, read the on-chain
Reservation (live + unclaimed), verify the source deposit against the pinned terms
(recipient/amount/sender) plus replay-freshness, then submit_swap_claim creating the
PendingAttestation swap. If the tx isn't visible/confirmed, reject so the user
resends — drops the pending_confirms queue + the old vote_initiate rich-payload
keccak. The loop's ATTEST then verifies + votes with per-step retries.

Both reuse self.solana_client (sync HTTP, no axon_lock needed). handle_swap_reserve
stays on the old path (pool/lottery = Phase 9). Freshness logic shared via a new
is_tx_fresh helper. New handler unit tests added; obsolete confirm/activate tests
(commitment gate, contract-reservation reads, pin consumption, pending-confirm
queue) removed — superseded behavior.

* feat(contract): emit MinerActivated/MinerDeactivated events (B3.0)

The MinerState.active flag carries no history, so a validator can't replay the
per-instant active state the crown capacity integral needs from logs alone. Emit
MinerActivated{miner,at} on vote_activate quorum and MinerDeactivated{miner,at}
on vote_deactivate quorum + self-deactivate. Additive (no CONFIG_VERSION bump);
the small follow-up PR the validator-rewrite roadmap anticipated for B3 crown
re-source. LiteSVM lib tests green; events present in the regenerated IDL.

* feat(validator): B3.1 — Solana event decode + cursor ingest

allways/solana/events.py: borsh layouts + discriminators (copied from the IDL)
for the crown-relevant events (CollateralPosted/Withdrawn, SwapInitiated/Completed/
TimedOut, QuoteSet/QuoteRemoved, MinerActivated/Deactivated) + decode_event, and
SolanaEventIngest — a cursor-based poll that fetches every signature newer than
the last cursor (oldest-first), decodes events, skips failed txs, and returns
typed records with (slot, block_time) + the advanced cursor. Foundation for the
B3.4 crown re-source (replaces the substrate event_watcher per-block replay).

Unit tests recompute the Anchor event:<Name> discriminator independently + borsh
round-trip; a localnet integration test emits the events via the builders and
asserts the ingest decodes them with correct fields/slot.

* feat(validator): B3.2 — sr25519 binding attribution

allways/validator/binding.py: verify the A5 Binding (hotkey signed the miner's
Solana pubkey) off-chain via bittensor Keypair.verify, and build a
pubkey→hotkey-ss58 attribution map. Each Binding PDA is per-miner so pubkey→hotkey
is 1:1; hotkey collisions (which the on-chain set-once HotkeyBinding prevents) are
resolved first-bound-wins by bound_at as defense in depth. This is how on-chain
state keyed by Solana pubkey (MinerState counters, MinerDirectionStats, events)
attributes to a metagraph UID — and why a struck pubkey can't rotate to dodge
strikes. Unit tests: valid/invalid/tampered sig, ss58 roundtrip, attribution map,
collision first-bound-wins.

* feat(validator): B3.4 — re-source crown off Solana events via SolanaEventIndex

Build SolanaEventIndex: ingest decoded program events (B3.1) into the
state_store event tables, attributing pubkey->hotkey at write (B3.2), and
expose the crown's per-instant read interface over them on the unix-blockTime
axis. Point scoring's crown replay (reconstruct/merge/replay/snapshot) at the
index, drop the reservation-pin overlay (pins are gone in the Solana model),
and re-denominate the window to unix-seconds.

- event_index.py: SolanaEventIndex.ingest + read interface; busy = swap-
  lifecycle only; QuoteSet rate / RATE_PRECISION; unbound/unstamped skipped.
- state_store.py: at-time + in-range readers (block_num repurposed as unix
  blockTime) + solana_event_meta string cursor.
- scoring.py: reconstruct_window_start_state -> 4-tuple (no pinned_rates);
  EventKind ACTIVE<BUSY<RATE<COLLATERAL; SCORING_WINDOW_SECS/MAX_SCORING_BACKFILL_SECS.
- tests: crown tests feed events through the index; pin-test class removed;
  new tests/test_event_index.py. Scoring stays dormant until B3.6.

Suite 733 passing, 3 integration deselected.

* feat(validator): B3.3 — flat eligibility gate off MinerState counters

Replace the per-validator credibility ledger (success_rate³ × credibility_ramp)
with a flat binary crown gate read off the on-chain MinerState counters:
eligible(ms) = successful_swaps >= MIN_SUCCESSFUL_SWAPS(2) and
failed_swaps <= MAX_FAILED_SWAPS(2). The reward factor goes from
pool × crown_share × sr³ × ramp × cap × vol to pool × crown_share × eligible × cap × vol.

scoring.py: new pure helpers is_eligible(miner_state) +
build_eligibility(solana_client, metagraph) (reads get_all('MinerState'),
attributes pubkey→hotkey via the B3.2 sr25519 binding, drops unbound /
off-metagraph miners; absent counters ⇒ ineligible ⇒ 0).

Delete success_rate/credibility_ramp + prune_swap_outcomes; constants
SUCCESS_EXPONENT, CREDIBILITY_RAMP_OBSERVATIONS, CREDIBILITY_MAX_TIMEOUTS,
CREDIBILITY_WINDOW_BLOCKS; state_store.get_success_rates_since +
prune_swap_outcomes_older_than. Add MIN_SUCCESSFUL_SWAPS / MAX_FAILED_SWAPS.

scoring_trace.py: WeightingTrace.record_eligibility/eligible replace the
credibility fields; log_scoring_trace/non_earner_lines/diagnose_non_earner take
eligibility: Dict[str,bool] (non-earner reason credibility_zero → ineligible).

Keep the swap_outcomes table + insert_swap_outcome + get_volume_by_direction_since
(volume re-sourced from MinerDirectionStats in B3.5; table dropped in B3.6).
Scoring stays dormant (not wired into forward.py until B3.6) — verified by unit
tests: new tests/test_eligibility.py covers real-sr25519 pubkey→hotkey→UID
attribution + gate boundaries; test_scoring_v1.py rewired with a FakeSolanaClient
+ autouse identity-attribution fixture; test_rate_state.py / test_event_watcher.py
read swap_outcomes via a local helper in place of the deleted reader.

Full suite 733 passing, 3 integration deselected.

* feat(validator): B3.5 — quality-volume structure off MinerDirectionStats

Re-source the per-direction volume factor from the on-chain MinerDirectionStats
accounts (realized VWAP) instead of the per-validator swap_outcomes ledger, and
reshape the reward as eligible × [w_a·crown + w_b·quality_volume]. Pure
structure: w_b=0.0 (Phase-C placeholder, no market-rate feed yet), so the
distributed weights are byte-identical to B3.3.

scoring.py: realized_vwap(total_to, total_from) (exact-integer legs, only the
final ratio is float, from<=0 → 0.0); DirectionVolume dataclass with a .vwap
property; build_direction_volumes(solana_client, metagraph) reads
get_all('MinerDirectionStats'), attributes pubkey→hotkey via the B3.2 sr25519
binding, drops unbound/off-metagraph, accumulates defensively; rate_quality is a
Phase-C STUB returning 1.0 (no price oracle). calculate_miner_rewards builds
direction_volumes once and slices from_amount per direction in place of
state_store.get_volume_by_direction_since.

constants.py: REWARD_WEIGHT_CROWN=1.0 / REWARD_WEIGHT_QUALITY_VOLUME=0.0.

Nothing in the reward path reads get_volume_by_direction_since/get_volume_since
anymore — both state_store methods + the swap_outcomes writer are orphaned from
scoring (kept until B3.6, deleted there with the table). Scoring still dormant.

Tests: FakeSolanaClient gained get_all('MinerDirectionStats') + add_direction_stats;
volume/capacity tests seed MinerDirectionStats instead of swap_outcomes; new
TestRealizedVWAP + TestBuildDirectionVolumes. Full suite 742 passing, 3 deselected.

* feat(validator): B3.6 — turn scoring on + delete substrate machinery

Final B3 stage. The validator now reads, votes, and scores entirely off
Solana: forward.py polls SolanaEventIngest off the stored cursor, folds the
decoded events into SolanaEventIndex with sr25519 attribution + persists the
cursor, then fires the block-gated due_for_scoring → score_and_reward_miners
and the live crown snapshot.

Crown axis moved to unix blockTime: calculate_miner_rewards / _flush_halt_window
/ the prune step / the live snapshot all run on int(time.time()) with a
last_scored_time companion; due_for_scoring cadence stays subtensor-block-gated
(heartbeat + set_weights are still TAO). Swap bounds + halt now read from the
Solana Config account via SolanaConfigCache.

Deleted the orphaned substrate machinery: event_watcher, swap_tracker,
optimistic_extensions, chain_verification (SwapVerifier); the dead state_store
tables (swap_outcomes, dest_tip_snapshots, pending_confirms,
reservation_pin_events, event_watcher_meta, bootstrapped_swaps) + their
methods/dataclasses; the deferred forward helpers. The scoring prune step now
also trims the active/busy/collateral tables the watcher used to own. Fixed a
scoring_trace.py reference that still read self.event_watcher (→ event_index).

Kept for later stages: reservation_pins + the substrate contract_client /
bounds_cache for the Phase-9 axon reserve path; contract_client.py stays for the
B4 CLI/miner repoint.

Full suite 550 green; localnet scoring e2e
(tests/test_solana_scoring_integration.py) drives quote/collateral/activate/bind
on the live B3.0 contract, ingests the events, and asserts the eligibility gate
+ Solana-sourced crown round.

* feat(miner): B4.0 — Solana client builders + SolanaSwap adapter

Add the miner/admin instruction builders B4 needs onto AllwaysSolanaClient
(remove_quote, self-deactivate, withdraw_treasury, and the AdminConfig runtime
setters: set_halted / set_min|max_collateral / set_min|max_swap_amount /
set_consensus_threshold / set_fulfillment_timeout / remove_validator) with
discriminators copied from the IDL and the AdminConfig signer+config metas.

Add SolanaSwap + swap_from_solana: flattens the borsh Swap layout into the
fields the miner poller/fulfiller consume, keyed by swap_key (== keccak of
from_tx_hash, derived when not supplied). Replaces the ink! classes.Swap
shape (int id -> swap_key bytes, block timeout_block -> unix timeout_at, ss58
miner_hotkey -> miner pubkey, no per-swap fee).

tests/test_solana_b4_builders.py: independent Anchor-discriminator recompute +
meta/arg assertions per builder + adapter field-mapping/key-derivation. Suite
564 passing, 4 deselected.

* feat(miner): B4.1 — repoint fulfillment + poller onto Solana

swap_poller: drop the ink! integer-cursor scan (get_next_swap_id + per-id
get_swap, with its transient-miss machinery). Solana exposes swaps via an
atomic getProgramAccounts snapshot per status, so the poller now enumerates
get_swaps('Active'/'Fulfilled'), filters to this miner's pubkey, and returns
SolanaSwaps keyed by swap_key. last_poll_ok still guards the caller's
send-cache cleanup against a failed (empty) poll.

fulfillment: SwapFulfiller takes the Solana client (signer = its keypair) —
drops wallet/subtensor/fee_divisor. The miner sends the FULL pinned
swap.to_amount (the 1% fee skims from collateral at confirm_swap, not this
leg), and mark_fulfilled records only the dest tx hash/block. Deadlines move
to the unix axis: Swap.timeout_at + int(time.time()) replace timeout_block +
get_current_block; new MINER_TIMEOUT_CUSHION_SECS / SENT_CACHE_DISCARD_MARGIN_SECS
(block constants * 12s). SentSwap + all per-swap dicts rekey int id ->
swap_key hex; the cache file follows.

Tests rewritten for the snapshot/unix model (cushion boundaries, full-amount
no-fee return, send-cache retain/discard, retry-not-gated-by-cushion,
absent-then-rediscovered idempotency, cache roundtrip). neurons/miner.py wiring
follows in B4.2 (not test-covered). Suite 560 passing, 4 deselected.

* feat(miner): B4.2 — wire the miner neuron to Solana

neurons/miner.py builds AllwaysSolanaClient(SOLANA_RPC_URL, keys.load_or_create())
(same keypair loader the validator uses) and drives SwapPoller/SwapFulfiller off
it + the miner's Solana pubkey. Drops the ink! contract_client and its subtensor
propagation (the Solana client has its own RPC; only the subtensor-backed chain
providers still need reconnect).

ensure_hotkey_bound: best-effort auto-bind on startup — the bt hotkey (sr25519)
signs its own Solana pubkey bytes and submits bind_hotkey, so on-chain state
keyed by pubkey attributes to this UID. Idempotent (skips if already bound),
re-verifies locally exactly as the validator will, and degrades to a logged
warning (alw miner bind-hotkey retries). Replicates the sign/verify primitive
inline rather than importing validator internals.

load_my_addresses now reads the miner's own MinerQuote PDAs (chain -> address on
both legs) instead of the substrate commitment; the quote-posted flag still
refreshes the cache. forward() updated for the snapshot poller (no .active dict;
live set = active|fulfilled keys; swap_key hex in logs/cleanup). neurons.miner
imports clean; suite 560 passing, 4 deselected.

* feat(cli): B4.3 — repoint miner-facing CLI onto Solana

helpers: add to_lamports/from_lamports + get_solana_cli_context (loads the
miner's Solana keypair; identity is the pubkey, not the bt hotkey). The
deferred taker/admin commands keep using get_cli_context untouched.

collateral: deposit/withdraw/view in SOL/lamports off the Solana client —
deposit pre-flights Config.max_collateral + the keypair's lamport balance;
withdraw gates on MinerState (active / has_active_swap / busy_until /
deactivation_at + 2*fulfillment_timeout_secs cooldown, all unix-secs); view
takes --pubkey. recover-from-hotkey left as-is (pure substrate bt utility).

miner_commands: status reads MinerState + collateral + own MinerQuotes + active
swaps via get_swaps filtered by pubkey; deactivate -> client.deactivate()
(self-deactivate); mark-fulfilled reworked to --from-tx-hash -> swap_key +
client.mark_fulfilled (no amount, pinned on-chain); new bind-hotkey (hotkey
sr25519-signs the pubkey, re-verifies locally, calls bind_hotkey). activate
stays on the dendrite-broadcast transport (validators already vote_activate on
Solana, B2.4) — repointed with the Phase-9 dendrite_lite migration.

pair: alw miner post -> client.set_quote per offered direction (rate stored as
int(display * RATE_PRECISION); validator decodes it back), keeps the rate-posted
flag for the running miner.

tests/test_cli_miner_solana.py: 8 CliRunner tests (deposit lamports + max-cap,
withdraw active/cooldown, self-deactivate, bind-hotkey verifying-sig +
already-bound skip, pair set_quote rate-scaling). Suite 568 passing, 4 deselected.

* feat(cli): B4.4 — repoint + trim admin CLI onto Solana

admin.py drives the program's AdminConfig setters via get_solana_cli_context
(signer = the admin Solana keypair): set-timeout/set-reservation-ttl now in
seconds, collateral + swap bounds in SOL/lamports, set-threshold, and
add-vali/remove-vali by Solana pubkey (membership read off Config.validators,
add takes --weight). halt/resume toggle Config.halted. Errors -> SolanaClientError.

Trimmed the ink!-only ops with no Solana equivalent: recycle-fees,
enable-chain-ext, transfer-ownership (fee-recycle dropped for a treasury vault;
no chain extension; admin is a Config field). Replaced fee-recycle with
withdraw-treasury (admin draws accrued fees from the Treasury PDA to a
recipient).

solana client: add set_reservation_ttl builder (i64 AdminConfig setter) for
admin parity. Tests: set_reservation_ttl added to the B4.0 builder coverage;
new tests/test_cli_admin_solana.py (setter reads Config + calls the Solana
method, add-vali weight, withdraw-treasury full-balance default, halt-idempotent,
ink!-only commands absent). Suite 574 passing, 4 deselected.

* test(miner): B4 localnet integration — miner/admin builders on-chain

tests/test_solana_b4_integration.py (@pytest.mark.integration): deploys the
program, activates a real bound miner via the B4 client methods (post_collateral
+ set_quote + bind_hotkey + vote_activate), then asserts everything B4 added that
is reachable without the Phase-9 reservation flow against live on-chain state:
SwapPoller reads getProgramAccounts -> ([],[]) (no Active swaps yet), miner
self-deactivate flips MinerState.active, remove_quote closes the MinerQuote PDA,
mark_fulfilled on a missing swap raises SolanaClientError, the admin runtime
setter + halt toggle land on Config, and withdraw_treasury rejects an
over-balance draw.

The mark_fulfilled HAPPY path (Active miner-owned swap -> Fulfilled) needs a
live Reservation that only the Phase-9 pool flow creates — same gate B2.5/B3.6
documented; the Rust e2e.sh onchain_* tests cover that lifecycle. Verified green
against a freshly deployed validator (all assertions passed; torn down after).
Unit suite 574 passing, 5 deselected.

* fix(miner): B4.1 fee model — miner delivers 99%, not the full to_amount

B4.1 wrongly had the miner send the full pinned to_amount. The decided model is
Option A (already implemented in the validator's solana_swap_loop): the miner
delivers apply_fee_deduction(to_amount, FEE_DIVISOR) = 99%, and the protocol's
1% is skimmed from the miner's SOL collateral at confirm_swap. The validator's
verify_fulfillment checks the dest leg delivered exactly 99% (expected_amount =
expected_user_receives), so a miner sending 100% would mismatch and fail
confirmation. The 1% withheld on the dest leg offsets the collateral skim → the
user bears the fee, the miner is a pass-through (matches the ink! incidence).

verify_swap_safety now returns apply_fee_deduction(swap.to_amount, fee_divisor);
SwapFulfiller regains a fee_divisor param (default FEE_DIVISOR=100, mirroring
SolanaSwapLoop). mark_fulfilled still records only the tx hash/block (to_amount
is pinned on-chain). Test asserts the 99% post-fee amount. Suite 574 passing.

* fix(cli): B5 — honor configured Solana program-id / RPC

get_solana_cli_context hardcoded pdas.PROGRAM_ID and read the RPC from
SOLANA_RPC_URL only, so 'alw config set program-id <addr>' (and the legacy
'contract' key) silently had no effect. Read program-id (+ contract alias)
and solana-rpc from config, pass program_id through to AllwaysSolanaClient,
and let the env var still override the RPC. Also accept 'contract' in the
ink! taker get_cli_context. Adds program-id/solana-rpc as valid config keys.

* refactor(validator): B5 — drop dead substrate voting.py

confirm_swap/timeout_swap substrate vote wrappers had zero importers — the
validator votes CONFIRM/TIMEOUT through the Solana client builders in
solana_swap_loop since B2.3. Untouched since pre-migration; no test covered
it. Removing the last orphaned ink! straggler outside the deliberate
Phase-9 reserve-intake surface.

* feat(validator): C1 — standalone market-rate feed (TAO/BTC)

MarketRateFeed derives canonical TAO/BTC as BTC_usd/TAO_usd, each leg
averaged over whatever public sources respond (Coinbase+Kraken,
CoinGecko+MEXC). TTL-cached, best-effort: a failed refresh serves the
last-good value within MARKET_RATE_MAX_STALE_SECS, then returns None so
the Phase-C rate_quality curve degrades to neutral rather than zeroing
rewards. Standalone — no scoring coupling. Sources/clock injectable for
network-free tests.

* feat(validator): C2 — rate-quality one-sided clamp curve

Replace the rate_quality stub with a direction-aware curve over realized
VWAP vs market. realized_canonical_rate converts native MinerDirectionStats
leg totals to canonical TAO/BTC (reusing chain decimals); rate_advantage
orients it per direction (btc→tao higher-better, tao→btc lower-better, same
as the crown's lower_rate_wins); quality_curve is a one-sided clamp — 1.0
at/above market within a tolerance deadband, ramping to RATE_QUALITY_MIN at
RATE_QUALITY_FLOOR_ADV. Above-market is capped at 1.0 so it doesn't double-
reward rate (the crown already does) or reward wash-trade quotes. market_rate
None/<=0 and zero-volume both return neutral 1.0. No reward change yet — the
call site passes market_rate=None until C3 wires the feed and flips W_B.

* feat(validator): C3 — turn on quality-volume reward (w_a=0.8/w_b=0.2)

Wire the MarketRateFeed onto the validator and read it once per scoring
round; pass the live TAO/BTC + direction into rate_quality so realized
volume at fair rates earns the w_b slice. fetch_market_rate is best-effort
(missing feed / failure → None → neutral 1.0), matching contract_is_halted.

Set the live weights to w_a=0.8 / w_b=0.2 (sum=1, envelope preserved).
A direction with zero realized volume falls back to w_a=1.0/w_b=0.0 so a
quiet direction pays the full pool via crown instead of recycling 20% —
the gentle rollout: w_b only ever redistributes among directions that
actually had volume, and a stale feed pays volume by raw share.

Update scoring reward math + docstrings; refresh the affected multi-miner
reward expectations for the 0.8/0.2 split and add TestPhaseCMarketRate
(below-market penalty, above-market cap, stale-feed at par, recycle of the
quality shortfall).

* feat(validator): C-rev rev1 — persist SwapCompleted clearing rates

Add a clearing_rates table (per-swap realized legs from SwapCompleted,
u128-safe TEXT columns) to the validator state store, and have
SolanaEventIndex persist a clearing-rate sample alongside the existing
busy -1 delta for every SwapCompleted. This is the on-chain per-swap
history the C-rev rate-quality reference will be built from, replacing
the external market feed.

state_store: clearing_rates schema + insert/get_in_range/prune (no
anchor preservation — each row is an independent sample).
event_index: SwapCompleted now fires both busy-close and clearing-rate
persist, attributed pubkey->hotkey at write like every other stream.

* feat(validator): C-rev rev2 — trimmed on-chain rate-quality reference

Add the deterministic per-direction reference the rate-quality curve will
compare against: a trimmed, volume-weighted, per-miner-capped average of
completed-swap clearing rates read off the clearing_rates table.

scoring: _canonical_rate_and_weight (canonical 'dest per source' rate +
canonical-source native weight, so a weight-mean equals aggregate VWAP in
either direction; realized_canonical_rate now delegates to it),
trimmed_reference (per-miner cap → weighted trim → weighted mean, pure float
ops in a fixed sorted order = identical across validators), DirectionReference
+ build_direction_references (one clearing_rates query per direction over a
24h window; reference + each miner's windowed VWAP from the same rows).
constants: RATE_REFERENCE_WINDOW_SECS/TRIM_FRAC/MINER_CAP_FRAC/MIN_SWAPS.

Not yet wired into scoring — rev3 swaps it in for the external market feed.

* feat(validator): C-rev rev3 — wire on-chain reference, remove market feed

Point the rate-quality curve at the deterministic on-chain reference and
delete the external market feed entirely. Scoring is now fully Solana-
sourced — no network, no cross-validator divergence from per-validator
fetch timing.

scoring: rate_quality re-signed to (from, to, realized_rate, reference_rate)
— neutral 1.0 when the reference is undefined (thin in-window history) or the
miner has no realized rate; calculate_miner_rewards builds the per-direction
references once per round and compares each crown holder's OWN windowed
realized rate against the reference; rate_advantage param renamed
market_rate->reference_rate; prune_crown_events also prunes clearing_rates on
its wider 24h horizon; fetch_market_rate removed.
constants: RATE_QUALITY_FLOOR_ADV tightened -0.10 -> -0.05 (realized-vs-
realized clusters tighter than posted); MARKET_RATE_* block removed.
neurons/validator: MarketRateFeed import + construction removed.

Removed allways/validator/market_rate.py + tests/test_market_rate.py.
Tests: test_rate_quality re-pointed to (realized, reference); the scoring
TestPhaseCMarketRate -> TestCrevReference seeds clearing_rates to drive the
reference below/above/thin, plus a determinism assertion (identical history
=> identical rewards). Full unit suite 626 passed; localnet scoring
integration green.

* test(validator): exercise executable-rate crown gate on localnet

The scoring integration test initialized Config with min/max swap = 0, which
makes is_executable_rate fail open (rate.py:150) — the executable-rate and
boundary-squat crown gates were never actually fired on-chain. Set non-zero
bounds and add a third 'sentinel' miner that quotes the highest btc->tao rate
of all but one that is unexecutable against those bounds. Assert it wins no
crown and earns 0 while the best executable-rate miner takes the full pool,
proving the bounds reach the on-chain Config and the gate fires.

* refactor(validator): B6 — decommission ink!/substrate surface, Solana-only

The off-chain rewrite (B0–B5 + Phase C/C-rev) was done but two consumers still
pinned the ink! client, leaving the validator split-brain. Remove them so the
validator + miner + CLI run Solana-only and the validator boots with zero ink!
dependency.

- handle_swap_reserve: stub with a Phase-9 rejection (reservations move to the
  Solana reservation pool; no open_or_request builder exists and the contract
  path is a stake-weighted lottery, not FCFS). Strip the ink!/SCALE/commitments/
  snapshot/cooldown plumbing + dead hash helpers.
- taker CLI: stub the reservation/swap-intake commands (swap/claim/status/quote/
  post-tx/resume-reservation + view) via helpers.phase9_unavailable; repoint
  miner activate + discover_validators onto Solana; add CliError; strip the ink!
  client from get_cli_context.
- neurons/validator.py: drop the AllwaysContractClient construction + substrate
  BoundsCache (keep axon_subtensor + SolanaConfigCache).
- delete allways/contract_client.py, allways/utils/scale.py, allways/commitments.py
  and their tests; replace test_axon_handlers.py with a stub-path test.

Acceptance grep over allways/ + neurons/ (non-test) is empty; neurons.validator,
neurons.miner, allways.cli.main import clean; unit suite 474 passed, 5 deselected.
Boot smoke deferred (e2e suite under reconstruction).

* feat(validator): B7 — SOL swap-asset provider for sol↔btc / sol↔tao legs

Adds CHAIN_SOL + SolanaProvider so both legs of every launch pair can be
verified end to end. The SOL leg is a peer-to-peer user↔miner transfer (the
program never custodies swap assets), verified like a BTC deposit: look the
signature up by hash via getTransaction and match a >= lamport credit
(balance-delta), reading the on-chain blockTime as the B2 replay-freshness floor.

- chains.py: CHAIN_SOL (lamport, 9 decimals, ~slot finality confs, rent-exempt
  min_onchain_amount) registered in SUPPORTED_CHAINS.
- chain_providers/solana.py: full ChainProvider over solana/rpc.py — fetch/verify,
  confirmations, balance, base58 pubkey validity, ed25519 proof sign/verify,
  SystemProgram send. Native-SOL path isolated so an SPL (sol↔usdc) leg is a
  clean future extension.
- rpc.py: getSlot + getBalance.
- registered 'sol' in PROVIDER_REGISTRY; miner forwards its Solana keypair for
  the dest leg (validator stays read-only).
- swap loop + freshness gates already chain-agnostic — no changes needed.
- 29 unit tests + a localnet integration test (real transfer, blockTime read).

* fix(validator): close slash-escape, verify axon binding, drop dead code

M1 review pass on the off-chain Solana migration:

- decide(): an overdue Fulfilled swap with an unverifiable dest leg now votes
  TIMEOUT, not WAIT-forever. A miner marking fulfilled with a junk to_tx_hash
  before the deadline previously escaped the slash and the user (already funded)
  was never refunded. Contract timeout_swap already accepts Active|Fulfilled.
  (also: >= timeout boundary to match the contract.)
- resolve_miner_pubkey(): verify the sr25519 binding signature (as the scoring
  attribution path does) before trusting the hkbind marker — blocks a hotkey
  squat with a garbage sig.
- scoring: build the sr25519 attribution snapshot once per round and share it
  with eligibility + volume (was 3x verify over all bindings).
- remove dead code: MAX_SCORING_BACKFILL_BLOCKS, blocks_to_minutes_str +
  SECONDS_PER_BLOCK, realized_canonical_rate (moved to a test-only helper),
  stale market-feed comment; fix 6 pre-existing F401/I001 lint errors.

* style: ruff format the off-chain Solana migration

The branch predated a format pass (shipped with lint errors). Apply ruff
format repo-wide so CI's format check passes; pure whitespace, no logic change.

---------

Co-authored-by: Landyn <landynmoreno@gmail.com>
* Add open_or_request and resolve_pool client builders

* Add default-on validator resolve-pool crank

* Re-anchor canonical_pair: SOL is canonical source

* Flip launch pairs to SOL and re-anchor rate gate to SOL leg

* Wire taker swap-now origination on Solana

* Restub swap reserve as FCFS; remove dead reservation_pins

* Add SOL-numeraire quoting: one price per chain to all pairs

* Restub swap reserve handler; drop reservation_pins code

* Repoint program id to a committed dev keypair

* Fix scoring integration test for SOL-pair gate semantics

* Rename min/max_swap_rao to _lamports; drop dead TAO helpers

* Log origin file:line on validator step errors

* Fix metagraph resync IndexError on shrunk hotkey list

* Add NUMERAIRE_CHAIN hub constant; derive direction pools from spokes

* Correct stale CLI stub comments; rename phase9_unavailable to taker_view_unavailable

* Make program id env-overridable via ALLWAYS_PROGRAM_ID
* Reject attestation when to_amount diverges from pinned rate

* Log reject via swap.swap_key, drop redundant _swap_key helper
* Extend deadlines for valid-but-unconfirmed legs

* Tighten extension padding to 120s
* Forfeit crown for busy miners via activity states

* Prune activity events under a single lock (no read/delete gap)
* Pass solana_rpc_url explicitly to chain providers

* Document SOLANA_RPC_URL in .env.example
* Expose remaining admin setters and config views

* Drop set-halted, keep danger halt/resume as the halt surface
…er (#506)

Bind ./data/solana -> /root/.solana (read-only) in both compose files so the
Solana keypair is operator-provisioned and survives container restarts, rather
than load_or_create silently minting a throwaway key on ephemeral storage; the
read-only mount also makes a missing key fail loudly at startup.

Also drop the now-unused btc-node service from the validator compose (validators
run BTC_MODE=lightweight) and remove a stale doc pointer in solana/__init__.py.
Scoring already integrates crown duration on the unix (blockTime) axis, then
re-quantized those intervals back into per-tick crown_holders rows purely to
fit the block-keyed dashboard table. Persist the intervals directly instead
(intervals_to_crown_rows), and rename the crown-time quantities off "block":
crown_blocks->crown_time, *_max_block->*_max_ts. The subtensor-cadence
counters (SCORING_WINDOW_BLOCKS, due_for_scoring) stay as blocks.

Emissions math is unchanged. 584 tests green, ruff clean.
The initial reservation-lottery window (POOL_WINDOW_SECS, seeds
Config.pool_window_secs) was an arbitrary 60s. Drop to 30s; it stays
runtime-adjustable via set_pool_window (dev seeds 5s).
LandynDev and others added 23 commits July 6, 2026 23:00
The swap loop only logged submitted votes, so a WAITing swap was silent
(a stalled Fulfilled swap looked identical to a healthy one until it timed
out). Attach a human reason to every SwapAction (leg tri-states / why) and
log one line per swap per pass, so the validator's log tells the whole
story: source/dest verification, why it waits, and the confirm/timeout/
reject decision. Log every reservation-pool contender (router/user, base58)
before the stake-weighted draw. Miner logs when a swap leaves its active
set (resolved). Logging only.
…emoval + regression tests (#507)

* Bump extension ceiling to 120m, add accountability guardrails, retire BTC node mode

- contract: MAX_TOTAL_EXTENSION_SECS 90->120min for edge-case BTC confirmation headroom
- tests: guardrail for #421 (payout must come from committed wallet) + Bug-2 (delivered
  payout that exhausts its extension budget past the ceiling still slashes)
- constants: drop 6 dead block-era/orphan constants; correct stale MAX_EXTENSIONS_PER_SWAP
  comment (v2 bounds extensions by the ceiling only, no per-swap count cap)
- BTC: remove local-node mode entirely (Esplora/embit only) -- rpc_* methods, BTC_RPC_* env,
  btc-node docker service, entrypoint guards; soft-warn if a stale BTC_MODE=node lingers

* Add LiteSVM regressions: reservation-terms freeze, confirm-past-deadline, ceiling pin

- test_swap_terms_frozen_against_post_reservation_requote (inv A): a miner re-quoting after it has
  reserved (different payout addr + doubled rate) cannot redirect the in-flight swap; terms are read
  from the frozen Reservation, never the live quote — the historic fund-theft hole.
- test_confirm_succeeds_after_deadline (inv B): confirm_swap has no deadline gate; a Fulfilled swap
  still confirms when quorum lands past timeout_at — locks in the 262/263/264 delivered-past-deadline fix.
- test_extension_ceiling_is_120_minutes (inv D): pins MAX_TOTAL_EXTENSION_SECS == 7200 so a silent
  revert of the 120-min bump fails a test (dynamic ceiling tests wouldn't catch it).
- inv C (executable-rate band) is off-chain by design (tests/test_rate.py) — noted in-test, no LiteSVM.

* Add halt-gating + input-validation regressions; restore whitespace tx-hash guard

- test_swap.rs: halt blocks new entry (post_collateral/vote_activate) and lifts on unhalt; an
  in-flight swap still confirms while halted (PRs 8/482/458). Adds set_halted_ix + a setup_full
  variant that exposes the admin key.
- test_input_validation_regressions.py: SS58 checksum validation (PR 312) + empty/whitespace
  source-tx-hash rejection (PR 167).
- reserve_engine.confirm_deposit: restore PR-167's strip+reject guard for whitespace-only hashes.
  The original CLI guard was lost when reserve->initiate moved on-chain (resume.py is now a stub),
  so whitespace hashes were passing intake again.

* Fix code-review findings: sent-cache margin must cover the 120m extension ceiling

- constants.py: SENT_CACHE_DISCARD_MARGIN_SECS was 6600s (110m), derived from stale block constants,
  but the contract now slides deadlines cumulatively up to MAX_TOTAL_EXTENSION_SECS=7200s (120m). A
  miner whose cached timeout_at missed extensions would discard its unmarked sent entry ~10m before
  the swap stops being claimable, then re-send on rediscovery (#461 double-send / double-payout). Tie
  the margin to the contract ceiling (7800s = 7200 + one base window) and drop the now-dead block
  constants (MAX_EXTENSION_BLOCKS, MAX_EXTENSIONS_PER_SWAP, SENT_CACHE_DISCARD_MARGIN_BLOCKS).
- bitcoin.py: warn when BTC_NETWORK is unset and defaulting to mainnet — node mode's RPC-URL network
  inference is gone, so a testnet operator who never set it would otherwise silently run mainnet.

---------

Co-authored-by: anderdc <me@alexanderdc.com>
#508)

Same program id (AKgf…74JU) across all clusters so `anchor deploy
--provider.cluster devnet|mainnet` and IDL/client tooling resolve the
program address without per-cluster declare_id! swaps. Part of the
local→devnet→mainnet migration tooling.

Co-authored-by: anderdc <me@alexanderdc.com>
* Report slashed swaps as timed_out at the seam

* Reconnect dashboard DB writes after connection loss

* Ignore .full-e2e dev scratch directory

* Gate dashboard writes on configured, not connected

* Fall back non-terminal on swap outcome miss

* Resolve seam status by swap_key past attestation
…_VALIDATOR_READONLY (#511)

SOLANA_VALIDATOR_READONLY only gated the Solana consensus vote — a 'read-only'
validator still set Bittensor weights, so it still influenced emissions. That's
the wrong default for staging a validator on testnet.

VALIDATOR_DEV_MODE=1 makes the validator fully observe-only:
- Solana swap loop logs "WOULD …" instead of voting (unchanged behavior), and
- should_set_weights() returns False, so no weights are set.

SOLANA_VALIDATOR_READONLY stays honored as a deprecated alias (one-time warning)
so existing deployments don't break. Documented in .env.example; unit-tested.

Co-authored-by: anderdc <me@alexanderdc.com>
Follow-up to #511: drop the deprecated alias completely. validator_dev_mode()
now reads ONLY VALIDATOR_DEV_MODE — no back-compat path, no warn-once state.
Any remaining SOLANA_VALIDATOR_READONLY in a deployment .env is now inert;
switch to VALIDATOR_DEV_MODE=1.

Co-authored-by: anderdc <me@alexanderdc.com>
* Drop legacy B3.5 swap_outcomes schema at init

* Tighten legacy-drop comments and test
* feat(cli): per-chain network config + quote churn-fee visibility

Config (config.json via `alw config set`):
- solana-network (devnet/mainnet/localnet) → RPC resolved in code (SOLANA_NETWORKS),
  precedence SOLANA_RPC_URL env > solana-rpc config > solana-network name > localnet.
  Fixes the footgun where only `network` (bittensor) was set and Solana silently used localhost.
- btc-network (mainnet/testnet4/…) fed to the BTC provider's BTC_NETWORK env (real env wins).
- env one-liner bundle: `alw config set env testnet|mainnet` sets all three chains + netuid.
- raw-URL escape hatches (solana-rpc / SOLANA_RPC_URL / BTC_ESPLORA_URLS) preserved for paid RPCs.

CLI UX (alw miner quotes):
- shows each direction's current rate + the per-direction churn fee this update incurs
  (0.01<5min / 0.001 5-10min / free after 10min) + total, before charging; --dry-run to preview.

Unit-tested (tests/test_network_config.py). alw pair could get the same fee-visibility next.

* feat(cli): churn-fee visibility + --dry-run for alw pair (parity with miner quotes)

Shows the total per-direction churn fee this pair update will cost before the confirm, and
adds --dry-run to preview without posting — same treatment as alw miner quotes.

---------

Co-authored-by: anderdc <me@alexanderdc.com>
* Sweep ink-era rot: dead dataclasses, duplicate constant, chain literals

- Delete unused ink-era dataclasses (MinerPair, Reservation, Swap,
  PendingExtension) from classes.py; re-point expected_swap_amounts to its
  real runtime type SolanaSwap via a TYPE_CHECKING import
- Drop the duplicate RATE_PRECISION in solana/pdas.py (unused there);
  constants.py is the single source of truth
- Replace hardcoded 'sol' with NUMERAIRE_CHAIN in swap.py launch checks
- Fulfillment: pass the cached dest address uniformly; subtensor ignores
  the hint, so the =="tao" special-case was dead

* Derive miner quote flags from the spoke registry

The 'alw miner quotes' command hand-typed a --btc-*/--tao-* option pair per
spoke. Generate them from LAUNCH_SPOKES instead, so adding a spoke there
grows the flags (and the help example) automatically. Same explicit,
scriptable flag surface — no behavior change.
A pre-attestation claim reaped by close_stale_claim closes the Swap PDA
with no funds moved. The seam previously read those swaps as non-terminal
fulfilled forever; record an 'expired' outcome so consumers see terminal
truth.
* Un-stub the CLI taker views and make the whole CLI script-safe

Read views (were stubs that printed 'not available' + exited 0) now read the
live Solana program:
- swap quote: every viable miner + what you receive after the 1% fee, best ★
- view miners / rates: aggregate MinerQuote + MinerState + collateral, with
  runtime status, --status/--sort/--min-capacity/--search filters honored
- view active-swaps / swap <hex> / reservation: on-chain Swap/Reservation reads
- status: network + RPC/program health + balance + miner-or-taker dashboard
- --json on every read view for automation

Script safety (the big defect: everything exited 0):
- shared fail() routes every error path to a non-zero exit
- safe_read() turns RPC/decode/transport faults into clean non-zero, no stacktrace
- deferred fund relays (post-tx/claim/resume) now exit 2 with honest guidance to
  the browser flow, replacing the false 'old ink! surface' message

Accuracy/rot:
- view --min-capacity unit TAO->SOL; view swap int id -> 32-byte hex swap_key
- drop dead --auto flag; drop legacy substrate contract-address from config
- main.py 'guided interactive' -> flag-driven; secs_str shows bare <60s
- quote flags already registry-derived (LAUNCH_SPOKES)

Tests: collateral rejection tests now assert non-zero exit (the fix); +1 stale
F401 and +1 import-sort lint fix in unrelated files to keep ruff check . green.
622 pass, ruff clean, verified live against the local solana stack.

* Harden CLI against adversarial QA: input validation, JSON errors, honest rates

Fixes found by an adversarial pass over every command:
- Reject nan/inf/1e999 amounts at parse time (FiniteFloat type) — no more
  tracebacks from any amount/price input across quote/swap/collateral/admin
- swap quote --json now exits non-zero on no-offer, matching the table path
- fail() emits {"error": ...} in --json mode so error paths stay parseable;
  set_json_output wired through quote/status/view
- Display effective directional rate (to per 1 from) in quote/view rates/view
  miners/miner status — '476.19 SOL/BTC' instead of the backwards hub rate
- Stub help no longer advertises dead flags; post-tx/claim/resume exit 69
  (EX_UNAVAILABLE, distinct from Click's usage-error 2)
- status --miner <garbage> fails instead of silently reporting no reservation
- admin gains group-level --yes (+ ALW_ASSUME_YES) so setters run headless
- --json added to view config / view validators; cleaner RPC-down message
- uninitialized program reports at exit 0 (JSON-clean), like empty results

622 pass, ruff clean, verified live against the local solana stack.
…CLI post-tx relay (#517)

* feat(validator): deferred-confirmation source intake + stale-claim reaper

Restore the ink!-era "deferred confirmation" for source deposits, dropped in the
Solana port (#496, which made confirm_deposit reject anything not already fully
confirmed — "no queue"). Lean on-chain instead of a validator-side queue: the Swap
PDA in PendingAttestation IS the queue entry, and the crank re-checks it each round.

- confirm_deposit (reserve_engine): accept a content-valid deposit even before it
  fully confirms; fast-fail with NO claim only when verify_transaction returns None
  (absent/content-mismatch), so the short reservation TTL frees the miner. Freshness is
  enforced only when block_time is present (mined); a 0-conf mempool tx defers freshness
  to the crank's 'ok' gate. BTC Esplora returns full vin/vout for mempool txs, so
  content-matching works pre-mining.
- crank (solana_swap_loop): add a CANCEL decision + _claim_is_stale that mirror the
  contract's close_stale_claim guard, reaping an orphaned PendingAttestation claim
  (reservation expired past its ceiling, or its slot re-resolved) to free the miner and
  reclaim the Swap PDA rent. The pending->extend / ok+fresh->attest deferral was already
  implemented; this closes the timeout gap (decide() had no PendingAttestation deadline).
- client/layouts: wire the pre-existing close_stale_claim instruction (discriminator +
  builder). No contract/IDL change — the instruction already exists; only the trigger
  was missing.

Note: claimed_swap_key reading all-zeros post-attestation is by design (vote_initiate
zeroes it + reserved_until at quorum), not a bug; the double-claim guard is set at
submit_swap_claim and cleared at attestation.

Tests: +18 (intake tri-state incl. fast-chain regression, reaper decisions, cast/benign
races, close_stale_claim discriminator vs Anchor sha256 + account metas). Full suite green.

* feat(cli): re-port `alw swap post-tx` — deferred-confirm relay via SwapConfirmSynapse

Un-stub the CLI taker confirm path. `alw swap post-tx <tx-hash>` relays the source-tx
hash to serving validators via a SwapConfirmSynapse; each validator verifies the on-chain
deposit against the pinned Reservation (sender/recipient/amount/freshness) and submits the
claim. The confirm is permissionless — signed by an ephemeral throwaway hotkey as transport
only (dendrite_lite.get_ephemeral_wallet); the on-chain deposit is the auth, so a pure-Solana
taker needs no registered neuron. Resolves the winning miner from the pending-swap stash
(fast path) or by scanning bindings (fallback); --miner relays on anyone's behalf. Verified
live on devnet/testnet (V1: ok → on-chain Swap created → FULFILLED).

helpers.py: add hotkey_bytes_to_ss58 + load/clear_pending_swap.

Complements the deferred-confirmation validator intake on this PR (#517) — a low-conf source
now accepted and deferred rather than rejected.

---------

Co-authored-by: anderdc <me@alexanderdc.com>
…lw config (#518)

The bt wallet is config-selectable but the Solana signer was env-only:
get_solana_cli_context always loaded SOLANA_KEYPAIR_PATH / ~/.solana/id.json,
so admin/miner commands could silently sign with an auto-generated, unfunded
key ('found no record of a prior credit' / authority rejections).

- resolve_solana_keypair_path (helpers.py), mirroring resolve_solana_rpc:
  SOLANA_KEYPAIR_PATH env > solana-keypair config > ~/.solana/id.json,
  with ~ expansion.
- load_cli_keypair: an explicitly configured path that doesn't exist fails
  loudly via fail() instead of minting a fresh key; only the bare default
  keeps the auto-generate convenience. Wired into get_solana_cli_context.
- config set solana-keypair validates the file loads at set time and echoes
  the resolved pubkey; bad path/file exits non-zero via fail().
- alw config prints the resolved signer pubkey; alw status resolves its
  caller keypair through the same path.
- tests mirror the resolve_solana_rpc coverage, incl. missing-explicit-path
  fails-not-generates and bare-default still auto-generates.

Miner/validator startup (keys.load_or_create() with no path) is untouched.

Co-authored-by: anderdc <me@alexanderdc.com>
…521)

BTC-source/dest swaps need more confirmation slack: a run of slow blocks can
leave an honest, adequately-fee'd payout waiting past the old 120-min budget,
and the ceiling is the only bound on extensions (no per-swap count cap).

The value that actually gated `set_max_total_extension` was the runtime hard
lid MAX_TOTAL_EXTENSION_SECS_MAX, not the initialize default — raising only the
default would have tripped the const assert. Both move to 8400.

- constants.rs: MAX_TOTAL_EXTENSION_SECS 7200 -> 8400 (initialize default)
- constants.rs: MAX_TOTAL_EXTENSION_SECS_MAX 7200 -> 8400 (runtime lid)
- validate.rs / doc comments / const assert message: 120 min -> 140 min
- test_extensions.rs: pin both the default and the lid at 8400
- constants.py: CONTRACT_MAX_TOTAL_EXTENSION_SECS mirror -> 8400
  (SENT_CACHE_DISCARD_MARGIN_SECS derives to 9000 = 8400 + one 600s window)

No account layout change; the regenerated Anchor IDL is byte-identical.
Deployed to devnet in place (same program id AKgf…74JU),
sig 64MmnnRCpYQct4ywg4oXSrGkAXLfCVQc3KTB8UZCavvAu8ses2GXufUzUThPaMf4hZb7Ln2Zc3K4PgbBAnJCJhMb
Verified on-chain: set-max-extension 8400 succeeds, 8401 rejected (InvalidAmount).

Co-authored-by: anderdc <me@alexanderdc.com>
)

`_poll_reservation` returned on `reserved_until != 0`, which matches a reservation left over from an
abandoned reserve (non-zero but expired — nothing reaps it on-chain). `swap now` then only checked
`resv.user == caller`, so a taker retrying their own interrupted swap was told to "Reserved. Send X"
before the pool draw ran. The premature deposit goes straight to the miner (no escrow) and is rejected
on freshness (mined before resolve_pool stamps created_at) — no claim, no Swap, no timeout, unrecoverable.

Fix:
- Factor a shared `live_unclaimed(resv)` predicate into helpers (reserved_until > now AND empty
  claimed_swap_key) and use it in BOTH `_poll_reservation` (origination) and `post-tx` (confirm), so the
  two paths can't disagree. Origination now waits through the stale/expired leftover instead of matching it.
- Gate the send on the reservation outliving the source chain's confirmation wait: `_send_margin_secs`
  = min_confirmations × seconds_per_block + relay slack. Refuse to instruct a send that would strand funds.
- Distinct exit messages: draw-not-resolved vs another-taker-won vs reservation-too-short, all telling
  the taker NOT to send.
- Regression test for the exact trigger (non-zero-but-expired reservation, empty claim, caller's user).

Found on devnet 2026-07-08 during a sol<->tao pressure test (harness aborted before any loss).

Co-authored-by: anderdc <me@alexanderdc.com>
T3 assertion surface for the e2e harness: allways/dev_signal.py emits
one NDJSON line per lifecycle decision when ALLWAYS_DEV_SIGNAL is set
(no-op otherwise), with call sites beside existing logs — validator
vote_cast (+tx sig), per-swap decision, d1_reject, pool_resolved,
swap_outcome, crown_snapshot, scoring_rewards; miner source_verified,
dest_sent, marked_fulfilled, refuse reasons. ALLWAYS_DEV_FAULTS names
a flag file enabling the withhold_dest fault on send_dest_funds so the
harness can force the slash path on a running miner. Both env vars are
unset in production, making the module inert.
swap_status surfaced a dead reservation (reserved_until passed, never
claimed) as 'reserved' with its stale user. The offering's win-detection
then read "another validator's user holds this miner" and marked won
draws lost, retried into the same pool, and finally failed the swap —
found live by the alw-utils L5 offering drive. A claimed reservation
past its deadline still reports through its swap's stage (the stale-claim
reaper owns that path).
…eferral (#523)

A deferring swap and a swap about to be slashed rendered identically in the
logs. The per-pass line reported only `src=ok dst=pending`, with no way to tell
whether the leg had runway left or was one pass from `timeout_swap`.

Both `_decide_fulfilled` and `_decide_pending_attestation` already hold the leg
info carrying `.confirmations` -- they pass it straight to the extension
helpers and then discard it. Print it, plus the remaining runway, and record
where an extension moved the deadline. No extra RPC: the data is already fetched.

Also fix a mismatched reason: the source path stamped 'source confirmed-pending
near reservation expiry' onto the result of `_extend_reservation_action`
unconditionally, so a plain WAIT (not near expiry, nothing extended) still
claimed it was near expiry. The reason now follows the decision.

Found while driving a sol->btc swap on devnet: the dest leg sat at `dst=pending`
for minutes with no indication of confirmation depth or remaining runway.

Co-authored-by: anderdc <me@alexanderdc.com>
…524)

`alw swap now --from btc` refused every send: _send_margin_secs demanded
min_confirmations × seconds_per_block + 30 = 1230s of reservation life,
but reservation_ttl_secs is 480. No TTL satisfies it, so btc→sol could
not be originated through the CLI at all.

The confirmation-depth premise is stale. Deferred-confirmation intake
(#517) means confirm_deposit accepts a content-valid deposit *before* it
confirms — it fast-fails only when tx_info is None, and defers freshness
for 0-conf mempool txs (no block_time) — then submits the claim
immediately. On-chain, submit_swap_claim gates only on
`reserved_until >= now` and an empty claim slot; min_confirmations
appears nowhere in the claim path. Depth is carried afterward by the
crank, which turns a 'pending' source leg near expiry into an
EXTEND_RESERVATION under the 8400s max_extend_at ceiling.

So the reservation must outlive broadcast → mempool visibility → relay →
submit_swap_claim landing: chain-independent, tens of seconds. Replace
the function with a derived constant _SEND_MARGIN_SECS = 180 (60s post-tx
dendrite timeout + ~60s Solana claim landing + 60s slack) and correct the
comments, which justified the margin by a rule the contract never had.

The guard itself is load-bearing (#522, unrecoverable fund loss: source
funds go to the miner with no escrow) and is preserved — a reservation
too short to land the claim still refuses the send.

Co-authored-by: anderdc <me@alexanderdc.com>
…fig (#525)

The program address was resolved three different ways. `pdas.py` read
ALLWAYS_PROGRAM_ID at module-import time into `PROGRAM_ID`, falling back to a
second constant `DEV_PROGRAM_ID`; `AllwaysSolanaClient.__init__` bound that
module global as a default argument (also at import time); and the CLI's
`get_solana_cli_context` layered a config lookup on top that *overrode* the env.

Two consequences:

1. `.env` was silently ignored by both neurons. `neurons/validator.py` and
   `neurons/miner.py` import `allways.solana.client` before calling
   `load_dotenv()`, so the address was frozen to the committed default before
   the file was ever read. Only a genuinely exported shell var (compose
   `environment:`) reached them. `allways/cli/main.py` happened to get the
   order right. The failure is silent: a wrong program id derives different
   PDAs, so every account reads as absent rather than raising.

2. CLI precedence was config > env, inverted relative to its own siblings
   `resolve_solana_rpc` and `resolve_solana_keypair_path`, which are env > config.

Collapse all of it into `allways/solana/program.py::resolve_program_id(config)`:
env > CLI config (`program-id`, legacy `contract`) > `constants.PROGRAM_ID`.
Resolution is lazy, so import order no longer matters. `DEV_PROGRAM_ID` and
`PROGRAM_ID` fold into a single `constants.PROGRAM_ID`.

A malformed env value now raises rather than falling back — it is explicit
operator intent, and silently defaulting would point a mainnet node at the
devnet program. A malformed config value still warns and falls through.

Also hoist `load_dotenv()` above the allways imports in both neurons (correct on
its own merits, and stops this recurring for other env vars), and drop the dead
`CONTRACT_ADDRESS` constant — an ink!-era leftover no code has ever read.

.env.example: document ALLWAYS_PROGRAM_ID and SOLANA_KEYPAIR_PATH, remove
CONTRACT_ADDRESS, MINER_BTC_ADDRESS (the miner's addresses live on-chain in the
MinerQuote PDA via `alw miner post`), and BTC_MODE (local-node mode was removed;
it is now read only to warn on stale values). Tighten the remaining comments.

Co-authored-by: anderdc <me@alexanderdc.com>
…a guessed slot (#526)

The reservation lottery pinned its seed slot at pool-open as
`open_slot + pool_window_secs * 1000 / SLOT_MS`, with SLOT_MS hardcoded to 400.
That places the seed slot at exactly the moment the window shuts, leaving zero
margin, and real slot time is never exactly 400ms. Both directions broke:

  * Slots faster than 400ms — devnet measures 381ms, and with the live
    pool_window_secs=60 the seed slot was produced 2.85s BEFORE bidding closed.
    seed_slot is public (it was emitted in PoolOpened) and pool_key is a PDA, so
    a late bidder could compute keccak(pool_key || slothash), run pick_weighted,
    and enter only when it would win. Free option on a fair lottery.

  * Slots slower than 400ms (routine on mainnet) — the seed slot did not exist at
    closes_at, the descending scan broke on `slot < seed_slot`, and resolve fell
    back to `keccak(pool_key || seed_slot.to_le_bytes())`. Both inputs are public
    at pool-open, so the winner was fully predictable before anyone bid, and an
    opener could grind its open slot to elect itself.

A skipped seed slot hit the same fallback, since the lookup was an exact match.
The fallback was silent — no event — so a degraded draw was indistinguishable
from a real one after the fact. It existed largely because LiteSVM leaves
SlotHashes empty.

resolve_pool is now two-phase. The first crank after `closes_at` arms the draw on
`clock.slot + SEED_SLOT_DELAY_SLOTS`, a slot that does not exist yet; a later crank
resolves against it. Arming after the window shuts means no bidder can react to the
entropy, and pinning a future slot means nobody — including the arming caller —
knows its hash. No slot-time constant is involved, so this survives any future
change to Solana's slot cadence.

The lookup now takes the lowest produced slot at-or-after seed_slot, tolerating a
skipped seed slot without guessing. If seed_slot ages out of SlotHashes (~512
slots, i.e. a long stall) the draw re-arms rather than drawing from a hash the
caller could have waited for. Bids survive the re-arm.

Residual, unchanged and documented: the leader of the seed slot can bias its own
block's hash. Bounded — a reservation only grants a hold, and moving funds still
needs vote_initiate consensus plus a real on-chain tx. Closing that requires a VRF.

Tests no longer lean on a fallback: LiteSVM now seeds the real SlotHashes sysvar
via solana-slot-hashes, which also pins the contract's hand-rolled parse (8-byte
count + 40-byte descending entries) against Solana's own serializer. Adds unit
tests for the scan and on-chain tests for arm-after-close, skip tolerance,
retry-before-produced, and rolloff re-arm.

Validator: SeedSlotNotYetProduced joins the benign resolve markers — it is the
expected result of cranking between arm and draw.

Co-authored-by: anderdc <me@alexanderdc.com>
Replace the lifetime MinerDirectionStats account read with per-window
sums from the ingested clearing_rates ledger (get_clearing_volumes),
so both reward slices weigh what actually cleared in the scored hour.
A direction clearing under MIN_DIRECTION_VOLUME (1 SOL) on its SOL
side is scored as zero-volume: w_a folds to 1.0 and volume_factor
stays neutral, so dust swaps can't steer the round. Deletes the dead
account-based builder (build_direction_volumes, DirectionVolume,
realized_vwap).
miner_scores rows (the factors the round actually paid, per hotkey and
direction) flush in the same transaction as the crown ledger;
current_miner_scores is wiped and rewritten every forward step from the
same shared build_direction_score_rows math, so neither table can
disagree with the weights. The halt flush clears the tip and writes no
score rows. Validator rate_history writes removed — the indexer owns
that table (real-time, per QuoteSet) including its freshness cursor.
Base automatically changed from contract-v2 to test July 10, 2026 20:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants