256 lines
10 KiB
HTML
256 lines
10 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Проверка оракула Pyth (Devnet/Mainnet/Testnet)</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: dark;
|
||
--bg: #0f1218;
|
||
--panel: #171b24;
|
||
--text: #e8edf6;
|
||
--muted: #97a3b8;
|
||
--line: #2a3242;
|
||
--ok: #55d48a;
|
||
--warn: #ffbf5e;
|
||
--err: #ff7d7d;
|
||
--btn: #273247;
|
||
--btn-hover: #32415c;
|
||
--code: #1e2633;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body { font-family: Arial, sans-serif; margin: 20px; background: var(--bg); color: var(--text); }
|
||
.wrap { width: 100%; max-width: 1800px; }
|
||
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); }
|
||
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
|
||
input { padding: 9px 10px; min-width: 340px; border: 1px solid var(--line); border-radius: 8px; background: #131824; color: var(--text); }
|
||
button { padding: 9px 14px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; background: var(--btn); color: var(--text); }
|
||
button:hover { background: var(--btn-hover); }
|
||
.muted { color: var(--muted); }
|
||
.ok { color: var(--ok); }
|
||
.warn { color: var(--warn); }
|
||
.err { color: var(--err); white-space: pre-wrap; }
|
||
code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
|
||
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||
th, td { border: 1px solid var(--line); padding: 6px; text-align: left; vertical-align: top; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<h1>Диагностика оракула Pyth (SOL/USD)</h1>
|
||
<div class="panel">
|
||
<div class="muted">
|
||
Страница нужна, чтобы проверить, что именно возвращает аккаунт оракула в разных сетях и где ломается парсинг.
|
||
</div>
|
||
<div class="muted">
|
||
Проверяются три сети: <code>devnet</code>, <code>mainnet-beta</code>, <code>testnet</code>.
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h3>Настройки</h3>
|
||
<div class="row">
|
||
<label>Feed ID (hex с 0x):<br /><input id="feedId" value="0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d" /></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>Oracle account (devnet):<br /><input id="oracleDevnet" value="7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE" /></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>Oracle account (mainnet-beta):<br /><input id="oracleMainnet" value="7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE" /></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>Oracle account (testnet):<br /><input id="oracleTestnet" value="7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE" /></label>
|
||
</div>
|
||
<div class="row">
|
||
<button id="runBtn">Проверить все сети</button>
|
||
</div>
|
||
<div class="muted">
|
||
Если для сети аккаунт не существует, это тоже покажется в отчёте.
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h3>Результаты</h3>
|
||
<div id="out" class="muted">Нажмите «Проверить все сети».</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const NETWORKS = [
|
||
{ name: "devnet", rpc: "https://api.devnet.solana.com", inputId: "oracleDevnet" },
|
||
{ name: "mainnet-beta", rpc: "https://api.mainnet-beta.solana.com", inputId: "oracleMainnet" },
|
||
{ name: "testnet", rpc: "https://api.testnet.solana.com", inputId: "oracleTestnet" },
|
||
];
|
||
|
||
function readU64(data, offset) {
|
||
let x = 0n;
|
||
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
|
||
return x;
|
||
}
|
||
function readI64(data, offset) {
|
||
let x = readU64(data, offset);
|
||
if (x > 0x7fffffffffffffffn) x -= 0x10000000000000000n;
|
||
return x;
|
||
}
|
||
function readU32(data, offset) {
|
||
return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24);
|
||
}
|
||
function readI32(data, offset) {
|
||
let x = readU32(data, offset);
|
||
if (x > 0x7fffffff) x -= 0x100000000;
|
||
return x;
|
||
}
|
||
function toHex(bytes) {
|
||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
||
}
|
||
function trimZeros(v) {
|
||
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
|
||
}
|
||
function priceToFloatStr(price, exponent) {
|
||
const p = Number(price);
|
||
if (!Number.isFinite(p)) return "NaN";
|
||
return trimZeros((p * Math.pow(10, exponent)).toFixed(12));
|
||
}
|
||
function fmtErr(e) {
|
||
const s = String(e?.message || e || "unknown error");
|
||
return s.length > 350 ? s.slice(0, 350) + "..." : s;
|
||
}
|
||
|
||
function parseWithOffsets(data, priceOffset, exponentOffset, publishOffset) {
|
||
const price = readI64(data, priceOffset);
|
||
const exponent = readI32(data, exponentOffset);
|
||
const publishTime = readI64(data, publishOffset);
|
||
return { price, exponent, publishTime, valueStr: priceToFloatStr(price, exponent) };
|
||
}
|
||
|
||
function parseFeedIdFromLikelyPosition(data) {
|
||
// Для текущего PriceUpdateV2 в аккаунте receiver:
|
||
// 0..7 discriminator, 8..39 write_authority, 40..40 verification enum, 41..72 feed_id.
|
||
if (data.length < 73) return null;
|
||
const feedRaw = data.slice(41, 73);
|
||
return "0x" + toHex(feedRaw);
|
||
}
|
||
|
||
async function rpcGetAccountInfo(rpcUrl, pubkey) {
|
||
const body = {
|
||
jsonrpc: "2.0",
|
||
id: 1,
|
||
method: "getAccountInfo",
|
||
params: [pubkey, { encoding: "base64", commitment: "confirmed" }],
|
||
};
|
||
const res = await fetch(rpcUrl, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!res.ok) throw new Error(`RPC HTTP ${res.status}`);
|
||
const json = await res.json();
|
||
if (json.error) throw new Error(json.error.message || JSON.stringify(json.error));
|
||
return json.result?.value || null;
|
||
}
|
||
|
||
async function fetchHermesPrice(feedIdHex) {
|
||
const url = `https://hermes.pyth.network/v2/updates/price/latest?ids[]=${encodeURIComponent(feedIdHex)}`;
|
||
const res = await fetch(url);
|
||
if (!res.ok) throw new Error(`Hermes HTTP ${res.status}`);
|
||
const json = await res.json();
|
||
const p = json?.parsed?.[0]?.price;
|
||
if (!p) throw new Error("Hermes: price not found");
|
||
return {
|
||
price: BigInt(p.price),
|
||
expo: Number(p.expo),
|
||
publishTime: BigInt(p.publish_time),
|
||
valueStr: priceToFloatStr(BigInt(p.price), Number(p.expo)),
|
||
};
|
||
}
|
||
|
||
async function runCheck() {
|
||
const out = document.getElementById("out");
|
||
out.textContent = "Проверка...";
|
||
|
||
const feedId = document.getElementById("feedId").value.trim().toLowerCase();
|
||
const rows = [];
|
||
|
||
let hermes = null;
|
||
let hermesErr = null;
|
||
try {
|
||
hermes = await fetchHermesPrice(feedId);
|
||
} catch (e) {
|
||
hermesErr = fmtErr(e);
|
||
}
|
||
|
||
for (const n of NETWORKS) {
|
||
const oracle = document.getElementById(n.inputId).value.trim();
|
||
try {
|
||
const ai = await rpcGetAccountInfo(n.rpc, oracle);
|
||
if (!ai) {
|
||
rows.push({
|
||
network: n.name,
|
||
status: "account not found",
|
||
details: "Аккаунт оракула не найден в этой сети",
|
||
});
|
||
continue;
|
||
}
|
||
const data = Uint8Array.from(atob(ai.data[0]), c => c.charCodeAt(0));
|
||
const feedFromData = parseFeedIdFromLikelyPosition(data);
|
||
|
||
const parsedCurrentUi = parseWithOffsets(data, 74, 90, 94); // как сейчас в UI проекта
|
||
const parsedShiftMinus1 = parseWithOffsets(data, 73, 89, 93); // диагностический вариант
|
||
|
||
const currentUiValid = parsedCurrentUi.price > 0n;
|
||
const shiftValid = parsedShiftMinus1.price > 0n;
|
||
const feedMatch = feedFromData === feedId;
|
||
|
||
rows.push({
|
||
network: n.name,
|
||
status: "ok",
|
||
details: `
|
||
<div>RPC: <code>${n.rpc}</code></div>
|
||
<div>Account owner: <code>${ai.owner}</code>, data len: <b>${data.length}</b></div>
|
||
<div>Feed ID (из аккаунта): <code>${feedFromData || "n/a"}</code> ${feedMatch ? '<span class="ok">совпадает</span>' : '<span class="warn">НЕ совпадает</span>'}</div>
|
||
<div>Парсер UI (offsets 74/90/94): price=<code>${parsedCurrentUi.price.toString()}</code>, exp=<code>${parsedCurrentUi.exponent}</code>, value=<b>${parsedCurrentUi.valueStr}</b> ${currentUiValid ? '<span class="ok">valid</span>' : '<span class="err">invalid</span>'}</div>
|
||
<div>Альт. парсер (offsets 73/89/93): price=<code>${parsedShiftMinus1.price.toString()}</code>, exp=<code>${parsedShiftMinus1.exponent}</code>, value=<b>${parsedShiftMinus1.valueStr}</b> ${shiftValid ? '<span class="ok">valid</span>' : '<span class="err">invalid</span>'}</div>
|
||
${hermes
|
||
? `<div>Hermes (эталон): price=<code>${hermes.price.toString()}</code>, exp=<code>${hermes.expo}</code>, value=<b>${hermes.valueStr}</b></div>`
|
||
: `<div class="warn">Hermes недоступен: ${hermesErr}</div>`
|
||
}
|
||
`,
|
||
});
|
||
} catch (e) {
|
||
rows.push({
|
||
network: n.name,
|
||
status: "error",
|
||
details: `<span class="err">${fmtErr(e)}</span>`,
|
||
});
|
||
}
|
||
}
|
||
|
||
out.innerHTML = `
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Сеть</th>
|
||
<th>Статус</th>
|
||
<th>Детали</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${rows.map((r) => `
|
||
<tr>
|
||
<td><b>${r.network}</b></td>
|
||
<td>${r.status === "ok" ? '<span class="ok">ok</span>' : (r.status === "account not found" ? '<span class="warn">not found</span>' : '<span class="err">error</span>')}</td>
|
||
<td>${r.details}</td>
|
||
</tr>
|
||
`).join("")}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
}
|
||
|
||
document.getElementById("runBtn").addEventListener("click", runCheck);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
|