Files
cryptowallet/apps/api/src/lib/sol-validator.ts
ZOMBIIIIIII f8403eb9f6 initalll
2026-06-11 12:03:51 +03:00

133 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Авто-выбор «хорошего» 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;
}