Skip to main content

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:

  1. The Merkle proof ties the record to a state root.
  2. 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_depth siblings — typically a handful, not 128. Never assume a fixed length; read proof_depth and check it matches merkle_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:

TerminalMeaning
Occupiedthe key's own leaf is there — the name exists
Vacantan empty subtree is there — the name is unregistered
Blockeda 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

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()
tip

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.

CallSelectorReturns
isRootValid(bytes32)0x30ef41b4bool — was this root ever accepted
currentRoot()0xfdab463dbytes32 — the latest accepted root
verifyRootValidity(bytes32)0xc7179944(bool isValid, uint32 blockHeight)
currentBlockHeight()0x367bf2f9uint32 — PIVX height of the latest root
programVkey()0x09665ee7bytes32 — the circuit the contract enforces
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

Interpreting the answer

isRootValidMeaningWhat to do
true, and the root equals currentRoot()the indexer is fully syncedaccept
true, but the root is older than currentRoot()the indexer lags behind the chainaccept, optionally warn — the record was valid as of that root
falsethis root was never accepted on-chainreject 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.

warning

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.