Verificar pruebas
Esta página muestra cómo verificar la resolución de un nombre sin confiar en el indexador que la sirvió. Todos los ejemplos de código que aparecen aquí se ejecutan contra un indexador real antes de publicarse.
Qué le aporta la verificación
Una resolución responde a «¿a qué dirección apunta richard.pivx?». Verificarla demuestra que
la respuesta es la comprometida en la raíz de estado, y que esa raíz es una que el contrato
ancla aceptó. Un indexador que invente una dirección, sirva un registro obsoleto u omita un
nombre no puede producir una prueba que supere estas comprobaciones.
Hay dos pasos independientes, y necesita ambos:
- La prueba de Merkle vincula el registro con una raíz de estado.
- La comprobación en la cadena vincula esa raíz con el contrato ancla.
El paso 1 por sí solo no vale nada: un indexador malicioso puede construir un árbol falso completo y servir pruebas coherentes con su propia raíz falsa. El paso 2 es lo que da sentido a la raíz.
Paso 1 — verificar la prueba de Merkle
El árbol
PiNS usa un árbol de Merkle disperso compacto sobre un espacio de claves de 128 bits. Una
clave son los primeros 16 bytes de SHA-256(domain_name). Al escribir un verificador importan
dos propiedades:
- Las pruebas son cortas y de longitud variable. Una hoja se sitúa a la menor profundidad en
la que el prefijo de su clave es único, así que una prueba lleva
proof_depthhermanos: por lo general un puñado, no 128. Nunca dé por supuesta una longitud fija; leaproof_depthy compruebe que coincide conmerkle_proof.length. - Un subárbol vacío son 32 bytes a cero a cualquier altura. No hay una escalera de hashes precalculados para nodos vacíos. Por eso la raíz de un árbol vacío es toda ceros.
Las funciones hash
Las hojas y los nodos internos van etiquetados para que sus dominios de hash no se solapen. Olvidar el byte de etiqueta es, con diferencia, el error más frecuente: produce un hash de aspecto plausible que jamás coincide con la raíz.
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 y target_address son bytes UTF-8 sin prefijo de longitud; owner_pubkey son los 32
bytes en bruto; price y nonce son enteros de 64 bits sin signo en little-endian.
El recorrido
Vaya de la hoja de vuelta a la raíz. Con n = proof_depth hermanos, en el paso i el bit que
decide de qué lado está es el bit n - 1 - i de la clave, contando desde el bit más
significativo del byte 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] es el hermano del nivel más profundo, el más cercano a la hoja.
El terminal
Toda prueba lleva un proof_terminal que describe qué hay al final del camino:
| Terminal | Significado |
|---|---|
Occupied | ahí está la hoja propia de la clave: el nombre existe |
Vacant | ahí hay un subárbol vacío: el nombre no está registrado |
Blocked | ahí está la hoja de un nombre distinto: el nombre no está registrado, y la respuesta incluye el registro completo de ese otro nombre |
Una resolución correcta debe ser Occupied. Vacant y Blocked son pruebas de ausencia:
permiten verificar que un nombre realmente no está registrado, en lugar de aceptar por fe el «no
encontrado» de un indexador. Rechace toda resolución cuyo terminal no sea Occupied.
Código
- 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();
}
Pruebe su implementación frente a la manipulación
Un verificador que siempre devuelve true supera cualquier prueba positiva. Antes de fiarse del
suyo, cambie un byte de target_address, owner_pubkey o price en una respuesta real y
confirme que ahora devuelve false. Los tres ejemplos anteriores se comprueban así.
Paso 2 — contrastar la raíz con el contrato ancla
Una prueba de Merkle verificada solo dice «este registro está en algún árbol». Para saber que está en el árbol auténtico, pregunte al contrato ancla en BNB Smart Chain si alguna vez aceptó esa raíz.
Use isRootValid(bytes32) — selector 0x30ef41b4. Devuelve un único booleano codificado en
ABI, mucho más fácil de interpretar correctamente que la estructura rootHistory.
| Llamada | Selector | Devuelve |
|---|---|---|
isRootValid(bytes32) | 0x30ef41b4 | bool — si esta raíz se aceptó alguna vez |
currentRoot() | 0xfdab463d | bytes32 — la última raíz aceptada |
verifyRootValidity(bytes32) | 0xc7179944 | (bool isValid, uint32 blockHeight) |
currentBlockHeight() | 0x367bf2f9 | uint32 — altura de PIVX de la última raíz |
programVkey() | 0x09665ee7 | bytes32 — el circuito que impone el contrato |
- 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;
}
Cómo interpretar la respuesta
isRootValid | Significado | Qué hacer |
|---|---|---|
true, y la raíz es igual a currentRoot() | el indexador está completamente sincronizado | aceptar |
true, pero la raíz es anterior a currentRoot() | el indexador va por detrás de la cadena | aceptar, opcionalmente avisar: el registro era válido en esa raíz |
false | esta raíz nunca se aceptó en la cadena | rechazar la resolución |
Un false significa que el árbol contra el que verificó no existe a efectos del protocolo. Ese
es exactamente el caso que produce un indexador controlado por un atacante, y justo para eso
sirve este paso.
Verificar el propio circuito
Los dos pasos anteriores demuestran que un registro pertenece a una raíz que el contrato aceptó.
El contrato solo acepta una raíz si la acompañaba una prueba ZK válida, y esa prueba se comprueba
contra programVkey, un compromiso de 32 bytes con el circuito compilado exacto.
Puede confirmar que esa clave corresponde al código fuente publicado:
git clone https://github.com/PIVX-Name/pivx-name-prover
cd pivx-name-prover/program
cargo prove build # plain build = mainnet
Compare la clave de verificación resultante con programVkey() (selector 0x09665ee7) en el
contrato ancla. Si coinciden, el contrato está imponiendo exactamente ese código fuente. Cambiar
una sola línea del circuito produce una clave distinta, así que no puede sustituirse un circuito
modificado sin que el cambio se vea en la cadena.
Compile sin características adicionales
Un cargo prove build sin más produce el circuito de mainnet que impone el contrato desplegado.
Cualquier indicador de característica cambia el programa compilado y, por tanto, su clave de
verificación, que ya no coincidirá.
Verificar una prueba SP1 directamente
Las pruebas son pruebas SP1 Groth16 estándar y pueden comprobarse con el SDK de 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)?;
Los valores públicos son la codificación ABI de (bytes32 old_root, bytes32 new_root, uint32 end_block_height): la transición de estado que realizó el lote y la altura de bloque de
PIVX que cubre.