Verifying Proofs
This page shows how to verify a name resolution without trusting the indexer that served it. Every code sample here is executed against a live indexer before publication.
What verification gives you
A resolution answers "what address does richard.pivx point to?". Verifying it proves that
the answer is the one committed to the state root — and that the root is one the anchor
contract accepted. An indexer that invents an address, serves a stale record, or omits a
name cannot produce a proof that passes these checks.
There are two independent steps, and you need both:
- The Merkle proof ties the record to a state root.
- The on-chain check ties that root to the anchor contract.
Step 1 alone is worthless: a malicious indexer can build an entire fake tree and serve consistent proofs against its own fake root. Step 2 is what makes the root meaningful.
Step 1 — verify the Merkle proof
The tree
PiNS uses a compact Sparse Merkle Tree over a 128-bit key space. A key is the first
16 bytes of SHA-256(domain_name). Two properties matter when writing a verifier:
- Proofs are short and variable-length. A leaf sits at the shallowest depth where its
key prefix is unique, so a proof carries
proof_depthsiblings — typically a handful, not 128. Never assume a fixed length; readproof_depthand check it matchesmerkle_proof.length. - An empty subtree is 32 zero bytes at every height. There is no ladder of precomputed empty-node hashes. The root of an empty tree is therefore all zeros.
The hash functions
Leaves and internal nodes are tagged so their hash domains cannot overlap. Omitting the tag byte is the single most common mistake — it produces a plausible-looking hash that never matches the root.
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 and target_address are UTF-8 bytes with no length prefix; owner_pubkey is the
raw 32 bytes; price and nonce are unsigned 64-bit little-endian.
The traversal
Walk from the leaf back to the root. With n = proof_depth siblings, at step i the bit
that decides which side you are on is bit n - 1 - i of the key, counting from the most
significant bit of 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] is the sibling at the deepest level, closest to the leaf.
The terminal
Every proof carries a proof_terminal describing what sits at the end of the path:
| Terminal | Meaning |
|---|---|
Occupied | the key's own leaf is there — the name exists |
Vacant | an empty subtree is there — the name is unregistered |
Blocked | a different name's leaf is there — the name is unregistered, and the response includes that name's full record |
A successful resolution must be Occupied. Vacant and Blocked are absence proofs:
they let you verify that a name genuinely is not registered, rather than taking an
indexer's "not found" on faith. Reject any resolution whose terminal is not Occupied.
Code
- 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();
}
Test your implementation against tampering
A verifier that always returns true passes every positive test. Before trusting yours,
change one byte of target_address, owner_pubkey or price in a real response and
confirm it now returns false. All three samples above are checked this way.
Step 2 — check the root against the anchor contract
A verified Merkle proof only says "this record is in some tree". To know it is in the real tree, ask the anchor contract on BNB Smart Chain whether it ever accepted that root.
Use isRootValid(bytes32) — selector 0x30ef41b4. It returns a single ABI-encoded
boolean, which is far easier to parse correctly than the rootHistory struct.
| Call | Selector | Returns |
|---|---|---|
isRootValid(bytes32) | 0x30ef41b4 | bool — was this root ever accepted |
currentRoot() | 0xfdab463d | bytes32 — the latest accepted root |
verifyRootValidity(bytes32) | 0xc7179944 | (bool isValid, uint32 blockHeight) |
currentBlockHeight() | 0x367bf2f9 | uint32 — PIVX height of the latest root |
programVkey() | 0x09665ee7 | bytes32 — the circuit the contract enforces |
- 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;
}
Interpreting the answer
isRootValid | Meaning | What to do |
|---|---|---|
true, and the root equals currentRoot() | the indexer is fully synced | accept |
true, but the root is older than currentRoot() | the indexer lags behind the chain | accept, optionally warn — the record was valid as of that root |
false | this root was never accepted on-chain | reject the resolution |
A false means the tree you verified against does not exist as far as the protocol is
concerned. That is the case an attacker-controlled indexer produces, and it is exactly
what this step is for.
Verifying the circuit itself
The two steps above prove a record belongs to a root the contract accepted. The contract
only accepts a root if a valid ZK proof accompanied it — and that proof is checked against
programVkey, a 32-byte commitment to the exact compiled circuit.
You can confirm that key corresponds to the published source:
git clone https://github.com/PIVX-Name/pivx-name-prover
cd pivx-name-prover/program
cargo prove build # plain build = mainnet
Compare the resulting verification key with programVkey() (selector 0x09665ee7) on the
anchor contract. If they match, the contract is enforcing that exact source. Changing a
single line of the circuit produces a different key, so a modified circuit cannot be
substituted without the change being visible on-chain.
Build with no extra features
A plain cargo prove build produces the mainnet circuit that the deployed contract
enforces. Any feature flag changes the compiled program and therefore its verification
key, which will not match.
Verifying an SP1 proof directly
Proofs are standard SP1 Groth16 proofs and can be checked with the SP1 SDK:
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)?;
The public values are the ABI encoding of (bytes32 old_root, bytes32 new_root, uint32 end_block_height) — the state transition the batch performed, and the PIVX block
height it covers.