Files
zeekay 60c90152e9 Ask the Z-chain's proof and its spent set what they refuse
Three things this chain could not say no to.

A verifier is only a verifier if it refuses a proof that DECODES. Every
refusal the suites held it to was decided in front of the arithmetic —
too short, off the curve, at infinity, no key for the circuit — and a
`verify` ending in `return {}` passes all of them, which is what the
first port of this chain shipped. `tamper` asks only what the pairing
has to answer: a point swapped for its own negation, a point swapped for
another valid one, A and C exchanged, one bit flipped in each of the
proof's eight coordinates, each public input moved by one, alpha negated
in the key, K[0] replaced. It opens with an acceptance, because a
verifier that refuses everything is as broken as one that accepts
everything and only a suite holding both verdicts can tell them apart.
Short-circuit `verify_pairing` and it reports 14 failures.

`mark_spent` is the last refusal in front of a note being spent twice
and it consulted the in-memory set, while the read beside it falls
through to the records. The weaker of two answers to one question is the
one that decides: a set that is not a superset of the records answers
"unspent" for a note the records hold, and the write then overwrote that
record with a new height and returned success. It asks `spent_at` now.
Put the old guard back and `state` reports three failures — the note
spent twice, its record moved to the second height, and a spend
proceeding against a store whose reads fail.

And the differential's four disagreements, all of them this evaluator's.
The corpus asks whether a refusal was decided from the block's own
bytes; the evaluator ran a per-transaction `validate_basic` pass and
called that the answer. That is a different question, and it answered
"the block is fine" for a genesis carrying a parent, a timestamp past
the skew bound, one note spent twice inside a block, and a transaction
its own block's height had passed — none of which is about a transaction
in isolation. One `check()`, split on the chain's own error constants.
Every compared field of all 629 vectors agrees now.

`make chains` still exits non-zero: the Rust column prints no row for Q,
Z, D or F, and silence is not agreement. Those evaluators are
chains/rust's.
2026-09-05 14:20:11 -07:00

347 lines
13 KiB
C++

// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause-Eco
//
// state_test.cpp — the three stores the shielded pool rests on: the spent set,
// the UTXO set, and the committed state root.
//
// The through-line is one rule: A READ THAT FAILED IS NOT AN ABSENT ROW.
// Reporting a spent-set read failure as "not spent" is exactly how an
// already-spent note gets spent again, and a chain that boots on an unreadable
// root believing the shielded state is empty disagrees with the network on every
// block it sees, permanently.
#include "check.hpp"
#include "fixtures.hpp"
#include "lux/zkvm/nullifier.hpp"
#include "lux/zkvm/root.hpp"
#include "lux/zkvm/store.hpp"
#include "lux/zkvm/utxo.hpp"
#include <functional>
using namespace lux::zkvm;
using namespace lux::zkvm::test;
namespace {
// Broken fails every READ with something that is not an absence. It is the store
// a dead disk gives you.
class Broken final : public store::Store {
public:
explicit Broken(store::Store& real) : real_(&real) {}
store::Result<std::optional<Bytes>> get(ByteView) const override {
return std::unexpected("disk gone");
}
store::Result<void> put(ByteView k, ByteView v) override { return real_->put(k, v); }
store::Result<void> erase(ByteView k) override { return real_->erase(k); }
store::Result<void> each(ByteView p,
const std::function<bool(ByteView, ByteView)>& f) const override {
return real_->each(p, f);
}
store::Result<void> commit() override { return real_->commit(); }
private:
store::Store* real_;
};
// Unscannable fails the ENUMERATION, which is what a boot does.
class Unscannable final : public store::Store {
public:
store::Result<std::optional<Bytes>> get(ByteView) const override {
return std::optional<Bytes>{};
}
store::Result<void> put(ByteView, ByteView) override { return {}; }
store::Result<void> erase(ByteView) override { return {}; }
store::Result<void> each(ByteView,
const std::function<bool(ByteView, ByteView)>&) const override {
return std::unexpected("iterator gone");
}
store::Result<void> commit() override { return {}; }
};
void the_spent_set_follows_the_records() {
std::printf("the spent set is rebuilt from the records, and counted off them\n");
store::Memory db;
// The record landed; a counter write, had there been one, did not.
const Bytes key = make_nullifier_key(view(b("orphan")));
check_ok(db.put(view(key), view(Bytes(8, 0))), "an orphan record is on disk");
auto ndb = NullifierDb::open(db);
check_ok(ndb, "the set opens");
if (!ndb) return;
auto spend = (*ndb)->spent_at(view(b("orphan")));
check(spend && spend->spent, "the set is rebuilt from the records");
check((*ndb)->count() == 1,
"and the count is read off them, so it cannot disagree with what it describes");
}
void the_spent_set_is_permanent() {
std::printf("\na note is spent once, and stays spent\n");
store::Memory db;
auto ndb = NullifierDb::open(db);
if (!ndb) {
check(false, "the set opens");
return;
}
check((*ndb)->count() == 0, "a fresh chain has spent nothing");
check_ok((*ndb)->mark_spent(view(b("a")), 1), "a note is spent");
check_ok((*ndb)->mark_spent(view(b("b")), 2), "and another");
check((*ndb)->count() == 2, "the count follows");
check_err((*ndb)->mark_spent(view(b("a")), 3), kErrNullifierSpent,
"spending the same note again is refused");
check((*ndb)->count() == 2, "and changes nothing");
auto at = (*ndb)->spent_at(view(b("b")));
check(at && at->spent && at->height == 2, "the set records WHICH block spent the note");
auto never = (*ndb)->spent_at(view(b("never")));
check(never && !never->spent, "and an unspent note reads as unspent");
}
void the_write_path_is_no_weaker_than_the_read_path() {
std::printf("\nthe refusal in front of a second spend reads the RECORDS, not a cache\n");
store::Memory db;
auto ndb = NullifierDb::open(db);
if (!ndb) {
check(false, "the set opens");
return;
}
check_ok((*ndb)->mark_spent(view(b("n")), 4), "a note is spent");
// The in-memory set stops being a superset of the records. spent_at already
// survives this — it falls through to the records — and mark_spent, the LAST
// refusal in front of a double spend, must survive it too. When it consulted
// the cache alone it did not: it wrote a second record over the first, at a
// new height, and returned success.
(*ndb)->forget_cache();
check_err((*ndb)->mark_spent(view(b("n")), 9), kErrNullifierSpent,
"spending it again is still refused with the set forgotten");
auto r = (*ndb)->spent_at(view(b("n")));
check(r && r->spent && r->height == 4,
"and the record still names the block that actually spent it");
// A store that cannot be read must not be mistaken for one holding nothing.
store::Memory real;
Broken broken(real);
auto over_broken = NullifierDb::open(broken);
check_ok(over_broken, "a set opens over a store whose reads fail");
if (over_broken)
check_err((*over_broken)->mark_spent(view(b("n")), 1), "disk gone",
"and a spend against it fails rather than proceeding blind");
}
void a_failed_read_is_not_an_unspent_note() {
std::printf("\na read that FAILED is not an unspent note\n");
store::Memory real;
Broken broken(real);
auto ndb = NullifierDb::open(broken);
check_ok(ndb, "the set opens over a store whose reads fail");
if (!ndb) return;
auto r = (*ndb)->spent_at(view(b("n")));
check_err(r, "disk gone", "the failure is reported as a failure");
}
void a_read_does_not_write_the_set() {
std::printf("\na read does not memoise what it loaded\n");
store::Memory db;
auto ndb = NullifierDb::open(db);
if (!ndb) {
check(false, "the set opens");
return;
}
check_ok((*ndb)->mark_spent(view(b("n")), 4), "a note is spent");
// A record on disk that the in-memory set does not know about is what a
// memoising read would fill in.
(*ndb)->forget_cache();
auto r = (*ndb)->spent_at(view(b("n")));
check(r && r->spent && r->height == 4, "the record on disk still reads as spent");
check((*ndb)->count() == 0, "and the read did not change the set it read");
}
void a_short_record_is_not_a_height() {
std::printf("\na record that is not eight bytes is not a height\n");
// Blocks are stored in the same store under their own keys, so the nullifier
// prefix can turn up over a value that is not a height. The boot scan passes
// over anything that is not eight bytes; the read path has to agree with it,
// or reading one of those is a crash where a miss was meant.
store::Memory db;
check_ok(db.put(view(make_nullifier_key(view(b("n")))), view(Bytes{1})),
"a one-byte value sits under the prefix");
auto ndb = NullifierDb::open(db);
check_ok(ndb, "the set opens");
if (!ndb) return;
check((*ndb)->count() == 0, "the boot scan passed over it");
check_err((*ndb)->spent_at(view(b("n"))), kErrNotAHeight, "and so does the read path");
}
void a_set_that_cannot_be_read_does_not_open() {
std::printf("\na set that cannot be read at startup is not an empty set\n");
Unscannable dead;
check_err(NullifierDb::open(dead), "iterator gone", "the spent set refuses to open");
check_err(UtxoDb::open(dead), "iterator gone", "and so does the UTXO set");
}
void the_utxo_set_survives_a_restart() {
std::printf("\nthe UTXO set is rebuilt at boot, so a restart reaches the same verdict\n");
store::Memory db;
Utxo u;
u.tx_id = id_of(7);
u.commitment = b("commitment");
u.height = 3;
auto live = UtxoDb::open(db);
check_ok(live, "the set opens");
if (!live) return;
check_ok((*live)->add(u), "an output is created");
check_err((*live)->add(u), kErrUtxoExists, "and a duplicate commitment is refused");
check((*live)->count() == 1, "the count follows the set");
auto restarted = UtxoDb::open(db);
check_ok(restarted, "a restarted node opens the same records");
if (!restarted) return;
check_err((*restarted)->add(u), kErrUtxoExists,
"and reaches the SAME verdict a running one did");
check((*restarted)->count() == 1, "with the same count");
}
void the_utxo_body_comes_back_whole() {
std::printf("\nan output's body comes back whole, not just its key\n");
store::Memory db;
auto live = UtxoDb::open(db);
if (!live) {
check(false, "the set opens");
return;
}
for (std::uint8_t i = 0; i < 3; ++i) {
Utxo u;
u.tx_id = id_of(i);
u.output_index = i;
u.commitment = Bytes{'c', i};
u.ciphertext = Bytes{'n', i};
u.height = 5;
check_ok((*live)->add(u), "an output is created");
}
auto restarted = UtxoDb::open(db);
if (!restarted) {
check(false, "the set reopens");
return;
}
check((*restarted)->count() == 3, "the set is rebuilt from the records");
for (std::uint8_t i = 0; i < 3; ++i) {
auto got = (*restarted)->get(view(Bytes{'c', i}));
check_ok(got, "an output reads back");
if (got) {
check(got->height == 5, "at the height it was created");
check(got->ciphertext == (Bytes{'n', i}), "with its ciphertext, not just its key");
}
}
check_err((*restarted)->get(view(b("never"))), kErrNoUtxo,
"and a commitment nobody created is a miss");
}
void a_failed_utxo_read_is_not_an_absent_output() {
std::printf("\na failed UTXO read is not an absent output\n");
store::Memory real;
Broken broken(real);
auto udb = UtxoDb::open(broken);
check_ok(udb, "the set opens");
if (!udb) return;
auto r = (*udb)->get(view(b("c")));
check_err(r, "disk gone", "a read that failed says so");
check(r.has_value() == false && r.error().find(kErrNoUtxo) == std::string::npos,
"and is NOT reported as 'no such utxo'");
}
void the_root_is_a_pure_fold_that_only_finalize_advances() {
std::printf("\nthe state root: a pure fold, advanced only by finalize\n");
store::Memory db;
auto root = Root::open(db);
check_ok(root, "the root opens");
if (!root) return;
check_eq(hex_of((*root)->get()), hex_of(Id{}), "a fresh chain has the empty root");
const std::vector<Transaction> txs = {block_tx(1)};
const Id first = (*root)->after(txs);
const Id again = (*root)->after(txs);
check_eq(hex_of(first), hex_of(again),
"computing a root twice gives the same answer — the fold mutates nothing");
check_eq(hex_of((*root)->get()), hex_of(Id{}), "and the committed root has not moved");
check_ok((*root)->finalize(first), "finalize advances it");
check_eq(hex_of((*root)->get()), hex_of(first), "to the value it was given");
// The fold is over the committed root, so it moves with it.
check(hex_of((*root)->after(txs)) != hex_of(first),
"and the next fold builds on the new committed root");
auto reopened = Root::open(db);
check_ok(reopened, "the root reopens");
if (reopened) check_eq(hex_of((*reopened)->get()), hex_of(first), "at the committed value");
}
void an_unreadable_root_is_not_an_empty_root() {
std::printf("\nan unreadable root is not an empty root\n");
store::Memory real;
Broken broken(real);
check_err(Root::open(broken), "disk gone", "the root refuses to open");
// A root of the wrong length is not a root either.
store::Memory db;
check_ok(db.put(ByteView(reinterpret_cast<const std::uint8_t*>(kRootKey), 10),
view(Bytes(7, 0))),
"a seven-byte value sits under the root key");
check_err(Root::open(db), "want 32", "and it is refused by length");
}
void the_root_has_one_definition() {
std::printf("\nthe root has exactly ONE definition\n");
// committed ‖ every output commitment (tx order) ‖ every nullifier (tx order)
store::Memory db;
auto root = Root::open(db);
if (!root) {
check(false, "the root opens");
return;
}
const std::vector<Transaction> txs = {block_tx(1)};
Hasher h;
h.write(view((*root)->get()));
for (const auto& tx : txs)
for (const auto& o : tx.outputs) h.write(view(o.commitment));
for (const auto& tx : txs)
for (const auto& n : tx.nullifiers) h.write(view(n));
check_eq(hex_of((*root)->after(txs)), hex_of(h.sum()),
"and it is that fold, on every node, with or without an accelerator");
}
} // namespace
int main() {
the_spent_set_follows_the_records();
the_spent_set_is_permanent();
the_write_path_is_no_weaker_than_the_read_path();
a_failed_read_is_not_an_unspent_note();
a_read_does_not_write_the_set();
a_short_record_is_not_a_height();
a_set_that_cannot_be_read_does_not_open();
the_utxo_set_survives_a_restart();
the_utxo_body_comes_back_whole();
a_failed_utxo_read_is_not_an_absent_output();
the_root_is_a_pure_fold_that_only_finalize_advances();
an_unreadable_root_is_not_an_empty_root();
the_root_has_one_definition();
return report("state");
}