initalll
This commit is contained in:
132
apps/api/src/lib/sol-validator.ts
Normal file
132
apps/api/src/lib/sol-validator.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user