Files
zeekay b0b3388568 The P-chain's last hand-written wire is a schema too
Its transactions were the named job and are done; what was left was
everything else the chain writes. Three more schemas, and the offsets are out
of the source entirely.

chains/schema/warp.zap — the warp envelope, the payloads it carries, and the
conversion preimage an L1's id is the hash of. Eleven structs across warp.rs
and warpmsg.rs, and the differential still reproduces the warp message Go
wrote, byte for byte.

chains/schema/state.zap — what a P node writes to its own disk. Separate from
the wire on purpose: a change to a wire schema is a fork, a change to this one
is a migration, and one file per consequence is how you can tell which you
just made.

The rest went into pchain.zap: the fx primitives the chain spends when they
travel alone rather than inside a transaction, the standalone owner key a
locked balance is tallied under, and a genesis blob.

TWO THINGS THE SCHEMAS HAD TO SAY OUT LOUD, and would have moved bytes
otherwise. The stored Staker reserves sixteen bytes past its last field — it
always has, and a schema that stopped at the public key would write a shorter
record than every record already on disk, so those bytes are named Reserved
for the same reason Out and In name their Pad. And the P-chain's standalone
owner key is NOT the fx owner shape: the same two fields in the other order,
because one is a key and one is a wire. Naming them apart is what keeps a
reader from finding a threshold where a locktime is.

TransferOutput and Utxo are now declared in two schemas — xchain.zap and
pchain.zap — because the X-chain makes them and the P-chain spends them. That
is two statements of one format and it is the wrong number. The right number
needs an import across schemas that zapgen does not have, and cannot get
cheaply: xchain.zap's own envelope holds list<ptr<TransferableOut>>, so the
reference cannot be moved away from the file that declares it. Until then
chains/rust/zap/tests/one_format.rs reads both schemas and fails the day the
two declarations drift — which is the day before a P node and an X node stop
agreeing about a UTXO. Its negative control was run: change one offset in one
file and it fails, naming the struct.

WHAT IS LEFT. Nothing. No Rust chain hand-writes a wire shape any more. The
only remaining calls to the builder outside a generated module are two tests
crafting deliberate buffers to feed a reader, which is where a hand-built
object belongs, and two generic list helpers that take a builder and know
nothing about any shape.

424 platformvm tests including the warp and chain differentials, the corpus
byte test and the hostile-bytes fuzz; `make wire` verified a fixed point over
all seven schemas.
2026-09-06 13:54:27 -07:00

73 lines
3.0 KiB
Rust

// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
//! The shapes two chains both speak are declared the same way in both.
//!
//! An unspent output is made by the X-chain and spent by the P-chain, so both
//! schemas state it — the same fields at the same offsets. That is two
//! statements of one format, and two statements of one format are how two
//! chains come to disagree about what a UTXO is while each stays consistent
//! with itself.
//!
//! It is two because `xchain.zap` names `TransferableOut` from its own
//! envelope, so lifting the shared primitives into a third schema needs an
//! import across schemas that zapgen does not have. Until it does, this test
//! is the join: the day the two drift is the day it fails, which is the day
//! before a P node and an X node stop agreeing about a UTXO.
//!
//! It reads the schemas as text on purpose. Comparing the EMITTED constants
//! would need one crate to depend on both chains, and the schema is the thing
//! that is supposed to be one — so the schema is what is compared.
use std::path::PathBuf;
/// The lines of one struct's body, whitespace-normalized.
fn body(schema: &str, name: &str) -> Vec<String> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../schema")
.join(schema);
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("{}: {e}", path.display()));
let open = format!("struct {name} {{");
let start = text
.find(&open)
.unwrap_or_else(|| panic!("{schema} does not declare {name}"))
+ open.len();
let end = start
+ text[start..]
.find("\n}")
.unwrap_or_else(|| panic!("{schema}: {name} is not terminated"));
text[start..end]
.lines()
.map(|l| l.split('#').next().unwrap_or("").split_whitespace().collect::<Vec<_>>().join(" "))
.filter(|l| !l.is_empty())
.collect()
}
#[test]
fn the_shapes_both_chains_speak_are_one_shape() {
// Every struct declared in more than one schema, and what it is.
for (name, what) in [
("TransferOutput", "how much, and whose"),
("Utxo", "an output that has not been spent"),
] {
let x = body("xchain.zap", name);
let p = body("pchain.zap", name);
assert!(!x.is_empty(), "{name} has no fields in xchain.zap");
assert_eq!(
x, p,
"{name} — {what} — is declared differently in the two schemas. \
A P node and an X node would read the same bytes as different values."
);
}
}
#[test]
fn a_struct_only_one_chain_declares_is_not_compared() {
// The guard on the test above: it would pass vacuously if `body` answered
// an empty list for a name neither schema declares, so `body` panics
// instead, and this says so.
let caught = std::panic::catch_unwind(|| body("pchain.zap", "NotAStructAnybodyDeclares"));
assert!(caught.is_err(), "a name no schema declares was not refused");
}