Files
zeekay 2cddf94e1c The P-chain speaks the node's names, and the node's one hash
platformvm had its own thirty-two-byte name wrapped in a struct, its own
twenty-byte address, and — under both of them — its own complete SHA-256.
Two implementations of the hash a transaction is NAMED by is the sharpest
version of this problem: they agree until the day they do not, and the day
they do not is a fork.

So ids.hpp now declares nothing. It adopts core's names and core's hash,
and keeps only the one thing that is a P-chain fact rather than a naming
fact: the primary network is the zero id. sha256.hpp is deleted; its FIPS
180-4 vectors moved to core, where the hash now lives, and sha256_test
became ids_test, which checks what is left to check here — that a UTXO's
name is the reference's ids.ID.Prefix.

Six identical local hex() helpers in the test files are gone too.

ONE HAZARD, NAMED, BECAUSE IT IS SILENT. FixedId::empty() meant "all
zero"; std::array::empty() means "has no elements", which is false for
thirty-two bytes and compiles without a word. Five checks were of the
first kind and would have become dead: the two empty-asset gates, the
empty-VM-id gate, and the reward transaction's own id. They now read
`== kEmptyId`, which cannot rot into a tautology. Three of the five were
caught by the suite; the reward-id one was not, and nothing tests it.

208 differential vectors still agree across Go, Rust and C++, so the names
that changed shape did not change value.
2026-09-05 15:04:34 -07:00

467 lines
20 KiB
C++

