Files
zeekayandHanzo Dev 578de7ef2b pop: freeze the over-long key, so the width is refused from both sides
The corpus froze a 47-byte key and both a 95- and a 97-byte proof, but no
49-byte key — so the one width a port could get wrong was the one the standard
did not carry. The clause is a width equality, not a lower bound: a decoder that
guarded only the short side reads the first 48 bytes of a longer buffer and
checks a proof against a PREFIX of what the registrant published. That is a
second spelling of an already-seated key, and two spellings of one key is two
seats and two votes on one signature.

The case is generated by the oracle and self-checked against pop.Verify, like
every other row, and lands as an ENCODING refusal before any pairing.

Go already held it as a unit assertion and Rust held its own; Rust's is dropped
here, because a case the frozen corpus carries for all three does not also want
a private restatement that has to be kept in step.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-02 02:22:56 -07:00

85 lines
3.2 KiB
Rust

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! Rust conforms to the Go oracle, byte for byte.
//!
//! `tests/vectors/pop.json` is the frozen corpus from `luxfi/conformance`,
//! generated by the Go `validator/pop` and self-checked against its verifier.
//! Every case carries the exact node, key and proof bytes and the accept/reject
//! the Go verifier returned. This asserts the Rust `pop::verify` returns the
//! same answer AND, on a reject, the same error class — so a certificate that
//! verifies in Go verifies here, and one Go refuses is refused here for the same
//! reason.
use lux_consensus::pop::{self, NodeId, PopError};
// A tiny JSON reach into the vector, without a serde dependency: the file is a
// flat, machine-generated object, one case per element.
fn field<'a>(obj: &'a str, key: &str) -> &'a str {
let k = format!("\"{key}\": \"");
let p = obj.find(&k).map(|i| i + k.len()).unwrap_or_else(|| {
panic!("case has no {key}: {obj}");
});
&obj[p..p + obj[p..].find('"').unwrap()]
}
fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
// The three error classes, spelled exactly as the Go oracle publishes them, so a
// mismatch names which clause diverged.
fn class(e: Option<PopError>) -> &'static str {
match e {
None => "",
Some(PopError::Key) => "pop: public key is not a canonical compressed BLS12-381 G1 point",
Some(PopError::Proof) => "pop: proof is not a canonical compressed BLS12-381 G2 point",
Some(PopError::Possession) => "pop: proof does not bind this node to this key",
}
}
#[test]
fn rust_matches_the_go_pop_oracle_case_for_case() {
let raw = include_str!("vectors/pop.json");
// Split into case objects on the boundary between array elements. The
// generator writes one object per case; each contains a "name".
let cases: Vec<&str> = raw.split("\"name\":").skip(1).collect();
assert!(cases.len() >= 8, "vector shrank to {} cases", cases.len());
let mut checked = 0;
for chunk in cases {
let name = {
let s = chunk.trim_start();
let s = &s[s.find('"').unwrap() + 1..];
&s[..s.find('"').unwrap()]
};
let node_hex = field(chunk, "node");
let key = unhex(field(chunk, "key"));
let proof = unhex(field(chunk, "proof"));
let accepted = chunk.contains("\"accepted\": true");
let node_bytes = unhex(node_hex);
assert_eq!(node_bytes.len(), 20, "{name}: node is not 20 bytes");
let mut node: NodeId = [0u8; 20];
node.copy_from_slice(&node_bytes);
let got = pop::verify(&node, &key, &proof);
assert_eq!(
got.is_ok(),
accepted,
"{name}: Rust says accepted={}, Go said accepted={accepted}",
got.is_ok(),
);
if !accepted {
let want = field(chunk, "error");
assert_eq!(class(got.err()), want, "{name}: error class diverged");
}
checked += 1;
}
assert_eq!(checked, 14, "expected 14 frozen cases, checked {checked}");
}