mirror of
https://github.com/luxfi/node.git
synced 2026-10-30 15:24:05 +00:00
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.
285 lines
11 KiB
C++
285 lines
11 KiB
C++
// Copyright (C) 2026, Lux Industries, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: BSD-3-Clause-Eco
|
|
//
|
|
// genesis_test.cpp — the state the network starts in.
|
|
//
|
|
// Ported from Go vms/platformvm/genesis/genesis_test.go and the UTXO envelope
|
|
// in github.com/luxfi/utxo/wire. The blob under test is the one the Go package
|
|
// wrote, so a chain that boots from these bytes boots into the state the Go
|
|
// node boots into — which is the only sense in which two implementations start
|
|
// the same network.
|
|
|
|
#include "golden.hpp"
|
|
#include "harness.hpp"
|
|
#include "lux/platformvm/genesis.hpp"
|
|
#include "signing.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;
|
|
}
|
|
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;
|
|
}
|
|
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;
|
|
}
|
|
|
|
// The same fixtures the golden generator used.
|
|
UTXO reference_utxo() {
|
|
UTXO u;
|
|
u.utxo = UtxoId{id_of(0x90), 2};
|
|
u.asset = id_of(0x10);
|
|
u.out = TransferOutput{4242, OutputOwners{5, 1, {short_of(0x30)}}};
|
|
return u;
|
|
}
|
|
|
|
UTXO locked_utxo() {
|
|
UTXO u;
|
|
u.utxo = UtxoId{id_of(0x91), 0};
|
|
u.asset = id_of(0x10);
|
|
u.stake_lock = 777;
|
|
u.out = TransferOutput{99, OutputOwners{0, 1, {short_of(0x40)}}};
|
|
return u;
|
|
}
|
|
|
|
BaseTx base_fixture() {
|
|
const Id asset = id_of(0x10);
|
|
BaseTx b;
|
|
b.network_id = 96369;
|
|
b.blockchain_id = id_of(0x20);
|
|
b.outs = {
|
|
TransferableOutput{asset, 0,
|
|
TransferOutput{1'000'000,
|
|
OutputOwners{7, 2, {short_of(0x30), short_of(0x40), short_of(0x50)}}}},
|
|
TransferableOutput{asset, 999, TransferOutput{42, OutputOwners{0, 1, {short_of(0x30)}}}},
|
|
};
|
|
b.ins = {TransferableInput{UtxoId{id_of(0x60), 1}, asset, 999, TransferInput{1'000'042, {0, 1}}}};
|
|
const std::string memo = "native-zap";
|
|
b.memo.assign(memo.begin(), memo.end());
|
|
return b;
|
|
}
|
|
|
|
signer::ProofOfPossession pop_fixture() {
|
|
signer::ProofOfPossession p{};
|
|
for (std::size_t i = 0; i < signer::kPublicKeyLen; ++i) p.public_key[i] = static_cast<std::uint8_t>(i);
|
|
for (std::size_t i = 0; i < signer::kSignatureLen; ++i)
|
|
p.proof[i] = static_cast<std::uint8_t>(255 - static_cast<int>(i));
|
|
return p;
|
|
}
|
|
|
|
txs::Tx unsigned_only(std::shared_ptr<txs::UnsignedTx> u) {
|
|
txs::Tx t;
|
|
t.unsigned_tx = std::move(u);
|
|
(void)t.initialize();
|
|
return t;
|
|
}
|
|
|
|
genesis::Genesis reference_genesis() {
|
|
genesis::Genesis g;
|
|
g.timestamp = 1'600'000'000;
|
|
g.initial_supply = 720'000'000'000'000;
|
|
g.message = "hello genesis";
|
|
const std::string alloc = "alloc-1";
|
|
g.utxos.push_back(genesis::Allocation{reference_utxo(), {alloc.begin(), alloc.end()}});
|
|
|
|
const signer::Signer sig = pop_fixture();
|
|
const std::vector<TransferableOutput> stake = {
|
|
TransferableOutput{id_of(0x10), 0, TransferOutput{2'000'000, OutputOwners{0, 1, {short_of(0x40)}}}}};
|
|
const txs::Owner rewards{3, 1, {short_of(0x30)}};
|
|
auto apv = txs::AddPermissionlessValidatorTx::create(
|
|
base_fixture(), txs::Validator{node_of(0x93), 103, 203, 2'000'000}, kEmptyId, sig, stake, rewards,
|
|
rewards, 20000);
|
|
g.validators.push_back(unsigned_only(apv.value()));
|
|
|
|
const std::string gen = "genesis-bytes";
|
|
auto cc = txs::CreateChainTx::create(base_fixture(), id_of(0x72), "My Chain 7", id_of(0x73),
|
|
{id_of(0x01), id_of(0x02)},
|
|
std::vector<std::uint8_t>(gen.begin(), gen.end()), txs::Auth{0, 2});
|
|
g.chains.push_back(unsigned_only(cc.value()));
|
|
return g;
|
|
}
|
|
|
|
// The reference genesis's validator carries the golden proof-of-possession
|
|
// bytes, which are a fixture and not a real proof — the blob under test is the
|
|
// one Go wrote, and Go does not verify a proof to write bytes. Every check
|
|
// ABOUT a genesis therefore runs against a validator of its own network, which
|
|
// registers no key at all, so nothing has to be proved about one.
|
|
genesis::Genesis verifiable_genesis(std::uint64_t weight = 2'000'000, std::uint64_t end = 203) {
|
|
genesis::Genesis g = reference_genesis();
|
|
g.timestamp = 0;
|
|
const std::vector<TransferableOutput> stake = {
|
|
TransferableOutput{id_of(0x10), 0,
|
|
TransferOutput{2'000'000, OutputOwners{0, 1, {short_of(0x40)}}}}};
|
|
const txs::Owner rewards{3, 1, {short_of(0x30)}};
|
|
auto apv = txs::AddPermissionlessValidatorTx::create(
|
|
base_fixture(), txs::Validator{node_of(0x93), 103, end, weight}, id_of(0x40),
|
|
signer::Signer{signer::Empty{}}, stake, rewards, rewards, 20000);
|
|
g.validators[0] = unsigned_only(apv.value());
|
|
return g;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// The cross-chain UTXO envelope: what genesis stores and what crosses to
|
|
// another chain. One encoding, both ways.
|
|
TEST(TheUTXOEnvelope) {
|
|
REQUIRE_EQ(std::string(pvmgold::utxo_wire), hex(reference_utxo().wire_bytes()));
|
|
REQUIRE_EQ(std::string(pvmgold::utxo_wire_locked), hex(locked_utxo().wire_bytes()));
|
|
|
|
auto back = UTXO::from_wire_bytes(reference_utxo().wire_bytes());
|
|
REQUIRE_OK(back);
|
|
REQUIRE_EQ(reference_utxo(), back.value());
|
|
|
|
auto locked_back = UTXO::from_wire_bytes(locked_utxo().wire_bytes());
|
|
REQUIRE_OK(locked_back);
|
|
REQUIRE_EQ(locked_utxo(), locked_back.value());
|
|
// The lock is a VALUE on the output here, not a second type, and it comes
|
|
// back as one.
|
|
REQUIRE_U64(777u, locked_back.value().stake_lock);
|
|
}
|
|
|
|
// A UTXO this chain cannot spend is not a UTXO it should pretend to hold.
|
|
TEST(AnUnspendableEnvelopeIsRefused) {
|
|
auto b = reference_utxo().wire_bytes();
|
|
REQUIRE_ERR(UTXO::from_wire_bytes(std::vector<std::uint8_t>{0x00}), Err::BufferTooSmall);
|
|
|
|
// Relabelled as some other shape.
|
|
auto wrong_shape = b;
|
|
wrong_shape[1] = 0x0B;
|
|
REQUIRE_ERR(UTXO::from_wire_bytes(wrong_shape), Err::UnsupportedFxOutput);
|
|
|
|
// A UTXO whose OUTPUT belongs to one of the six other signature families.
|
|
auto foreign = reference_utxo().wire_bytes();
|
|
// Find the inner envelope's discriminator and relabel the family.
|
|
const auto inner = reference_utxo().out;
|
|
TransferableOutput as_out{reference_utxo().asset, 0, inner};
|
|
auto inner_bytes = as_out.wire_bytes();
|
|
inner_bytes[0] = 0x02; // an ML-DSA output: real, and not one this chain spends
|
|
REQUIRE_ERR(TransferableOutput::from_wire_bytes(inner_bytes, id_of(0x10)), Err::UnsupportedFxOutput);
|
|
}
|
|
|
|
// The genesis blob: the bytes the Go package wrote, and every field back out of
|
|
// them. A chain that boots from these boots into the state the Go node boots
|
|
// into.
|
|
TEST(TheGenesisBlob) {
|
|
const auto g = reference_genesis();
|
|
REQUIRE_EQ(std::string(pvmgold::genesis_blob), hex(g.encode()));
|
|
|
|
auto back = genesis::Genesis::parse(unhex(pvmgold::genesis_blob));
|
|
REQUIRE_OK(back);
|
|
const auto& p = back.value();
|
|
REQUIRE_U64(1'600'000'000u, p.timestamp);
|
|
REQUIRE_U64(720'000'000'000'000u, p.initial_supply);
|
|
REQUIRE_EQ(std::string("hello genesis"), p.message);
|
|
|
|
REQUIRE_EQ_NUM(1, p.utxos.size());
|
|
REQUIRE_EQ(reference_utxo(), p.utxos[0].utxo);
|
|
const std::string alloc = "alloc-1";
|
|
REQUIRE_EQ(std::vector<std::uint8_t>(alloc.begin(), alloc.end()), p.utxos[0].message);
|
|
|
|
// The transactions come back from their own SIGNED bytes, so their ids are
|
|
// what they were when the genesis was written.
|
|
REQUIRE_EQ_NUM(1, p.validators.size());
|
|
REQUIRE_EQ(g.validators[0].tx_id, p.validators[0].tx_id);
|
|
REQUIRE(p.validators[0].unsigned_tx->kind() == txs::Kind::AddPermissionlessValidator);
|
|
|
|
REQUIRE_EQ_NUM(1, p.chains.size());
|
|
REQUIRE_EQ(g.chains[0].tx_id, p.chains[0].tx_id);
|
|
const auto* chain = dynamic_cast<const txs::CreateChainTx*>(p.chains[0].unsigned_tx.get());
|
|
REQUIRE(chain != nullptr);
|
|
REQUIRE_EQ(std::string("My Chain 7"), chain->blockchain_name());
|
|
}
|
|
|
|
// An empty genesis is a legitimate genesis — a network with no money and no
|
|
// validators is a network that has not started, not a malformed one.
|
|
TEST(AnEmptyGenesisRoundTrips) {
|
|
genesis::Genesis g;
|
|
g.timestamp = 42;
|
|
g.initial_supply = 0;
|
|
auto back = genesis::Genesis::parse(g.encode());
|
|
REQUIRE_OK(back);
|
|
REQUIRE_U64(42u, back.value().timestamp);
|
|
REQUIRE(back.value().utxos.empty());
|
|
REQUIRE(back.value().validators.empty());
|
|
REQUIRE(back.value().chains.empty());
|
|
REQUIRE_OK(back.value().verify());
|
|
}
|
|
|
|
// Go: the genesis refusals. Each is a state no transaction could have produced,
|
|
// so a chain that started there could never be reasoned about.
|
|
TEST(GenesisRefusals) {
|
|
{ // money that is not money
|
|
genesis::Genesis g = verifiable_genesis();
|
|
g.utxos[0].utxo.out.amt = 0;
|
|
REQUIRE_ERR(g.verify(), Err::BadGenesis);
|
|
}
|
|
{ // a validator nobody can vote toward
|
|
REQUIRE_ERR(verifiable_genesis(0).verify(), Err::BadGenesis);
|
|
}
|
|
{ // a validator whose term is already over would have to be removed by the
|
|
// first block, which is a state no transaction could have produced
|
|
genesis::Genesis g = verifiable_genesis(2'000'000, 203);
|
|
g.timestamp = 1'000'000'000;
|
|
REQUIRE_ERR(g.verify(), Err::BadGenesis);
|
|
}
|
|
{ // a key nobody holds is a validator nobody is. The refusal names the
|
|
// PROOF rather than the genesis, because that is the fact that is wrong.
|
|
genesis::Genesis g = reference_genesis();
|
|
g.timestamp = 0;
|
|
REQUIRE_ERR(g.verify(), Err::InvalidPublicKey);
|
|
}
|
|
{ // a "chain" that does not create a chain
|
|
genesis::Genesis g = verifiable_genesis();
|
|
auto base = txs::BaseTxUnsigned::create(base_fixture());
|
|
g.chains[0] = unsigned_only(base.value());
|
|
REQUIRE_ERR(g.verify(), Err::BadGenesis);
|
|
}
|
|
{ // and a well-formed one passes
|
|
REQUIRE_OK(verifiable_genesis().verify());
|
|
}
|
|
}
|
|
|
|
// A truncated blob is a refusal, not a partial genesis.
|
|
TEST(AMalformedBlobIsRefused) {
|
|
REQUIRE_ERR(genesis::Genesis::parse(std::vector<std::uint8_t>{1, 2, 3}), Err::BadGenesis);
|
|
|
|
// A length that overruns the blob it indexes into.
|
|
auto g = reference_genesis();
|
|
auto b = g.encode();
|
|
const std::int64_t root = static_cast<std::int64_t>(
|
|
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 = root + 24; // the utxo length list
|
|
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 list = slot + rel;
|
|
b[list] = 0xff;
|
|
b[list + 1] = 0xff;
|
|
REQUIRE_ERR(genesis::Genesis::parse(b), Err::BadGenesis);
|
|
}
|