Files
node/test/engine_test.cpp
zeekayandHanzo Dev 375c83bf8c A height is decided on Go's rule, and an imported tip is anchored
The proposer was height mod n with no fallback, so one seat down halted the
chain at its turn, and a node that read an export could never build on it.

- windower: Go's proposervm windower (lux-rs/node src/proposer.rs), held to
  windower_test.go's vectors; a proposal is ML-DSA-signed by its slot's
  proposer and checked against it.
- engine: lux-rs/node's step rule, clause for clause — one proposer a window
  from a clock counted off the tip's time, one signature a height through a
  durable journal, the lowest live block that can still reach alpha after a
  settle window, no building beside a live block or f+1 votes, sibling caps,
  one settle deadline a height, the retry, and catch-up on certificates.
- the anchor: a gated validator signs one statement at its imported tip (T, H)
  under its own tag, through the same journal; alpha of them are the
  certificate that lifts the gate, and T+1 is built on it.
- the mesh binds an address, resolves names, accepts on its own thread and is
  swept again, so a seat that restarts is met again.
- tests: windower, statement, engine and live (five seats anchor Zoo
  mainnet's tip and build on it; one down; one that imported another tip; one
  that restarts and catches up).

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-24 16:13:14 -07:00

273 lines
11 KiB
C++

