Persist per-round score snapshots and a live mid-round tip#529
Open
LandynDev wants to merge 53 commits into
Open
Persist per-round score snapshots and a live mid-round tip#529LandynDev wants to merge 53 commits into
LandynDev wants to merge 53 commits into
Conversation
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).
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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.