// Copyright (C) 2026, Lux Industries, Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause-Eco
//
// warp_test.cpp — a message another chain signed, and the proof enough of it did.
//
// Ported from Go vms/platformvm/warp: unsigned_message_test.go, message_test.go,
// signature_test.go and validator_test.go. The verification case is not a
// self-check: the message, the three validators, the bit vector and the
// aggregated signature all came out of the Go reference, so a port that
// aggregated differently, indexed the bits differently, or used the wrong domain
// tag fails here.
#include "golden.hpp"
#include "harness.hpp"
#include "lux/platformvm/warp.hpp"
#include "lux/platformvm/warpmsg.hpp"
#include <string>
using namespace lux::platformvm;
namespace {
Id id_of(std::uint8_t b) {
Id v{};
for (std::size_t i = 0; i < kIdLen; ++i) v[i] = static_cast<std::uint8_t>(b + i);
return v;
}
NodeId node_of(std::uint8_t b) {
NodeId v{};
for (std::size_t i = 0; i < kNodeIdLen; ++i) v.b[i] = static_cast<std::uint8_t>(b + i);
return v;
}
std::vector<std::uint8_t> unhex(const char* s) {
auto nib = [](char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
return -1;
};
std::vector<std::uint8_t> out;
for (std::size_t i = 0; s[i] != 0 && s[i + 1] != 0; i += 2)
out.push_back(static_cast<std::uint8_t>(nib(s[i]) * 16 + nib(s[i + 1])));
return out;
}
std::vector<std::uint8_t> payload() {
const std::string p = "hello-from-the-other-chain";
return {p.begin(), p.end()};
}
// The three validators the Go reference built, in the canonical order a bit
// vector indexes: by uncompressed public key, ascending. Equal weights, so a
// two-of-three signature is exactly two-thirds.
std::map<NodeId, std::pair<std::vector<std::uint8_t>, std::uint64_t>> reference_set(
std::uint64_t w0 = 100, std::uint64_t w1 = 100, std::uint64_t w2 = 100) {
return {
{node_of(1), {unhex(pvmgold::warp_vdr0_pk), w0}},
{node_of(2), {unhex(pvmgold::warp_vdr1_pk), w1}},
{node_of(3), {unhex(pvmgold::warp_vdr2_pk), w2}},
};
}
warp::BitSetSignature reference_signature(std::vector<std::uint8_t> signers = {0x03}) {
warp::BitSetSignature s;
s.signers = std::move(signers);
const auto raw = unhex(pvmgold::warp_agg_sig);
std::copy(raw.begin(), raw.end(), s.signature.begin());
return s;
}
} // namespace
// Go: warp.NewUnsignedMessage — bytes and id.
TEST(UnsignedMessageWire) {
auto m = warp::UnsignedMessage::build(96369, id_of(0xC0), payload());
REQUIRE_OK(m);
REQUIRE_EQ(std::string(pvmgold::warp_unsigned), hex(m.value().bytes));
REQUIRE_EQ(std::string(pvmgold::warp_unsigned_id), hex(m.value().id));
auto back = warp::UnsignedMessage::parse(m.value().bytes);
REQUIRE_OK(back);
REQUIRE_EQ_NUM(96369, back.value().network_id);
REQUIRE_EQ(id_of(0xC0), back.value().source_chain_id);
REQUIRE_EQ(payload(), back.value().payload);
REQUIRE_EQ(m.value().id, back.value().id);
}
// Go: warp.NewMessage — the container plus its signature.
TEST(MessageWire) {
auto u = warp::UnsignedMessage::build(96369, id_of(0xC0), payload());
REQUIRE_OK(u);
auto m = warp::Message::build(u.value(), reference_signature());
REQUIRE_OK(m);
REQUIRE_EQ(std::string(pvmgold::warp_message), hex(m.value().bytes));
auto back = warp::Message::parse(m.value().bytes);
REQUIRE_OK(back);
REQUIRE_EQ(u.value().bytes, back.value().unsigned_message.bytes);
REQUIRE_EQ(u.value().id, back.value().unsigned_message.id);
REQUIRE(back.value().signature == reference_signature());
}
// The bit vector is a big-endian big integer, and one with a leading zero byte
// denotes the same set with different bytes — which would be two messages with
// one meaning, under two ids.
TEST(TheBitVectorIsCanonical) {
REQUIRE_EQ_NUM(2, reference_signature({0x03}).num_signers().value());
REQUIRE_EQ_NUM(1, reference_signature({0x04}).num_signers().value());
REQUIRE_EQ_NUM(0, reference_signature({}).num_signers().value());
REQUIRE_EQ_NUM(9, reference_signature({0xff, 0x01}).num_signers().value());
REQUIRE_ERR(reference_signature({0x00, 0x03}).num_signers(), Err::InvalidBitSet);
}
// Go: warp.FlattenValidatorSet. Two nodes sharing a key are ONE signer with the
// sum of their weights — one aggregate cannot tell them apart, so the set must
// not either — and a keyless validator's stake still counts toward the total.
TEST(FlattenTheValidatorSet) {
auto set = warp::flatten(reference_set());
REQUIRE_OK(set);
REQUIRE_EQ_NUM(3, set.value().validators.size());
REQUIRE_U64(300u, set.value().total_weight);
// Ordered by the uncompressed key, which is what the bit vector indexes.
auto shared = reference_set();
shared[node_of(4)] = {unhex(pvmgold::warp_vdr0_pk), 50};
auto merged = warp::flatten(shared);
REQUIRE_OK(merged);
REQUIRE_EQ_NUM(3, merged.value().validators.size());
REQUIRE_U64(350u, merged.value().total_weight);
REQUIRE_U64(150u, merged.value().validators[0].weight);
REQUIRE_EQ_NUM(2, merged.value().validators[0].node_ids.size());
// A validator with no key cannot sign, and its stake still counts.
auto keyless = reference_set();
keyless[node_of(5)] = {{}, 400};
auto with_keyless = warp::flatten(keyless);
REQUIRE_OK(with_keyless);
REQUIRE_EQ_NUM(3, with_keyless.value().validators.size());
REQUIRE_U64(700u, with_keyless.value().total_weight);
}
// Go: warp.FilterValidators. A signature cannot name a validator that is not
// in the set.
TEST(FilterSigners) {
auto set = warp::flatten(reference_set());
REQUIRE_OK(set);
const auto& vdrs = set.value().validators;
auto two = warp::filter(std::vector<std::uint8_t>{0x03}, vdrs);
REQUIRE_OK(two);
REQUIRE_EQ_NUM(2, two.value().size());
REQUIRE(two.value()[0].public_key == vdrs[0].public_key);
REQUIRE(two.value()[1].public_key == vdrs[1].public_key);
auto third = warp::filter(std::vector<std::uint8_t>{0x04}, vdrs);
REQUIRE_OK(third);
REQUIRE_EQ_NUM(1, third.value().size());
REQUIRE(third.value()[0].public_key == vdrs[2].public_key);
// Bit 3 names a fourth validator, and there are three.
REQUIRE_ERR(warp::filter(std::vector<std::uint8_t>{0x08}, vdrs), Err::UnknownValidator);
REQUIRE_ERR(warp::filter(std::vector<std::uint8_t>{0x00, 0x03}, vdrs), Err::InvalidBitSet);
}
// Go: warp.VerifyWeight. Computed without dividing, so nothing rounds in the
// attacker's favour.
TEST(VerifyWeight) {
REQUIRE_OK(warp::verify_weight(200, 300, 2, 3)); // exactly two-thirds
REQUIRE_OK(warp::verify_weight(300, 300, 2, 3));
REQUIRE_ERR(warp::verify_weight(199, 300, 2, 3), Err::InsufficientWeight);
REQUIRE_OK(warp::verify_weight(0, 0, 2, 3)); // an empty set is trivially met
// Nothing overflows on the way: the comparison is at full width.
REQUIRE_OK(warp::verify_weight(UINT64_MAX, UINT64_MAX, 1, 1));
REQUIRE_ERR(warp::verify_weight(UINT64_MAX - 1, UINT64_MAX, 1, 1), Err::InsufficientWeight);
}
// The one that matters: the Go reference's own aggregate over the Go
// reference's own message verifies here, and every way of breaking it fails.
TEST(VerifyTheReferenceSignature) {
auto u = warp::UnsignedMessage::build(96369, id_of(0xC0), payload());
REQUIRE_OK(u);
auto set = warp::flatten(reference_set());
REQUIRE_OK(set);
const auto sig = reference_signature();
// Two of three equal weights is exactly two-thirds.
REQUIRE_OK(warp::verify(sig, u.value(), 96369, set.value(), 2, 3));
// Three-quarters is more than two of three.
REQUIRE_ERR(warp::verify(sig, u.value(), 96369, set.value(), 3, 4), Err::InsufficientWeight);
// The message must have been signed for THIS network.
REQUIRE_ERR(warp::verify(sig, u.value(), 96370, set.value(), 2, 3), Err::WrongNetworkID);
// Claiming a signer who did not sign breaks the aggregate.
auto over_claimed = sig;
over_claimed.signers = {0x07};
REQUIRE_ERR(warp::verify(over_claimed, u.value(), 96370, set.value(), 2, 3), Err::WrongNetworkID);
REQUIRE_ERR(warp::verify(over_claimed, u.value(), 96369, set.value(), 2, 3),
Err::InvalidWarpSignature);
// Claiming the wrong two is the same failure.
auto wrong_pair = sig;
wrong_pair.signers = {0x06};
REQUIRE_ERR(warp::verify(wrong_pair, u.value(), 96369, set.value(), 2, 3), Err::InvalidWarpSignature);
// A different message is not this message.
auto other = warp::UnsignedMessage::build(96369, id_of(0xC0), std::vector<std::uint8_t>{1, 2, 3});
REQUIRE_OK(other);
REQUIRE_ERR(warp::verify(sig, other.value(), 96369, set.value(), 2, 3), Err::InvalidWarpSignature);
// And a flipped bit in the signature itself — whether it stops the
// signature decompressing or merely stops the pairing, the answer is the
// same refusal.
auto tampered = sig;
tampered.signature[0] ^= 0x01;
REQUIRE_ERR(warp::verify(tampered, u.value(), 96369, set.value(), 2, 3), Err::InvalidWarpSignature);
auto tampered_tail = sig;
tampered_tail.signature[95] ^= 0x01;
REQUIRE_ERR(warp::verify(tampered_tail, u.value(), 96369, set.value(), 2, 3),
Err::InvalidWarpSignature);
}
// A keyless validator's weight raises the bar without being able to help clear
// it. That is what stops a chain from cheapening its own quorum.
TEST(KeylessWeightRaisesTheBar) {
auto u = warp::UnsignedMessage::build(96369, id_of(0xC0), payload());
REQUIRE_OK(u);
auto padded = reference_set();
padded[node_of(9)] = {{}, 1'000};
auto set = warp::flatten(padded);
REQUIRE_OK(set);
REQUIRE_U64(1'300u, set.value().total_weight);
// The same two signers now hold 200 of 1300, which is not two-thirds.
REQUIRE_ERR(warp::verify(reference_signature(), u.value(), 96369, set.value(), 2, 3),
Err::InsufficientWeight);
}
// A signature scheme this port does not implement parses to a refusal, not to a
// signature that verifies trivially.
TEST(AnUnimplementedSchemeIsRefused) {
auto u = warp::UnsignedMessage::build(96369, id_of(0xC0), payload());
REQUIRE_OK(u);
auto m = warp::Message::build(u.value(), reference_signature());
REQUIRE_OK(m);
// The signature buffer is the second byte field of the message object; its
// own first byte is the scheme.
auto b = m.value().bytes;
const std::uint32_t root = static_cast<std::uint32_t>(b[8]) | (static_cast<std::uint32_t>(b[9]) << 8) |
(static_cast<std::uint32_t>(b[10]) << 16) |
(static_cast<std::uint32_t>(b[11]) << 24);
const std::int64_t slot = static_cast<std::int64_t>(root) + 8; // signature bytes @8
const std::int32_t rel = static_cast<std::int32_t>(
static_cast<std::uint32_t>(b[slot]) | (static_cast<std::uint32_t>(b[slot + 1]) << 8) |
(static_cast<std::uint32_t>(b[slot + 2]) << 16) | (static_cast<std::uint32_t>(b[slot + 3]) << 24));
const std::int64_t sig_buf = slot + rel;
// Inside that buffer, the root object's first byte is the scheme kind.
const std::uint32_t sig_root =
static_cast<std::uint32_t>(b[sig_buf + 8]) | (static_cast<std::uint32_t>(b[sig_buf + 9]) << 8) |
(static_cast<std::uint32_t>(b[sig_buf + 10]) << 16) |
(static_cast<std::uint32_t>(b[sig_buf + 11]) << 24);
b[sig_buf + sig_root] = 0x01; // the post-quantum scheme
REQUIRE_ERR(warp::Message::parse(b), Err::UnknownWarpSignature);
}
// ── the payload layer: what a warp message SAYS
//
// Ported from Go warp/payload (payload_test, addressed_call_test, hash_test)
// and warp/message (register_l1_validator_test, l1_validator_registration_test,
// l1_validator_weight_test, chain_to_l1_conversion_test).
namespace {
ShortId short_of(std::uint8_t b) {
ShortId v{};
for (std::size_t i = 0; i < kShortIdLen; ++i) v[i] = static_cast<std::uint8_t>(b + i);
return v;
}
signer::PublicKeyBytes reference_key() {
signer::PublicKeyBytes k{};
for (std::size_t i = 0; i < k.size(); ++i) k[i] = static_cast<std::uint8_t>(0x20 + i);
return k;
}
} // namespace
// The envelope says WHO sent this and from what address; the message inside
// says what.
TEST(TheEnvelopeWire) {
auto h = warpmsg::Hash::build(id_of(0xD0));
REQUIRE_OK(h);
REQUIRE_EQ(std::string(pvmgold::payload_hash), hex(h.value().bytes));
const std::string src = "source-addr", call = "the-call";
auto a = warpmsg::AddressedCall::build(
std::vector<std::uint8_t>(src.begin(), src.end()),
std::vector<std::uint8_t>(call.begin(), call.end()));
REQUIRE_OK(a);
REQUIRE_EQ(std::string(pvmgold::payload_addressed_call), hex(a.value().bytes));
auto back = warpmsg::parse_envelope(a.value().bytes);
REQUIRE_OK(back);
const auto* parsed = std::get_if<warpmsg::AddressedCall>(&back.value());
REQUIRE(parsed != nullptr);
REQUIRE_EQ(std::vector<std::uint8_t>(src.begin(), src.end()), parsed->source_address);
REQUIRE_EQ(std::vector<std::uint8_t>(call.begin(), call.end()), parsed->payload);
auto back_hash = warpmsg::parse_envelope(h.value().bytes);
REQUIRE_OK(back_hash);
REQUIRE(std::get_if<warpmsg::Hash>(&back_hash.value()) != nullptr);
// A kind that names nothing is refused rather than guessed at.
auto junk = h.value().bytes;
const std::uint32_t root = static_cast<std::uint32_t>(junk[8]) |
(static_cast<std::uint32_t>(junk[9]) << 8) |
(static_cast<std::uint32_t>(junk[10]) << 16) |
(static_cast<std::uint32_t>(junk[11]) << 24);
junk[root] = 0x7f;
REQUIRE_ERR(warpmsg::parse_envelope(junk), Err::WrongPayloadType);
}
// A validator's NAME on the P-chain is the hash of the message that registered
// it. Two registrations differing anywhere are two validators, and a replay of
// one cannot become the other.
TEST(RegisterL1ValidatorWire) {
auto m = warpmsg::RegisterL1Validator::build(
id_of(0xD1), node_of(0xD2), reference_key(), 1717171717,
warpmsg::PChainOwner{1, {short_of(0x30)}},
warpmsg::PChainOwner{2, {short_of(0x40), short_of(0x50)}}, 9000);
REQUIRE_OK(m);
REQUIRE_EQ(std::string(pvmgold::msg_register_l1_validator), hex(m.value().bytes));
REQUIRE_EQ(std::string(pvmgold::msg_register_l1_validator_id), hex(m.value().validation_id()));
REQUIRE_OK(m.value().verify());
auto back = warpmsg::parse_message(m.value().bytes);
REQUIRE_OK(back);
const auto* parsed = std::get_if<warpmsg::RegisterL1Validator>(&back.value());
REQUIRE(parsed != nullptr);
REQUIRE_EQ(id_of(0xD1), parsed->chain_id);
REQUIRE_U64(1717171717u, parsed->expiry);
REQUIRE_U64(9000u, parsed->weight);
REQUIRE(parsed->bls_public_key == reference_key());
REQUIRE_EQ(warpmsg::PChainOwner({1, {short_of(0x30)}}), parsed->remaining_balance_owner);
REQUIRE_EQ(warpmsg::PChainOwner({2, {short_of(0x40), short_of(0x50)}}), parsed->disable_owner);
// The name follows the bytes, so it survives the round trip.
REQUIRE_EQ(m.value().validation_id(), parsed->validation_id());
// One changed field is a different validator.
auto other = warpmsg::RegisterL1Validator::build(
id_of(0xD1), node_of(0xD2), reference_key(), 1717171717,
warpmsg::PChainOwner{1, {short_of(0x30)}},
warpmsg::PChainOwner{2, {short_of(0x40), short_of(0x50)}}, 9001);
REQUIRE_OK(other);
REQUIRE(!(other.value().validation_id() == m.value().validation_id()));
}
// Go: RegisterL1Validator.Verify — the four refusals.
TEST(RegisterL1ValidatorVerify) {
auto build = [&](const Id& chain, const NodeId& node, std::uint64_t weight,
const warpmsg::PChainOwner& rem, const warpmsg::PChainOwner& dis) {
return warpmsg::RegisterL1Validator::build(chain, node, reference_key(), 1, rem, dis, weight);
};
const warpmsg::PChainOwner good{1, {short_of(0x30)}};
// The primary network is not an L1 and does not register validators this way.
auto primary = build(kPrimaryNetworkId, node_of(1), 1, good, good);
REQUIRE_OK(primary);
REQUIRE_ERR(primary.value().verify(), Err::InvalidChainID);
auto weightless = build(id_of(0xD1), node_of(1), 0, good, good);
REQUIRE_OK(weightless);
REQUIRE_ERR(weightless.value().verify(), Err::InvalidWeight);
NodeId empty{};
auto anonymous = build(id_of(0xD1), empty, 1, good, good);
REQUIRE_OK(anonymous);
REQUIRE_ERR(anonymous.value().verify(), Err::InvalidNodeID);
// An owner nobody can satisfy would make the balance unrecoverable.
const warpmsg::PChainOwner unspendable{1, {}};
auto bad_owner = build(id_of(0xD1), node_of(1), 1, unspendable, good);
REQUIRE_OK(bad_owner);
REQUIRE_ERR(bad_owner.value().verify(), Err::InvalidOwner);
auto ok_msg = build(id_of(0xD1), node_of(1), 1, good, good);
REQUIRE_OK(ok_msg);
REQUIRE_OK(ok_msg.value().verify());
}
// Registration and weight: the two things an L1 tells the P-chain about a
// validator after it exists.
TEST(RegistrationAndWeightWire) {
auto reg = warpmsg::L1ValidatorRegistration::build(id_of(0xD3), true);
REQUIRE_OK(reg);
REQUIRE_EQ(std::string(pvmgold::msg_l1_validator_registration), hex(reg.value().bytes));
auto back = warpmsg::parse_message(reg.value().bytes);
REQUIRE_OK(back);
const auto* p = std::get_if<warpmsg::L1ValidatorRegistration>(&back.value());
REQUIRE(p != nullptr);
REQUIRE_EQ(id_of(0xD3), p->validation_id);
REQUIRE(p->registered);
// False here is permanent: this id is not and can never become a validator,
// which is what makes an expiry final rather than a retry.
auto never = warpmsg::L1ValidatorRegistration::build(id_of(0xD3), false);
REQUIRE_OK(never);
auto never_back = warpmsg::parse_message(never.value().bytes);
REQUIRE_OK(never_back);
REQUIRE(!std::get_if<warpmsg::L1ValidatorRegistration>(&never_back.value())->registered);
auto vw = warpmsg::L1ValidatorWeight::build(id_of(0xD4), 7, 12345);
REQUIRE_OK(vw);
REQUIRE_EQ(std::string(pvmgold::msg_l1_validator_weight), hex(vw.value().bytes));
auto vw_back = warpmsg::parse_message(vw.value().bytes);
REQUIRE_OK(vw_back);
const auto* w = std::get_if<warpmsg::L1ValidatorWeight>(&vw_back.value());
REQUIRE(w != nullptr);
REQUIRE_EQ(id_of(0xD4), w->validation_id);
// The nonce is what stops an old weight being replayed over a newer one.
REQUIRE_U64(7u, w->nonce);
REQUIRE_U64(12345u, w->weight);
}
// The conversion: what a network became when it went sovereign. Its id is the
// hash of its own canonical encoding, so the id and the bytes cannot diverge.
TEST(ConversionWire) {
warpmsg::ConversionData d;
d.chain_id = id_of(0xD5);
d.manager_chain_id = id_of(0xD6);
const std::string mgr = "manager";
d.manager_address.assign(mgr.begin(), mgr.end());
const auto n1 = node_of(0xD7), n2 = node_of(0xD8);
d.validators = {
{std::vector<std::uint8_t>(n1.b.begin(), n1.b.end()), reference_key(), 11},
{std::vector<std::uint8_t>(n2.b.begin(), n2.b.end()), reference_key(), 22},
};
auto encoded = d.encode();
REQUIRE_OK(encoded);
REQUIRE_EQ(std::string(pvmgold::conversion_data), hex(encoded.value()));
auto cid = d.conversion_id();
REQUIRE_OK(cid);
REQUIRE_EQ(std::string(pvmgold::conversion_id), hex(cid.value()));
auto m = warpmsg::ChainToL1Conversion::build(cid.value());
REQUIRE_OK(m);
REQUIRE_EQ(std::string(pvmgold::msg_chain_to_l1_conversion), hex(m.value().bytes));
auto back = warpmsg::parse_message(m.value().bytes);
REQUIRE_OK(back);
const auto* p = std::get_if<warpmsg::ChainToL1Conversion>(&back.value());
REQUIRE(p != nullptr);
REQUIRE_EQ(cid.value(), p->id);
// One changed validator weight is a different conversion.
auto d2 = d;
d2.validators[1].weight = 23;
REQUIRE(!(d2.conversion_id().value() == cid.value()));
}