Files
chains/fhevm/test/records_test.cpp
Zach Kelling 4963cd1f3d Import the C++ D-Chain and F-Chain
These were in luxfi/compute, a private repo whose licence is the same
BSD-3-Clause as luxfi/node's. Nothing here needed protecting, and keeping
it private broke the public C++ chain builds that reference it.

Sibling paths lose a level: the chains sat three deep in compute and sit
one deep here, so what reached ~/work through ../../../../ now does it
through ../../../.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 21:35:12 -07:00

369 lines
16 KiB
C++

// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause-Eco
//
// records_test.cpp — what the chain persists, the derivations that name it, and
// the threshold arithmetic every decision on F reduces to.
#include "check.hpp"
#include "fixtures.hpp"
#include "lux/fhevm/records.hpp"
using namespace lux::fhevm;
using namespace lux::fhevm::test;
namespace {
void identifiers_are_deterministic() {
// Every id F derives comes from signed transaction fields alone, so two
// validators applying one block agree — and changing any input changes it.
Id d = digest_of("determinism");
Account a = new_test_key().addr;
Account b = new_test_key().addr;
check_eq(hex_of(derive_handle(d, kTestScheme)), hex_of(derive_handle(d, kTestScheme)),
"a handle is a function of its inputs");
check(derive_handle(d, kTestScheme) != derive_handle(d, "bfv-n13"), "the scheme binds");
check(derive_handle(d, kTestScheme) != derive_handle(digest_of("other"), kTestScheme),
"the digest binds");
Id h = derive_handle(d, kTestScheme);
check_eq(hex_of(derive_permit_id(h, a, b, 1, 0, 1)), hex_of(derive_permit_id(h, a, b, 1, 0, 1)),
"a permit id is a function of its inputs");
check(derive_permit_id(h, a, b, 1, 0, 1) != derive_permit_id(h, a, b, 1, 0, 2),
"a grantor may re-grant the same capability without colliding");
check(derive_permit_id(h, a, b, 1, 0, 1) != derive_permit_id(h, b, a, 1, 0, 1),
"and grantor and grantee are not interchangeable");
check(derive_permit_id(h, a, b, 1, 0, 1) != derive_permit_id(h, a, b, 2, 0, 1),
"the capability bits bind");
check(derive_permit_id(h, a, b, 1, 0, 1) != derive_permit_id(h, a, b, 1, 5, 1),
"and so does the expiry");
check_eq(hex_of(derive_request_id(h, a, 3)), hex_of(derive_request_id(h, a, 3)),
"a request id is a function of its inputs");
check(derive_request_id(h, a, 3) != derive_request_id(h, a, 4), "the nonce binds");
check(derive_request_id(h, a, 3) != derive_request_id(h, b, 3), "and the requester binds");
}
void the_committee_digest_is_semantic() {
// The value members attest describes the PROPOSAL, not the bytes one client
// happened to encode: it changes with every meaningful field and with the
// members themselves.
Committee c = new_committee(3);
Bytes pk = network_public_key();
Id base = committee_digest(1, 2, view(pk), c.members);
check_eq(hex_of(committee_digest(1, 2, view(pk), c.members)), hex_of(base),
"the digest is a function of the proposal");
check(committee_digest(2, 2, view(pk), c.members) != base, "the epoch binds");
check(committee_digest(1, 3, view(pk), c.members) != base, "the threshold binds");
Bytes other{'o', 't', 'h', 'e', 'r'};
check(committee_digest(1, 2, view(other), c.members) != base, "the network key binds");
std::vector<CommitteeMember> fewer(c.members.begin(), c.members.begin() + 2);
check(committee_digest(1, 2, view(pk), fewer) != base, "membership binds");
// Length-prefixing means a member's fields cannot be re-split across the
// boundary to forge a colliding digest.
std::vector<CommitteeMember> shifted = c.members;
shifted[0].public_key.insert(shifted[0].public_key.end(), c.members[1].public_key.begin(),
c.members[1].public_key.end());
check(committee_digest(1, 2, view(pk), shifted) != base,
"concatenated fields cannot be re-split to collide");
}
void tally_counts_distinct_members() {
// A threshold decision counts MEMBERS, not messages: one member repeating
// itself never raises a count, and votes for different values are counted
// apart.
Account a = new_test_key().addr;
Account b = new_test_key().addr;
Id x{1};
Id y{2};
std::vector<Attestation> as;
bool as_nil = true;
vote(as, as_nil, a, x);
check(!as_nil, "casting a vote makes the list non-nil, as append does in Go");
vote(as, as_nil, a, x);
check(as.size() == 1, "a repeated member holds one entry");
check_eq(std::uint64_t(tally(as, x)), 1, "and counts once");
vote(as, as_nil, b, x);
check_eq(std::uint64_t(tally(as, x)), 2, "two members are two votes");
check_eq(std::uint64_t(tally(as, y)), 0, "votes for another value do not count");
// Moving a vote moves it: the old value keeps nothing.
vote(as, as_nil, a, y);
check(as.size() == 2, "still one entry per member");
check_eq(std::uint64_t(tally(as, x)), 1, "the value it left keeps only the other member");
check_eq(std::uint64_t(tally(as, y)), 1, "and the value it moved to gains one");
// The count is over DISTINCT members whatever the list holds, so a record
// that somehow carried a duplicate could not inflate a threshold.
std::vector<Attestation> doubled{{a, x}, {a, x}};
check_eq(std::uint64_t(tally(doubled, x)), 1, "a duplicated entry counts once");
}
void membership_is_by_voting_identity() {
// A member's F-Chain account is derived from its PUBLIC signing key by
// exactly the derivation that authenticates a payer, so committee
// membership and payer identity cannot disagree.
Committee c = new_committee(3);
EpochRecord rec;
rec.committee = c.members;
for (std::size_t i = 0; i < c.keys.size(); ++i) {
check(rec.member_of(c.keys[i].addr),
"member " + std::to_string(i) + " is recognised by its payer address");
}
check(!rec.member_of(new_test_key().addr), "a stranger is not a member");
}
void canonical_order() {
Committee c = new_committee(3);
check(committee_order(c.members), "a committee arrives in ascending node-id order");
std::vector<CommitteeMember> shuffled{c.members[2], c.members[0], c.members[1]};
check(!committee_order(shuffled), "and any other order is not canonical");
check(committee_order({}), "the empty committee is trivially ordered");
}
void records_survive_their_own_round_trip() {
// Whatever the writer emits, the reader takes back — for every record, so
// the caches a node rebuilds on boot are the records it wrote.
Committee c = new_committee(2);
CiphertextRecord ct;
ct.handle = digest_of("h");
ct.owner = c.keys[0].addr;
ct.type = 9;
ct.level = -1;
ct.epoch = 3;
ct.registered_at = 1700;
ct.size = 4096;
ct.chain_id = test_chain_id();
ct.scheme = "ckks-n15";
ct.digest = digest_of("d");
CiphertextRecord ct_back;
std::string err;
check(unmarshal(marshal(ct), &ct_back, &err) && ct_back == ct,
"a ciphertext record round-trips");
PermitRecord pm;
pm.permit_id = digest_of("p");
pm.handle = ct.handle;
pm.grantee = c.keys[1].addr;
pm.grantor = c.keys[0].addr;
pm.operations = kPermitOpMask;
pm.expiry = 99;
pm.created_at = 1700;
pm.attestation = Bytes{1, 2, 3};
pm.chain_id = test_chain_id();
pm.status = std::string(kStatusRevoked);
PermitRecord pm_back;
check(unmarshal(marshal(pm), &pm_back, &err) && pm_back == pm,
"a permit record round-trips, its attestation included");
DecryptRecord dr;
dr.request_id = digest_of("r");
dr.ciphertext_handle = ct.handle;
dr.requester = c.keys[1].addr;
dr.callback[0] = 0xca;
dr.callback_selector = {1, 2, 3, 4};
dr.source_chain = test_chain_id();
dr.epoch = 3;
dr.nonce = 7;
dr.expiry = 1800;
dr.status = RequestStatus::Completed;
dr.created_at = 1700;
dr.completed_at = 1750;
dr.result_handle = digest_of("result");
dr.error = "none";
dr.permit_id = pm.permit_id;
vote(dr.attestations, dr.attestations_nil, c.keys[0].addr, dr.result_handle);
DecryptRecord dr_back;
check(unmarshal(marshal(dr), &dr_back, &err) && dr_back == dr,
"a decrypt record round-trips, attestations included");
EpochRecord ep;
ep.epoch = 3;
ep.start_time = 1700;
ep.end_time = 1800;
ep.committee = c.members;
ep.committee_nil = false;
ep.threshold = 2;
ep.public_key = network_public_key();
ep.public_key_nil = false;
ep.status = EpochStatus::Ended;
vote(ep.attestations, ep.attestations_nil, c.keys[1].addr, digest_of("next"));
EpochRecord ep_back;
bool ok = unmarshal(marshal(ep), &ep_back, &err);
check(ok && ep_back.epoch == ep.epoch && ep_back.end_time == ep.end_time &&
ep_back.committee == ep.committee && ep_back.public_key == ep.public_key &&
ep_back.status == ep.status && ep_back.attestations == ep.attestations,
"an epoch record round-trips, committee and votes included");
}
void omitempty_is_gos_omitempty() {
// Go's `omitempty` drops an empty SLICE and a zero number — and never drops
// an ARRAY, which has a fixed length and is therefore never empty. A port
// that dropped result_handle when it was all zeros would write a different
// record from the Go chain for every pending request there has ever been.
PermitRecord pm;
pm.status = std::string(kStatusActive);
std::string s = marshal(pm);
check(s.find("\"attestation\"") == std::string::npos,
"an empty attestation slice is omitted");
DecryptRecord dr;
check(marshal(dr).find("\"completed_at\"") == std::string::npos,
"a zero completed_at is omitted");
check(marshal(dr).find("\"error\"") == std::string::npos, "and an empty error is omitted");
check(marshal(dr).find("\"result_handle\":[0,0,0") != std::string::npos,
"but an all-zero result handle is written, because a Go array is never empty");
dr.completed_at = 5;
dr.error = "why";
check(marshal(dr).find("\"completed_at\":5") != std::string::npos,
"and both appear once they are set");
check(marshal(dr).find("\"error\":\"why\"") != std::string::npos, "including the error");
EpochRecord ep;
check(marshal(ep).find("\"end_time\"") == std::string::npos,
"an epoch that has not ended omits its end time");
}
void a_record_is_read_the_way_unmarshal_reads_one() {
// NOT the rule payloads follow, and that is the point. The reference reads
// a payload through a Decoder with DisallowUnknownFields and a RECORD
// through plain json.Unmarshal, which are two different acceptance sets.
// Both were asked of Go directly:
//
// record with an unknown member Unmarshal: no error (ignored)
// record with a trailing '}' Unmarshal: error (any tail is)
//
// Reading rows under the payload rules SKIPPED a row the reference loads,
// and two nodes that load different rows answer the same query differently.
CiphertextRecord back;
std::string err;
std::string doc = marshal(CiphertextRecord{});
doc.insert(doc.size() - 1, ",\"body\":\"AAAA\"");
check(unmarshal(doc, &back, &err), "a member the schema lacks is ignored, as Unmarshal does");
check(!unmarshal("{not json", &back, &err), "one that does not parse is still refused");
check(!unmarshal(marshal(CiphertextRecord{}) + "{}", &back, &err),
"and so is one with a second document after it");
// Unmarshal scans the WHOLE document, so a stray bracket is trailing
// content here even though Decoder.More says it is not in a payload.
check(!unmarshal(marshal(CiphertextRecord{}) + "}", &back, &err),
"including a stray closing brace, which a payload is allowed");
check(unmarshal(marshal(CiphertextRecord{}) + " \n", &back, &err),
"but whitespace is not content");
// The same split reaches a nested committee member: refused inside an
// epoch PROPOSAL, ignored inside an epoch RECORD.
EpochRecord ep;
std::string erow =
R"({"epoch":1,"start_time":0,"committee":[{"node_id":"","public_key":null,"weight":1,"index":0,"rank":9}],"threshold":1,"public_key":null,"status":0,"attestations":null})";
check(unmarshal(erow, &ep, &err) && ep.committee.size() == 1,
"an unknown member inside a committee member is ignored in a record");
}
void the_persisted_schema_is_pinned() {
// The C++ analogue of the Go package's reflective schema pin. Go walks the
// struct; here the marshaller IS the schema — every field a record persists
// appears as a key, in order — so the key list is pinned instead, and a
// field added, removed or renamed fails until someone writes it down.
//
// What this pin catches is what the Go one catches: a change to what F
// STORES. A field added to a record and not marshalled would slip past it,
// which is why sizeof is pinned beside it — the two together mean a silent
// addition cannot happen either way.
auto keys = [](const std::string& doc) {
std::string out;
std::size_t i = 0;
int depth = 0;
bool in_string = false;
std::string current;
// A flat scan is enough: the only nested objects a record holds are its
// committee members and its attestations, and those are pinned by their
// own records below.
for (; i < doc.size(); ++i) {
char c = doc[i];
if (in_string) {
if (c == '\\') {
++i;
continue;
}
if (c == '"') {
in_string = false;
if (depth == 1 && i + 1 < doc.size() && doc[i + 1] == ':') {
if (!out.empty()) out += ",";
out += current;
}
current.clear();
continue;
}
current.push_back(c);
continue;
}
if (c == '"') {
in_string = true;
continue;
}
if (c == '{' || c == '[') ++depth;
if (c == '}' || c == ']') --depth;
}
return out;
};
// A record with every optional field SET, so the pin covers the whole
// schema rather than only the fields a zero value happens to emit.
PermitRecord pm;
pm.attestation = Bytes{1};
DecryptRecord dr;
dr.completed_at = 1;
dr.error = "e";
EpochRecord ep;
ep.end_time = 1;
check_eq(keys(marshal(CiphertextRecord{})),
"handle,owner,type,level,epoch,registered_at,size,chain_id,scheme,digest",
"CiphertextRecord stores what it was written down as storing");
check_eq(keys(marshal(pm)),
"permit_id,handle,grantee,grantor,operations,expiry,created_at,attestation,chain_id,"
"status",
"PermitRecord stores what it was written down as storing");
check_eq(keys(marshal(dr)),
"request_id,ciphertext_handle,requester,callback,callback_selector,source_chain,epoch,"
"nonce,expiry,status,created_at,completed_at,result_handle,error,permitId,attestations",
"DecryptRecord stores what it was written down as storing");
check_eq(keys(marshal(ep)),
"epoch,start_time,end_time,committee,threshold,public_key,status,attestations",
"EpochRecord stores what it was written down as storing");
// And the sizes, which catch a field that was added and NOT marshalled —
// the one way the key list above could be fooled. If one of these fails,
// the question to answer is the one the Go pin exists to force: should F be
// storing that?
check_eq(sizeof(CiphertextRecord), std::size_t(184), "CiphertextRecord holds no more than it declares");
check_eq(sizeof(PermitRecord), std::size_t(216), "PermitRecord holds no more than it declares");
check_eq(sizeof(DecryptRecord), std::size_t(320), "DecryptRecord holds no more than it declares");
check_eq(sizeof(EpochRecord), std::size_t(120), "EpochRecord holds no more than it declares");
}
} // namespace
int main() {
std::printf("fhevm — the records, the derivations, and the threshold arithmetic\n\n");
identifiers_are_deterministic();
the_committee_digest_is_semantic();
tally_counts_distinct_members();
membership_is_by_voting_identity();
canonical_order();
records_survive_their_own_round_trip();
omitempty_is_gos_omitempty();
a_record_is_read_the_way_unmarshal_reads_one();
the_persisted_schema_is_pinned();
return report("records");
}