This commit is contained in:
ZOMBIIIIIII
2026-06-11 12:03:51 +03:00
parent 399322973e
commit f8403eb9f6
23 changed files with 3998 additions and 6 deletions

View File

@@ -0,0 +1,132 @@
/**
* Авто-выбор «хорошего» Solana-валидатора для нативного стейкинга — чтобы работало из коробки
* без операторской настройки. Каскад с кэшем (KeyDB 24ч):
* 1. env override SOL_STAKE_VALIDATOR_VOTE (оператор может закрепить).
* 2. Stakewiz API (без ключа): фильтр delinquent/commission/skip_rate/uptime, сорт по wiz_score.
* 3. RPC getVoteAccounts: фильтр epochVoteAccount/commission/stake, сорт по (commission, credit growth).
* 4. Hard-coded last-resort (проверенный активный валидатор, 0% комиссия).
*
* Менять валидатор на новых стейках безопасно: stake-аккаунт запоминает свой voter в delegation,
* unstake/withdraw читают аккаунт, а не этот резолвер.
*/
import { Connection, PublicKey } from '@solana/web3.js';
import { env } from '../config/env';
import { getRedis } from '../config/redis';
import { logger } from './logger';
/** Last-resort fallback — Yurbason (0% commission, давний, проверен активным). */
const FALLBACK_VOTE = '9gANMngbGUmAaLXL1RC3JdiaLjRowJXNbzCTh53ht7mq';
const CACHE_KEY = 'sol:validator:default';
const CACHE_TTL_SEC = 24 * 60 * 60; // 24h
const STAKEWIZ_URL = 'https://api.stakewiz.com/validators';
const HTTP_TIMEOUT_MS = 6000;
let _conn: Connection | null = null;
function defaultConn(): Connection {
if (!_conn) _conn = new Connection('https://api.mainnet-beta.solana.com', 'confirmed');
return _conn;
}
function isValidVote(addr: unknown): addr is string {
if (typeof addr !== 'string' || !addr) return false;
try { new PublicKey(addr); return true; } catch { return false; }
}
/**
* Резолвит vote-адрес валидатора. Никогда не throws — деградирует до hard-coded fallback.
*/
export async function resolveStakeValidator(conn?: Connection): Promise<string> {
// 1) env override
if (isValidVote(env.defi.solStakeValidatorVote)) {
return env.defi.solStakeValidatorVote;
}
// cache
try {
const cached = await getRedis().get(CACHE_KEY);
if (cached && isValidVote(cached)) return cached;
} catch { /* ignore */ }
let chosen: string | null = null;
// 2) Stakewiz (внешний, untrusted) — выбор кросс-проверяется против on-chain перед доверием
chosen = await fromStakewiz().catch(() => null);
if (chosen) {
const ok = await verifyOnChain(conn ?? defaultConn(), chosen).catch(() => false);
if (!ok) {
logger.warn(`SOL validator: Stakewiz pick ${chosen} failed on-chain verification — falling back to RPC`);
chosen = null;
}
}
// 3) RPC fallback (on-chain authoritative)
if (!chosen) {
chosen = await fromRpc(conn ?? defaultConn()).catch(() => null);
}
// 4) last-resort
if (!chosen || !isValidVote(chosen)) {
logger.warn('SOL validator auto-select failed (Stakewiz + RPC) — using hard-coded fallback');
chosen = FALLBACK_VOTE;
}
try {
await getRedis().set(CACHE_KEY, chosen, 'EX', CACHE_TTL_SEC);
} catch { /* ignore */ }
return chosen;
}
async function fromStakewiz(): Promise<string | null> {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
try {
const res = await fetch(STAKEWIZ_URL, { headers: { Accept: 'application/json' }, signal: controller.signal });
if (!res.ok) return null;
const list = (await res.json()) as any[];
if (!Array.isArray(list)) return null;
const candidates = list
.filter((v) => v && v.delinquent === false)
.filter((v) => Number(v.commission) <= 2)
.filter((v) => Number(v.skip_rate) < 1)
.filter((v) => Number(v.uptime) >= 99.8)
.filter((v) => isValidVote(v.vote_identity))
.sort((a, b) => Number(b.wiz_score) - Number(a.wiz_score));
return candidates.length ? String(candidates[0].vote_identity) : null;
} catch {
return null;
} finally {
clearTimeout(t);
}
}
/**
* Кросс-проверка: vote-адрес (напр. от Stakewiz) ДОЛЖЕН существовать on-chain в current[],
* не быть delinquent, и иметь commission ≤ 10%. Защита от спуфнутого/устаревшего внешнего ответа.
*/
async function verifyOnChain(conn: Connection, vote: string): Promise<boolean> {
const accounts = await conn.getVoteAccounts();
if (accounts.delinquent.some((v: any) => v.votePubkey === vote)) return false;
const cur = accounts.current.find((v: any) => v.votePubkey === vote);
return !!cur && Number(cur.commission) <= 10;
}
async function fromRpc(conn: Connection): Promise<string | null> {
const accounts = await conn.getVoteAccounts();
const delinquent = new Set(accounts.delinquent.map((v: any) => v.votePubkey));
const candidates = accounts.current
.filter((v: any) => v.epochVoteAccount === true)
.filter((v: any) => Number(v.commission) <= 5)
.filter((v: any) => !delinquent.has(v.votePubkey))
.filter((v: any) => Number(v.activatedStake) > 100_000_000)
.map((v: any) => {
const ec = Array.isArray(v.epochCredits) ? v.epochCredits : [];
const last = ec[ec.length - 1];
const prev = ec[ec.length - 2];
const creditGrowth = last && prev ? Number(last[1]) - Number(prev[1]) : 0;
return { votePubkey: v.votePubkey as string, commission: Number(v.commission), creditGrowth };
})
.sort((a, b) => (a.commission !== b.commission ? a.commission - b.commission : b.creditGrowth - a.creditGrowth));
return candidates.length && isValidVote(candidates[0].votePubkey) ? candidates[0].votePubkey : null;
}