Проверка доказательств
Эта страница показывает, как проверить разрешение имени, не доверяя выдавшему его индексатору. Каждый пример кода здесь перед публикацией выполняется на живом индексаторе.
Что даёт проверка
Разрешение имени отвечает на вопрос «на какой адрес указывает richard.pivx?». Проверка
доказывает, что этот ответ — именно тот, который зафиксирован в корне состояния, и что этот
корень принят якорным контрактом. Индексатор, который выдумает адрес, отдаст устаревшую
запись или скроет имя, не сможет предъявить доказательство, проходящее эти проверки.
Шага два, они независимы, и нужны оба:
- Доказательство Меркла связывает запись с корнем состояния.
- Проверка в блокчейне связывает этот корень с якорным контрактом.
Один только шаг 1 бесполезен: вредоносный индексатор может построить целое поддельное дерево и выдавать согласованные доказательства относительно собственного поддельного корня. Смысл корню придаёт именно шаг 2.
Шаг 1 — проверьте доказательство Меркла
Дерево
PiNS использует компактное разрежённое дерево Меркла над 128-битным пространством ключей.
Ключ — это первые 16 байт SHA-256(domain_name). При написании верификатора важны два
свойства:
- Доказательства короткие и переменной длины. Лист располагается на минимальной глубине,
где префикс его ключа уникален, поэтому доказательство несёт
proof_depthсоседей — обычно несколько штук, а не 128. Никогда не рассчитывайте на фиксированную длину; читайтеproof_depthи проверяйте, что оно совпадает сmerkle_proof.length. - Пустое поддерево — это 32 нулевых байта на любой высоте. Никакой лестницы заранее вычисленных хешей пустых узлов нет. Поэтому корень пустого дерева состоит из одних нулей.
Хеш-функции
Листья и внутренние узлы помечаются тегами, чтобы их области хеширования не пересекались. Пропуск байта тега — самая частая ошибка: она даёт правдоподобный на вид хеш, который никогда не совпадёт с корнем.
leaf = SHA-256( 0x00 ‖ domain ‖ owner_pubkey ‖ target_address ‖ price_le64 ‖ nonce_le64 )
node = SHA-256( 0x01 ‖ left ‖ right )
key = SHA-256( domain )[0..16]
domain и target_address — это байты UTF-8 без префикса длины; owner_pubkey — сырые
32 байта; price и nonce — беззнаковые 64-битные little-endian.
Обход
Идите от листа обратно к корню. При n = proof_depth соседях на шаге i сторону определяет
бит n - 1 - i ключа, считая от старшего бита байта 0:
h = leaf
for i in 0..n:
h = key_bit(key, n-1-i) ? node(sibling[i], h) : node(h, sibling[i])
merkle_proof[0] — это сосед на самом глубоком уровне, ближайший к листу.
Терминал
Каждое доказательство несёт proof_terminal, описывающий, что находится в конце пути:
| Терминал | Значение |
|---|---|
Occupied | там собственный лист ключа — имя существует |
Vacant | там пустое поддерево — имя не зарегистрировано |
Blocked | там лист другого имени — запрошенное имя не зарегистрировано, а ответ включает полную запись того имени |
Успешное разрешение должно иметь терминал Occupied. Vacant и Blocked — это
доказательства отсутствия: они позволяют убедиться, что имя действительно не
зарегистрировано, а не принимать на веру «не найдено» от индексатора. Отклоняйте любое
разрешение, терминал которого отличается от Occupied.
Код
- Python
- PHP
- JavaScript
import hashlib
import struct
LEAF_TAG, NODE_TAG = b"\x00", b"\x01"
KEY_LEN, MAX_DEPTH = 16, 128
def hash_leaf(domain, owner_pubkey_hex, target_address, price, nonce):
h = hashlib.sha256()
h.update(LEAF_TAG)
h.update(domain.encode())
h.update(bytes.fromhex(owner_pubkey_hex))
h.update(target_address.encode())
h.update(struct.pack("<Q", price))
h.update(struct.pack("<Q", nonce))
return h.digest()
def hash_node(left, right):
h = hashlib.sha256()
h.update(NODE_TAG)
h.update(left)
h.update(right)
return h.digest()
def key_of(domain):
return hashlib.sha256(domain.encode()).digest()[:KEY_LEN]
def key_bit(key, i):
return (key[i // 8] >> (7 - (i % 8))) & 1
def fold(key, start, siblings):
h, n = start, len(siblings)
for i, sib in enumerate(siblings):
h = hash_node(sib, h) if key_bit(key, n - 1 - i) else hash_node(h, sib)
return h
def verify_resolution(entry, expected_root):
if entry["proof_terminal"] != "Occupied":
raise ValueError("a resolution must carry an Occupied terminal")
siblings = [bytes.fromhex(s) for s in entry["merkle_proof"]]
if len(siblings) != entry["proof_depth"]:
raise ValueError("proof_depth does not match the sibling count")
if entry["proof_depth"] > MAX_DEPTH:
raise ValueError("proof depth exceeds MAX_DEPTH")
leaf = hash_leaf(
entry["domain_name"],
entry["owner_pubkey"],
entry["target_address"],
int(entry["price"]),
int(entry["nonce"]),
)
root = fold(key_of(entry["domain_name"]), leaf, siblings)
return root.hex() == expected_root.lower()
<?php
const LEAF_TAG = "\x00";
const NODE_TAG = "\x01";
const KEY_LEN = 16;
const MAX_DEPTH = 128;
function hashLeaf(string $domain, string $ownerPubkeyHex, string $targetAddress, int $price, int $nonce): string
{
return hash('sha256',
LEAF_TAG
. $domain
. hex2bin($ownerPubkeyHex)
. $targetAddress
. pack('P', $price) // u64 little-endian
. pack('P', $nonce),
true);
}
function hashNode(string $left, string $right): string
{
return hash('sha256', NODE_TAG . $left . $right, true);
}
function keyOf(string $domain): string
{
return substr(hash('sha256', $domain, true), 0, KEY_LEN);
}
function keyBit(string $key, int $i): int
{
return (ord($key[intdiv($i, 8)]) >> (7 - ($i % 8))) & 1;
}
function fold(string $key, string $start, array $siblings): string
{
$h = $start;
$n = count($siblings);
foreach ($siblings as $i => $sib) {
$h = keyBit($key, $n - 1 - $i) === 1 ? hashNode($sib, $h) : hashNode($h, $sib);
}
return $h;
}
function verifyResolution(array $entry, string $expectedRoot): bool
{
if ($entry['proof_terminal'] !== 'Occupied') {
throw new RuntimeException('a resolution must carry an Occupied terminal');
}
$siblings = array_map('hex2bin', $entry['merkle_proof']);
if (count($siblings) !== (int)$entry['proof_depth']) {
throw new RuntimeException('proof_depth does not match the sibling count');
}
if ((int)$entry['proof_depth'] > MAX_DEPTH) {
throw new RuntimeException('proof depth exceeds MAX_DEPTH');
}
$leaf = hashLeaf($entry['domain_name'], $entry['owner_pubkey'], $entry['target_address'],
(int)$entry['price'], (int)$entry['nonce']);
return bin2hex(fold(keyOf($entry['domain_name']), $leaf, $siblings)) === strtolower($expectedRoot);
}
import crypto from "node:crypto";
const LEAF_TAG = 0x00;
const NODE_TAG = 0x01;
const KEY_LEN = 16;
const MAX_DEPTH = 128;
const sha256 = (...parts) =>
crypto.createHash("sha256").update(Buffer.concat(parts)).digest();
const u64le = (v) => {
const b = Buffer.alloc(8);
b.writeBigUInt64LE(BigInt(v));
return b;
};
function hashLeaf(domain, ownerPubkeyHex, targetAddress, price, nonce) {
return sha256(
Buffer.from([LEAF_TAG]),
Buffer.from(domain, "utf8"),
Buffer.from(ownerPubkeyHex, "hex"),
Buffer.from(targetAddress, "utf8"),
u64le(price),
u64le(nonce),
);
}
const hashNode = (l, r) => sha256(Buffer.from([NODE_TAG]), l, r);
const keyOf = (domain) =>
crypto.createHash("sha256").update(domain, "utf8").digest().subarray(0, KEY_LEN);
const keyBit = (key, i) => (key[Math.floor(i / 8)] >> (7 - (i % 8))) & 1;
function fold(key, start, siblings) {
let h = start;
const n = siblings.length;
for (let i = 0; i < n; i++) {
h = keyBit(key, n - 1 - i) ? hashNode(siblings[i], h) : hashNode(h, siblings[i]);
}
return h;
}
export function verifyResolution(entry, expectedRoot) {
if (entry.proof_terminal !== "Occupied") {
throw new Error("a resolution must carry an Occupied terminal");
}
const siblings = entry.merkle_proof.map((s) => Buffer.from(s, "hex"));
if (siblings.length !== entry.proof_depth) {
throw new Error("proof_depth does not match the sibling count");
}
if (entry.proof_depth > MAX_DEPTH) {
throw new Error("proof depth exceeds MAX_DEPTH");
}
const leaf = hashLeaf(
entry.domain_name,
entry.owner_pubkey,
entry.target_address,
Number(entry.price),
Number(entry.nonce),
);
return fold(keyOf(entry.domain_name), leaf, siblings).toString("hex")
=== expectedRoot.toLowerCase();
}
Проверьте свою реализацию на подделке
Верификатор, который всегда возвращает true, проходит любой положительный тест. Прежде чем
доверять своему, измените один байт в target_address, owner_pubkey или price в реальном
ответе и убедитесь, что теперь он возвращает false. Все три примера выше проверены именно
так.
Шаг 2 — сверьте корень с якорным контрактом
Проверенное доказательство Меркла говорит лишь «эта запись есть в каком-то дереве». Чтобы узнать, что она в настоящем дереве, спросите у якорного контракта в BNB Smart Chain, принимал ли он когда-нибудь этот корень.
Используйте isRootValid(bytes32) — селектор 0x30ef41b4. Он возвращает одно логическое
значение в ABI-кодировке, которое разобрать правильно куда проще, чем структуру
rootHistory.
| Вызов | Селектор | Возвращает |
|---|---|---|
isRootValid(bytes32) | 0x30ef41b4 | bool — принимался ли когда-либо этот корень |
currentRoot() | 0xfdab463d | bytes32 — последний принятый корень |
verifyRootValidity(bytes32) | 0xc7179944 | (bool isValid, uint32 blockHeight) |
currentBlockHeight() | 0x367bf2f9 | uint32 — высота PIVX для последнего корня |
programVkey() | 0x09665ee7 | bytes32 — схема, которую требует контракт |
- Python
- PHP
- JavaScript
import requests
def is_root_valid(rpc_url, contract_address, smt_root):
"""Returns True if the anchor contract has ever accepted this root."""
clean = smt_root.replace("0x", "").lower().rjust(64, "0")
payload = {
"jsonrpc": "2.0",
"method": "eth_call",
"params": [{"to": contract_address, "data": f"0x30ef41b4{clean}"}, "latest"],
"id": 1,
}
r = requests.post(rpc_url, json=payload, timeout=15).json()
if "error" in r:
raise RuntimeError(f"EVM RPC error: {r['error']['message']}")
result = r.get("result", "0x")
# A bool is ABI-encoded as a full 32-byte word: 0x00..01 for true.
return int(result, 16) == 1 if result not in ("", "0x") else False
<?php
function isRootValid(string $rpcUrl, string $contractAddress, string $smtRoot): bool
{
$clean = str_pad(str_replace('0x', '', strtolower($smtRoot)), 64, '0', STR_PAD_LEFT);
$payload = [
'jsonrpc' => '2.0',
'method' => 'eth_call',
'params' => [['to' => $contractAddress, 'data' => '0x30ef41b4' . $clean], 'latest'],
'id' => 1,
];
$ch = curl_init($rpcUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 15,
]);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);
if (isset($data['error'])) {
throw new RuntimeException('EVM RPC error: ' . $data['error']['message']);
}
$result = $data['result'] ?? '0x';
if ($result === '' || $result === '0x') {
return false;
}
// A bool is ABI-encoded as a full 32-byte word: 0x00..01 for true.
return hexdec(substr($result, -1)) === 1;
}
async function isRootValid(rpcUrl, contractAddress, smtRoot) {
const clean = smtRoot.replace(/^0x/, "").toLowerCase().padStart(64, "0");
const response = await fetch(rpcUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method: "eth_call",
params: [{ to: contractAddress, data: `0x30ef41b4${clean}` }, "latest"],
id: 1,
}),
});
if (!response.ok) throw new Error(`RPC request failed: ${response.statusText}`);
const json = await response.json();
if (json.error) throw new Error(`EVM RPC error: ${json.error.message}`);
const result = json.result;
if (!result || result === "0x") return false;
// A bool is ABI-encoded as a full 32-byte word: 0x00..01 for true.
return BigInt(result) === 1n;
}
Как читать ответ
isRootValid | Значение | Что делать |
|---|---|---|
true, и корень равен currentRoot() | индексатор полностью синхронизирован | принять |
true, но корень старше currentRoot() | индексатор отстаёт от цепочки | принять, при желании предупредить — на момент того корня запись была верна |
false | этот корень никогда не принимался в блокчейне | отклонить разрешение |
Значение false означает, что дерева, относительно которого вы проверяли, с точки зрения
протокола не существует. Именно такой случай создаёт индексатор под контролем злоумышленника,
и именно ради него нужен этот шаг.
Проверка самой схемы
Два шага выше доказывают, что запись принадлежит корню, который принял контракт. А корень
контракт принимает только вместе с корректным ZK-доказательством, которое проверяется
относительно programVkey — 32-байтового обязательства к конкретной скомпилированной схеме.
Убедиться, что этот ключ соответствует опубликованному исходному коду, можно так:
git clone https://github.com/PIVX-Name/pivx-name-prover
cd pivx-name-prover/program
cargo prove build # plain build = mainnet
Сравните полученный ключ верификации с programVkey() (селектор 0x09665ee7) в якорном
контракте. Если они совпадают, контракт требует именно этот исходный код. Изменение хотя бы
одной строки схемы даёт другой ключ, поэтому подменить схему незаметно для блокчейна
невозможно.
Собирайте без дополнительных фич
Обычный cargo prove build даёт схему основной сети, которую требует развёрнутый контракт.
Любой флаг фичи меняет скомпилированную программу, а значит и её ключ верификации, который
после этого не совпадёт.
Прямая проверка доказательства SP1
Доказательства — это стандартные доказательства SP1 Groth16, и проверить их можно с помощью SDK SP1:
use sp1_sdk::{ProverClient, SP1ProofWithPublicValues};
let client = ProverClient::from_env();
let (_, vk) = client.setup(ELF);
let proof = SP1ProofWithPublicValues::load("proof.bin")?;
client.verify(&proof, &vk)?;
Публичные значения — это ABI-кодировка (bytes32 old_root, bytes32 new_root, uint32 end_block_height): переход состояния, выполненный пакетом, и высота блока PIVX,
которую он покрывает.