증명 검증
이 페이지는 결과를 제공한 인덱서를 신뢰하지 않고 이름 해석을 검증하는 방법을 보여줍니다. 여기의 모든 코드 예시는 공개 전에 실제로 동작 중인 인덱서를 대상으로 실행됩니다.
검증으로 얻는 것
해석은 「richard.pivx는 어떤 주소를 가리키는가?」에 답합니다. 그것을 검증하면 그 답이 상태 루트에
약속된 바로 그 답이며, 그 루트가 앵커 컨트랙트가 실제로 받아들인 루트임을 증명하게 됩니다. 주소를
지어내거나, 오래된 레코드를 내주거나, 이름을 숨기는 인덱서는 이 검사들을 통과하는 증명을 만들어 낼 수
없습니다.
서로 독립적인 두 단계가 있으며, 둘 다 필요합니다:
- 머클 증명은 레코드를 어떤 상태 루트에 묶습니다.
- 온체인 확인은 그 루트를 앵커 컨트랙트에 묶습니다.
1단계만으로는 가치가 없습니다. 악의적인 인덱서는 가짜 트리 전체를 만들어 자기 가짜 루트와 앞뒤가 맞는 증명을 내줄 수 있으니까요. 그 루트에 의미를 부여하는 것이 바로 2단계입니다.
1단계 — 머클 증명 검증
트리
PiNS는 128비트 키 공간 위의 압축 희소 머클 트리를 사용합니다. 키는 SHA-256(domain_name)의 앞
16바이트입니다. 검증기를 작성할 때는 두 가지 성질이 중요합니다:
- 증명은 짧고 길이가 가변적입니다. 잎은 키 접두사가 유일해지는 가장 얕은 깊이에 놓이므로, 증명은
proof_depth개의 형제 노드를 담습니다 — 보통 몇 개뿐이지 128개가 아닙니다. 절대 고정 길이를 가정하지 말고,proof_depth를 읽어merkle_proof.length와 일치하는지 확인하세요. - 빈 서브트리는 어느 높이에서든 32개의 0 바이트입니다. 빈 노드 해시를 미리 계산해 둔 사다리 같은 것은 없습니다. 그래서 빈 트리의 루트는 전부 0입니다.
해시 함수
잎과 내부 노드는 해시 영역이 겹치지 않도록 태그를 붙입니다. 태그 바이트를 빠뜨리는 것이 가장 흔한 실수이며, 그럴듯해 보이지만 루트와는 결코 맞지 않는 해시를 만들어 냅니다.
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의 한 바이트를 바꿔 보고 이제 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 증명이 함께 있을 때만 루트를 받아들이며, 그 증명은 정확히 컴파일된 회로에 대한 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)와 비교하세요. 일치한다면 컨트랙트는
바로 그 소스 코드를 강제하고 있는 것입니다. 회로의 한 줄만 바꿔도 다른 키가 나오므로, 수정된 회로를
온체인에 흔적을 남기지 않고 바꿔치기할 수는 없습니다.
추가 기능 없이 빌드하세요
옵션 없는 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
블록 높이입니다.