验证证明
本页说明如何在不信任提供结果的索引器的前提下验证一次名称解析。 这里的每一段代码示例在发布前都会针对一个真实运行的索引器执行过。
验证能给你什么
一次解析回答的是「richard.pivx 指向哪个地址?」。验证它,则证明这个答案正是被承诺进状态根的那一个
——并且该根是锚定合约确实接受过的根。凭空捏造地址、给出过期记录或隐藏某个名称的索引器,都拿不出能通过
这些检查的证明。
这里有两个彼此独立的步骤,两者都不可少:
- 默克尔证明把记录绑定到某个状态根。
- 链上校验把那个根绑定到锚定合约。
仅有第 1 步毫无价值:恶意索引器完全可以构造一整棵伪造的树,并给出与自己伪根相自洽的证明。是第 2 步 才让这个根有了意义。
第 1 步 — 验证默克尔证明
这棵树
PiNS 使用基于 128 位密钥空间的紧凑稀疏默克尔树。密钥是 SHA-256(domain_name) 的前 16 个字节。
编写验证器时,有两个性质很重要:
- 证明很短,且长度可变。 叶子位于其密钥前缀唯一的最浅深度,因此一份证明携带
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 位小端序整数。
遍历方式
从叶子往回走到根。在有 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 证明,可以用 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)?;
其公开值是 (bytes32 old_root, bytes32 new_root, uint32 end_block_height) 的 ABI 编码——即该批次执行的状态转移,以及它所覆盖的 PIVX 区块高度。