// Copyright (C) 2026, Lux Industries, Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause-Eco
//
// engine_test.cpp — one validator against a peer the test scripts.
//
// live_test proves validators converge; this proves what one of them refuses,
// each against a control that shows the refusal is the thing being tested:
//
// - a proposal signed for a window that is not its signer's is never parsed —
// the chain is not shown a byte of it — while the same signer's proposal in
// its own window is parsed, executed and relayed;
// - a block the chain refuses is never signed;
// - a node whose tip came off an export builds nothing in its own windows and
// signs nothing but its anchor, where the same node without the gate builds;
// - a chain that refuses to build does not stop the node.
#include "fake_chain.hpp"
#include "lux/node/engine.hpp"
#include "lux/node/host.hpp"
#include "lux/node/journal.hpp"
#include "lux/node/signer.hpp"
#include <chrono>
#include <cstdio>
#include <filesystem>
#include <functional>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#include <unistd.h>
using namespace lux::node;
using namespace std::chrono_literals;
namespace {
int g_fail = 0;
void check(bool ok, const std::string& what) {
std::printf(" %s %s\n", ok ? "ok " : "FAIL", what.c_str());
if (!ok) ++g_fail;
}
constexpr auto kWindow = 300ms;
constexpr std::uint64_t kTip = 799;
Id filled(std::uint8_t b) {
Id id{};
id.fill(b);
return id;
}
// A chain that says no to building, in the way a chain in another process does:
// the call fails.
class Refusing final : public VM {
public:
explicit Refusing(std::shared_ptr<test::Store> s) : inner_(std::move(s)) {}
Id chain_id() const override { return {}; }
std::string alias() const override { return "test"; }
std::shared_ptr<Block> build() override {
++calls;
throw std::runtime_error("build: the chain is not ready");
}
std::shared_ptr<Block> parse(std::span<const std::uint8_t> b) override { return inner_.parse(b); }
std::shared_ptr<Block> get(const Id& id) const override { return inner_.get(id); }
void prefer(const Id&) override {}
Id last_accepted() const override { return inner_.last_accepted(); }
std::uint64_t last_accepted_height() const override { return inner_.last_accepted_height(); }
int calls = 0;
private:
test::FakeChain inner_;
};
// A validator under test (seat 0) and a scripted peer (seat 1) in a committee
// of four, the two linked over a real loopback socket.
struct Rig {
std::filesystem::path root;
Id chain = filled(0x2c);
std::vector<std::unique_ptr<Signer>> signers;
Committee committee;
std::shared_ptr<test::Store> store;
std::unique_ptr<Host> host, peer;
std::mutex guard;
std::unique_ptr<Engine> engine;
VM* vm = nullptr;
Rig(const std::string& name, bool gate, std::function<std::unique_ptr<VM>(std::shared_ptr<test::Store>)> make = {}) {
root = std::filesystem::temp_directory_path() / ("node-engine-" + std::to_string(::getpid()) + "-" + name);
std::filesystem::remove_all(root);
std::string text;
for (int i = 0; i < 4; ++i) {
signers.push_back(std::make_unique<Signer>(Signer::open(root / ("v" + std::to_string(i)))));
text += signers.back()->publish(chain) + "\n";
}
committee = Committee::read(text, chain);
store = test::Store::imported(filled(0x79), kTip, 1747000000);
if (gate) close_gate(root / "v0");
auto link = [this](std::uint32_t i) {
Link l;
l.me = signers[i]->identity();
l.chain = chain;
l.seat = [this](const Node& who) -> std::optional<std::uint32_t> {
const auto at = committee.seat(who);
if (!at) return std::nullopt;
return std::uint32_t(*at);
};
return l;
};
HostConfig a;
a.index = 0;
a.link = link(0);
host = std::make_unique<Host>(std::move(a));
HostConfig b;
b.index = 1;
b.link = link(1);
peer = std::make_unique<Host>(std::move(b));
const auto pa = host->listen_bind();
const auto pb = peer->listen_bind();
std::thread t([&] { peer->connect_mesh({{0, {"127.0.0.1", pa}}}, 2000); });
host->connect_mesh({{1, {"127.0.0.1", pb}}}, 2000);
t.join();
EngineConfig ec;
ec.committee = committee;
ec.self = signers[0]->node();
ec.sk = signers[0]->secret();
ec.identity = signers[0]->identity();
ec.chain = chain;
ec.binding = Binding::Transport;
ec.window = kWindow;
ec.data = root / "v0";
auto chainp = make ? make(store) : std::make_unique<test::FakeChain>(store);
vm = chainp.get();
engine = std::make_unique<Engine>(std::move(chainp), *host, guard, std::move(ec));
}
~Rig() {
engine.reset();
host.reset();
peer.reset();
std::filesystem::remove_all(root);
}
// Drive the engine for `d`, draining what reaches the peer.
std::vector<Event> run(std::chrono::milliseconds d) {
std::vector<Event> events;
const auto end = std::chrono::steady_clock::now() + d;
while (std::chrono::steady_clock::now() < end) {
auto s = engine->step();
events.insert(events.end(), s.events.begin(), s.events.end());
peer->pump();
std::this_thread::sleep_for(1ms);
}
return events;
}
// A proposal the peer (seat 1) signs at `slot`, of a block on `parent`.
Proposal proposal(std::uint64_t slot, const Id& parent, std::uint64_t nonce) {
std::vector<std::uint8_t> b{'F', 'A', 'K', 'E'};
b.insert(b.end(), parent.begin(), parent.end());
for (std::uint64_t v : {kTip + 1, std::uint64_t(1747000100), nonce})
for (int s = 56; s >= 0; s -= 8) b.push_back(std::uint8_t(v >> s));
Proposal p;
p.height = kTip + 1;
p.slot = slot;
p.block = b;
const auto header = proposal_header(chain, committee.root(), store->tip, p.height, slot,
signers[1]->identity().public_key(), b);
p.signature = pq::sign(signers[1]->identity().secret_key(), kProposalContext, header);
return p;
}
// The first slot at the next height whose proposer is (or is not) seat `i`.
std::uint64_t slot_of(std::uint32_t i, bool is) const {
for (std::uint64_t s = 0;; ++s)
if ((engine->proposer(kTip + 1, s) == committee.members()[i].node) == is) return s;
}
};
} // namespace
int main() {
std::printf("============= node — the engine, one validator at a time =============\n\n");
// [1] Whose window a proposal is in decides whether it is looked at.
{
Rig r("windows", false);
const auto theirs = r.slot_of(1, true);
const auto others = r.slot_of(1, false);
// The engine under test takes its own windows too; its store has
// nothing to build, so the only block at the height is the peer's.
r.store->pending = false;
r.run(50ms);
r.peer->gossip(kProposalMsgType, encode(r.proposal(others, r.store->tip, 1)));
r.run(3 * kWindow);
check(r.store->parses == 0, "[1] a proposal signed for a window that is not its signer's is never parsed");
r.peer->gossip(kProposalMsgType, encode(r.proposal(theirs, r.store->tip, 2)));
r.run(3 * kWindow);
check(r.store->parses == 1 && r.store->verifies >= 1,
"[1] the signer's own window: parsed and executed");
}
// [2] A block the chain refuses is never signed.
{
Rig r("refused", false);
r.store->pending = false;
const auto theirs = r.slot_of(1, true);
r.run(50ms);
// On a parent that is not the tip: the chain refuses it.
r.peer->gossip(kProposalMsgType, encode(r.proposal(theirs, filled(0x13), 3)));
const auto events = r.run(4 * kWindow);
bool said = false;
for (const auto& e : events) said = said || e.text.find("proposer") != std::string::npos;
check(r.store->parses == 1, "[2] a correctly signed proposal is parsed");
check(said, "[2] and the refusal is said once");
r.engine.reset();
auto j = Journal::open(r.root / "v0" / "votes");
check(j.size() == 0, "[2] and nothing was signed for it");
// control: the same proposal on the tip is signed.
Rig c("refused-control", false);
c.store->pending = false;
const auto s = c.slot_of(1, true);
c.run(50ms);
c.peer->gossip(kProposalMsgType, encode(c.proposal(s, c.store->tip, 4)));
c.run(4 * kWindow);
c.engine.reset();
auto k = Journal::open(c.root / "v0" / "votes");
check(k.size() == 1, "[2] control: the same block on the tip is signed, once");
}
// [3] A gated node builds nothing in its own windows and signs only its
// anchor; the same node ungated builds.
{
Rig g("gated", true);
const auto mine = g.slot_of(0, true);
g.run(kWindow * std::int64_t(mine + 3));
check(g.store->builds == 0, "[3] a gated node builds nothing, its own window included");
check(!g.engine->caught_up(), "[3] and says it is not caught up");
g.engine.reset();
auto j = Journal::open(g.root / "v0" / "votes");
const auto a = j.standing(Slot{g.chain, kTip, 0});
check(j.size() == 1 && a && read(*a) && read(*a)->kind == Kind::Anchor && read(*a)->block == g.store->tip,
"[3] its journal holds one statement: the anchor on its imported tip");
Rig c("gated-control", false);
const auto m = c.slot_of(0, true);
c.run(kWindow * std::int64_t(m + 3));
check(c.store->builds >= 1, "[3] control: ungated, the same node builds in its window");
}
// [4] A chain that refuses to build does not stop the node.
{
Refusing* refusing = nullptr;
Rig r("refusing", false, [&](std::shared_ptr<test::Store> s) {
auto v = std::make_unique<Refusing>(std::move(s));
refusing = v.get();
return v;
});
const auto mine = r.slot_of(0, true);
const auto events = r.run(kWindow * std::int64_t(mine + 3));
bool said = false;
for (const auto& e : events) said = said || e.text.find("building") != std::string::npos;
check(refusing->calls >= 1 && said, "[4] the refusal reached the engine, which said so and went on");
}
std::printf("----------------------------------------------------------------------\n");
if (g_fail) { std::printf("==== node ENGINE: FAIL (%d) ====\n", g_fail); return 1; }
std::printf("==== node ENGINE: PASS — what one validator refuses, against a control ====\n");
return 0;
}