gas held a transaction gas meter -- State, with advance_time and consume -- that nothing called, because FlatFees is what runs. Unreachable code that looks like a mechanism is a claim the chain does not honour, so it is gone. What is left in gas is reached, and now provably: l1 prices the LP-77 continuous validator fee with the curve and warp uses the wide integer. The module is pub(crate) and denies dead code, so an unused item is a build error. Putting the meter back is a failing build, which is the only kind of proof that survives the next person. The notes were wrong in both directions and are corrected. LLM.md said "gas holds the arithmetic; nothing calls it" while l1 called it on five lines, and gas.rs said the opposite of LLM.md in its own header. The fee difference itself is stated plainly rather than filed as a gap, because it is a divergence: Go's PickFeeCalculator returns the dynamic calculator on every verify and every build and calls the static schedule unreachable, so for the same transaction the two chains answer different amounts. It is written on executor::Fees, the seam it happens at and the seam it would be fixed at -- a dynamic calculator arrives as another implementation of that trait and no caller changes. It is not ported here because it is Go's per-kind complexity tables, arithmetic that has to be checked against Go vector by vector; an unchecked port of it would not close the divergence, it would move it somewhere harder to see.
14 KiB
The P-Chain, in Rust
The chain that decides who validates: the validator sets, the stake behind
them, the clock those sets change on, and the networks they secure. Ported
from the Go reference at ~/work/lux/node/vms/platformvm, against the VM seam
~/work/lux-rs/node/src/vm.rs.
A permissionless validator set with no admin key and no allowlist is what
makes this a public good rather than a product. Every check in executor
exists to make entry honest — enough stake, real stake, for long enough, once,
and signed for by whoever owns it. None exists to make entry selective.
cd chains/rust/platformvm
PATH=~/.cargo/bin:$PATH cargo test # 419 tests
PATH=~/.cargo/bin:$PATH cargo clippy --all-targets
One build hazard, and it is the machine's, not the code's. This crate's
dependency graph reaches lux-rs/node, which is large, and ~/.cargo/config.toml
routes every rustc through a shared compile cache. Under load that cache has
handed back artifacts that do not match their crate — the symptom is
E0463: can't find crate for alloy_rlp with the rlib sitting right there on
disk. CARGO_BUILD_RUSTC_WRAPPER="" cargo … bypasses it and builds
deterministically; a stale-looking failure is worth one retry that way before
it is believed.
The claim, and how it is checked
Two implementations of a chain agree about a transaction only if they agree about its bytes, because the bytes are what a signature covers and what an id is the hash of. So "same wire" is checked against Go, not against itself.
tests/vectors/gen.go builds each value with the Go P-Chain's own
constructors and prints the hex; tests/wire_vectors.rs holds those lines
verbatim and asserts both directions — the bytes this port writes are
byte-identical, and Go's bytes read back as the same value. Regenerate with:
mkdir -p /tmp/pvmvec && cp tests/vectors/gen.go /tmp/pvmvec/main.go
cd /tmp/pvmvec && cat > go.mod <<'EOF'
module pvmvec
go 1.26.5
require github.com/luxfi/node v0.0.0
replace github.com/luxfi/node => /home/z/work/lux/node
EOF
go mod tidy && go run .
33 vectors are checked this way: every transaction kind that carries an envelope, the signed form with three credentials, all four block kinds (including the golden abort block Go pins byte for byte), the unspent-output envelope locked and unlocked, a whole genesis blob, a signature Go made — which this port recovers to the same address Go derives from the key that made it — and a proof of possession Go made, which verifies here under the same domain tag.
Beyond the bytes, these Go test tables are ported case for case, and each one
is named in the Rust test that carries it: TestVerifySpendUTXOs (what value
may be created, locked and burned), TestStakerDiffIterator and
TestMutableStakerIterator (the order weight changes are read in),
TestStakerLess and the TestBaseStakers* set,
TestAddPermissionlessValidatorTxSyntacticVerify and its delegator sibling,
TestUnsignedCreateChainTxVerify, TestTransformChainTxSyntacticVerify, the
six TestPriorityIs*, TestParseCredsBuf*, TestRewards/TestSplit, the
whole stakingparams suite, TestGoldenAbortBlock, and
TestHIGH2_SlashAmount*.
tests/hostile_bytes.rs is the other half of the wire claim: every entry
point that takes bytes takes them from someone who chose them, so every honest
buffer is truncated at every length, mutated a byte at a time, given a length
nothing backs and a root pointing anywhere, and thrown at every reader. A
reader that can be made to panic on a chosen buffer is a way to stop a node
from a distance. The sweep reaches past the front door — most mutations still
parse, so the field accessors are exercised on hostile content rather than
refused at the header.
What is here
| module | what it is |
|---|---|
zap |
the arena wire — a fixed payload plus relative pointers, read by indexing. Port of luxfi/zap v1.2.7. |
ids, components |
names, and the values an unspent output holds, plus the envelope one travels in. |
txs |
all twenty transaction kinds: their bytes, and the checks that need no state. |
sign |
who authorised a spend — secp256k1 recovery and the P-Chain address. |
signer |
how a validator proves the BLS key it signs with is its own. |
security |
on what terms a network's set is admitted. |
stakingparams |
the terms the set is admitted on, and the keyless rule that decides whether a change to them is admissible. |
reward |
what a staker is paid. |
flow |
value is not created, and the spend is authorised. |
state |
what the chain believes. |
store |
where that is written down: a byte map whose batch is all there or none of it is. |
persist |
what is written down, as records, and how it is read back. |
validators |
who validates, now and at any height already passed. |
uptime |
how much of its term a validator was reachable for — the node's measurement, not the chain's. |
executor |
what a transaction does to that belief. |
block |
the four things a block can be. |
genesis |
how the chain is born. |
vm |
the chain, as the node holds it. |
The seam
PlatformVm implements lux-rs/node's own Vm — lux_node::vm::Vm, reached
through a path dependency on that crate and re-exported from vm for
convenience. Nothing of the seam is declared here. A restated trait of the same
shape compiles and satisfies nothing, because the node's chain map holds
Box<dyn lux_node::vm::Vm> and only the host's own trait coerces into it;
the_seam_is_the_hosts_own_declaration builds that box by its full path so the
claim is checked rather than asserted. Id is lux_consensus::finality::Id on
both sides of the join, so there is one id type in a node's chain map.
The dependency runs chain → node, not node → chain: a chain naming the seam it plugs into cannot also be named by it. Registration therefore belongs to whatever holds both, which is this repo.
PlatformVm::open is the one way to start a chain that writes itself down: it
resumes what the store holds, or writes the chain's birth if it holds nothing.
Two entry points would be two answers to which chain this is, and the wrong one
is a node that silently rejoins at height zero. What comes back is checked, not
trusted.
PlatformVm::from_genesis is the whole birth of a network in one call: hand it
the bytes a network was published as and it holds the first block, the first
validator set and the first balance, having trusted nobody. The first block is
a commit block naming the genesis bytes as its parent at GENESIS_TIME
(2020-12-05T05:00:00Z) — the shape Go's state.init builds, so both
implementations name the first block identically.
The differential
conformance/chain-differential hands one corpus of Go-built bytes to the Go,
Rust and C++ P-chains and compares every answer with every other. src/bin/ conformance.rs is this chain's voice in it.
On all 39 P-chain vectors this chain now answers exactly what Go answers —
parse, kind, id, syntactic verdict and execution verdict. The two P-chain rows
the run still reports are C++'s, and Go sides with this chain on both:
P_EDGE_EMPTY_NODE_ID, where C++ refuses AddValidator by kind before it looks
at the validator, and P_SEAM_BLOCK_REJECT, where the C++ host seam has no
reject at all.
That is what closed the fork. It used to be the other way round: C++ executed the sovereign-L1 plane and this chain refused it by name, and the run named six vectors for it.
What is deliberately absent
Nothing here is a stub. Where a thing is not held, the path that would need it refuses by name with its own error, rather than succeeding against nothing.
TransformChainTx. Refused — which is fidelity, not a gap: Go refuses it permanently too (errTransformChainTxNotPermitted). A network's terms are therefore whatever it was born with, and a network born without them refuses by name rather than borrowing the primary network's.- Dynamic transaction fees. This is a divergence, not just a gap, and it is
worth stating as one.
FlatFeesis Go's static schedule and it is what this chain charges. Go's live chain charges by gas instead —PickFeeCalculatorreturns the dynamic calculator on every verify and every build, and calls the static schedule "unreachable" — so the two answer different amounts for the same transaction. Fixing it means anotherexecutor::Feesimplementation over Go's per-kind complexity tables, checked against Go vector by vector; the seam is already the right shape for it andexecutor::Feessays so. Thegasmodule holds the price curve, which is called —l1prices the LP-77 continuous validator fee with it, andwarpuses its wide integer. It ispub(crate)and denies dead code, so nothing can sit in it unused. - Persisting a node's measurements.
uptime::Trackeris ported whole, with Go's whole test suite, anduptime::Ledgerkeeps its measurements in memory. A node that wants them to survive a restart writes its ownuptime::Recordover whatever it already persists — which is right, because these numbers are not agreed and must not be in the state that is. - The node's edges. Mempool policy, block-building heuristics, gossip, the
metrics surface, and the full JSON-RPC service.
callanswers five read-only methods (getHeight,getTimestamp,getCurrentSupply,getCurrentValidators,getValidatorsAt). - Legacy scheduled stakers.
AddValidatorTxandAddDelegatorTxare read off the wire and refused by the executor — again matching Go, where the scheduled-staker flow has no role now that stakers enter immediately.
The two halves a node fills
Neither is a stub and neither has a default that pretends. Both are traits the node implements, because both are things the chain cannot know by itself.
executor::Uptime— how much of its term a validator was reachable for. Two honest nodes measure differently, which is why the reward is a proposal.executor::Atomic— what another chain has already handed over. An import spends value made somewhere else, so only the shared half both chains write to can say the export happened.NoImportsis the honest answer for a node with no such half: every import is refused for want of what it names, exactly as Go answers from an empty shared memory. Removing what an import consumed from the shared half is the node's, on accept — Go splits it the same way, returningAtomicRequestsfor the node to apply.
Things worth knowing before changing anything
- A number in a wire layout is consensus. The offsets in
txs,block,genesisandcomponentsare the wire. Changing one silently reinterprets every transaction already on disk. A slot that stops naming a kind stays a hole rather than letting the ones after it slide down. - The unsigned bytes are a byte-prefix of the signed bytes. Both halves are self-delimiting zap messages, so the split is found by reading the first message's length rather than by re-encoding anything.
- An id is the hash of the bytes that travelled. Genesis stores embedded transactions as their own signed bytes for exactly this reason.
verify_credentialsis not optional. It used to take the recovery function as an argument and nothing in the executor supplied it, so a transaction could execute with no signature checked at all. It now callssign::recoverdirectly: a check that can be handed a different answer is a check that can be handed one that always agrees. Both it andverify_permission— the one-owner check the authorisation paths call, and where the recovery actually happens — have their types pinned by aconst _inflow, so adding a parameter of any kind stops the build rather than waiting for someone to notice. Every path that takes a credential has a test that forges the last one with a real stranger's signature rather than removing it, so each exercises a genuine recovery landing on the wrong address — which is the failure the original missing-recoverer bug could not have produced, and the reason the tests that existed then all passed.- Where a transaction is addressed is part of the bytes being well formed.
syntactic_verifytakes atxs::Chain— network, blockchain, native asset — and refuses a mismatch before it looks at anything else, because a transaction that is well formed on two networks is one signature that spends on both. - A warp message is only bound to its source by the aggregate. The source
chain and the sender's address are written inside the message, so anyone
can put any pair there; what nobody can write is a quorum of that chain's
validators over the bytes.
Vm::verifychecks that first, over every transaction a block carries — the decision ones and, on a proposal block, the one the chain emitted about itself — because a warp message is an assertion about another chain no matter who put it in the block. Without itverify_l1_conversionwould be comparing a claim with itself, and anyone could re-weight or de-register any L1's validators. - The set at a past height is not the set now. A signature made at height
H is checked long after H.
validators::Historyrecords what each accepted height changed and rewinds;PlatformVm::validator_set_atanswers, and refuses a height the chain has not reached rather than extrapolating one. - Reward arithmetic reproduces a defect on purpose.
cap - supplywraps past the cap and the live chain is past it. Seereward. - Reward arithmetic is exact. Go divides with
big.Intat the end;rewardreproduces that with the identityfloor(floor(x/a)/b) == floor(x/(a·b))so every divisor fits au64, and the Go test table is reproduced value for value. - Money is not votable.
stakingparamsgoverns admission thresholds, durations, the delegation-fee floor and the uptime requirement — and never the consumption rates, minting period, supply cap or fee split. A test reads this file's own source to hold that line.
The one dependency worth explaining
lux-consensus is named for finality::Id alone — the host's own id type, so
a chain here plugs into lux-rs/node's Vm with no conversion at the join.
blst, k256, sha2 and ripemd are the curves and hashes the Go node uses;
a second implementation of any of them would be a second answer to who signed.
serde_json is the shape the seam's call speaks in — the node's RPC
boundary, not a serialization of chain state. ZAP is the only thing chain
values are written in.