証明の検証
このページでは、結果を返したインデクサーを信頼せずに名前解決を検証する方法を示します。 ここに載せたコード例はいずれも、公開前に実際に稼働しているインデクサーに対して実行されています。
検証によって得られるもの
名前解決は「richard.pivx はどのアドレスを指すか?」に答えます。それを検証すると、その答えが状態ルートに
コミットされたものであること、そしてそのルートがアンカーコントラクトの受け入れたものであることを証明でき
ます。アドレスをでっち上げたり、古いレコードを返したり、名前を隠したりするインデクサーは、これらの検査を
通る証明を作れません。
互いに独立した 2 つの手順があり、両方とも必要です:
- マークル証明がレコードを状態ルートへ結びつけます。
- オンチェーンの確認がそのルートをアンカーコントラクトへ結びつけます。
手順 1 だけでは無価値です。悪意あるインデクサーは偽のツリーを丸ごと作り、自分の偽ルートと辻褄の合う証明を 返せるからです。そのルートに意味を与えるのが手順 2 です。
手順 1 — マークル証明を検証する
ツリー
PiNS は 128 ビットの鍵空間上のコンパクト疎マークルツリーを使います。鍵は SHA-256(domain_name) の
先頭 16 バイトです。検証器を書くうえで重要な性質が 2 つあります:
- 証明は短く、長さは可変です。 葉は鍵の接頭辞が一意になる最も浅い深さに置かれるので、証明は
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 でどちら側にいるかを
決めるのは、0 バイト目の最上位ビットから数えた鍵の n - 1 - i 番目のビットです:
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 のいずれか 1 バイトを書き換え、false が返ることを
確かめてください。上記の 3 つの例はいずれもこの方法で確認しています。
手順 2 — ルートをアンカーコントラクトと突き合わせる
検証済みのマークル証明が言えるのは「このレコードは何らかのツリーの中にある」ということだけです。それが 本物のツリーにあると知るには、BNB Smart Chain 上のアンカーコントラクトに、そのルートを受け入れたことが あるか尋ねてください。
isRootValid(bytes32) を使います — セレクター 0x30ef41b4。ABI エンコードされた真偽値を 1 つ返すだけなので、
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 は、あなたが突き合わせたそのツリーがプロトコルの観点では存在しないという意味です。攻撃者に支配された
インデクサーが生み出すのがまさにこの状況であり、この手順はそのためにあります。
回路そのものを検証する
上の 2 つの手順は、レコードがコントラクトの受け入れたルートに属することを証明します。そしてコントラクトが
ルートを受け入れるのは、有効な ZK 証明が添えられていたときだけであり、その証明はコンパイル済みのまさにその
回路への 32 バイトのコミットメントである programVkey に対して確認されます。
その鍵が公開されたソースコードに対応することは、次のように確かめられます:
git clone https://github.com/PIVX-Name/pivx-name-prover
cd pivx-name-prover/program
cargo prove build # plain build = mainnet
得られた検証鍵を、アンカーコントラクト上の programVkey()(セレクター 0x09665ee7)と比べてください。
一致すれば、コントラクトはまさにそのソースコードを強制しています。回路を 1 行変えるだけで別の鍵になるので、
改変された回路をオンチェーンに痕跡を残さず差し替えることはできません。
追加のフィーチャーなしでビルドしてください
素の 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 の
ブロック高です。