Commit Graph
100 Commits
Author SHA1 Message Date
zeekay a93f6e3dec build the VMs without the daemon in their closure
A plugin is its own binary; the daemon that loads it has no business being
linked into it. Twelve VMs reached into github.com/luxfi/node for four things,
and each one now programs against a home that is actually its own:

- fee admission (Policy, FlatPolicy, NoUserTxPolicy, Validate) joins the
  settlement half already in chains/fee. One fee package, and the nodefee alias
  that marked the split is gone.
- the cross-chain artifact types move to chains/artifacts.
- the factory the daemon registers is luxfi/vm/manager.Factory, and the version
  types are luxfi/version. Both are ordinary modules the daemon reads too, so a
  VM imports them directly rather than through anything of ours.

The GPU switch is deleted, not moved. It was a global set during daemon startup
that a separate process could never observe, so every plugin read its default;
FHEOptions.Backend is the switch that was really there.

go list -deps names no luxfi/node for aivm, bridgevm, dexvm, evm, fhevm,
graphvm, identityvm, keyvm, mpcvm, quantumvm, schain or zkvm, and the built
plugin binaries carry no such string. oraclevm and relayvm still do: they are
re-export shims over luxfi/oracle and luxfi/relay, which build their fee
policies upstream. Their tests keep naming the daemon's fee package because
that is the type their value has.
2026-09-03 21:10:40 -07:00
zeekayandHanzo Dev 9a568c2bbb feat(fhevm): add plugin entrypoint for binary plugin build
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-03 13:21:35 -07:00
zeekayandHanzo Dev 0d9fec309a chore: symlink AGENTS.md + GEMINI.md + CLAUDE.md -> LLM.md
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 13:42:24 -07:00
zeekayandHanzo Dev 17bdadba30 chain routes: /v1/bc -> /v1/chain
luxd serves chain routes under /v1/chain, and the node, cli and sdk releases
already ship it. The deployed gateways still answer the old prefix, so remote
reads break until they are redeployed; the estate moves together rather than
sitting half converted.

Lines that record an observation - a probe, a status code, a past release -
keep the prefix they were written against, as do URLs on hosts that are not
ours. Avalanche endpoints move to /ext/bc, which is the path that host serves.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 08:19:10 -07:00
zeekay 4aa7898664 Include string.h once 2026-09-01 02:38:03 -07:00
zeekay b00b12408a Ask for the plugin directory once 2026-09-01 02:10:25 -07:00
zeekay c49dae1bb9 Close the library mpcvm just refused to trust 2026-09-01 02:03:02 -07:00
zeekay 56e81b4132 Retract v1.7.37, which supersedes a release it does not contain 2026-09-01 01:55:11 -07:00
zeekay b1b67ee46c Join the v1.7.37 line, whose one unique fix is already here
v1.7.37 branched from 964b6b0, which is BEFORE v1.7.36, so it carries a higher
number than a release it does not contain. Three of its four commits are
accounted for: the hero-card change is here by patch-id as 60b7b3e, the codec
fix as af7b1dc with internal/codec byte-identical, and the GPU plugin identity
work is cherry-picked in 114440f — the one thing on that line no other line
had.

The tree is not taken. Its go.mod retracts v1.7.36 for serving zkvm's routes
flat; this line serves them the same way, so taking that directive would
retract one release for what its successors also do. The route shape is left
for whoever owns it.
2026-09-01 01:53:40 -07:00
zeekay a7f17a965f Join the parallel line into this history
luxfi/chains was developed twice from the same starting point: two roots with
the same subject and date, no commit in common, 41 commits on one side and 288
on the other. This records that the two are one project again.

The tree is this line's. Eighteen of the parallel line's commits are already
here by patch-id and seventeen more by an exact-subject twin the re-creation
re-hashed. What remains unique to it is one comment-only commit, applied in
f849e8f.

Its tree is not taken. The files it holds and this line does not were deleted
here on purpose and with reasons: the D-Chain proxy that answered nothing
(e4d04be, which added an AST check against its return), the vestigial BLS
custody subsystem (b0cc6d8), thresholdvm after the rename to mpcvm (51a7797).
Merging its tree would undo four deliberate removals and trip the check the
first one installed.
2026-09-01 01:53:01 -07:00
zeekayandHanzo Dev d7199487d2 Build the stub plugins the loader is now allowed to trust
The three GPU suites compiled a library exporting launcher names and nothing
else, which is exactly what the identity check refuses, so eighteen tests
failed against a loader that had started doing its job. The stubs now export
lux_gpu_backend_init answering with this build's ABI version and vtable size,
compiled against the real header so an ABI bump moves them with it.

Three assertions pinned the behaviour that was removed — aivm expecting the
bare filename, mpcvm the bare leaves, bridgevm the working directory — and now
pin its absence.

TestALibraryThatOnlyExportsTheNames is new in each package: it plants the
unidentified build, watches the loader refuse it, then opens the identified
build from the same place so the refusal is the identity check rather than a
stub that never worked.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 01:52:39 -07:00
zeekay c0d4022c71 Cite the specs by the numbers they carry
LP-134 was renumbered LP-1340 (lp-1340-lux-chain-topology); 21 citations
across 16 files still named the old number, which resolves to nothing.

Corona rests on Module-LWE, not Ring-LWE — LP-4805 says so in three places
and b17e086 already corrected one file, leaving evm/main.go and
schain/SECURITY_review.md claiming the wrong hardness assumption. The
two-family argument in LP-4900 depends on which lattice problem this is.

P3Q at 0x012205 is kind-byte signature dispatch; STARK/FRI is starkfri at
0x012220 (LP-7050). Naming P3Q for a scheme at another slot describes a
precompile that is not the one being imported.

Carries the substance of 2f22fa2, the one commit the parallel line held that
this history did not.
2026-09-01 01:40:34 -07:00
zeekayandHanzo Dev 6aa5b77a44 Identify a GPU plugin before trusting it, and load only from a named place
A GPU plugin replaces a VM's state transitions wholesale. Three of the four
seams reached one by dlsym'ing launcher names out of a library nothing had
identified, and all four would fall back to a bare filename — which makes the
dynamic loader's default search path, and in bridgevm's case the working
directory of whoever started the node, the thing that decides which code
computes consensus state.

A library exporting the five bridgevm launcher names and nothing else,
compiled from twenty lines of C that return 0:

  before, LUX_GPU_PLUGIN_DIR=/tmp/rogue   AutoBackend = hip
  before, no env, cwd holds the .so       AutoBackend = hip
  after,  either                          AutoBackend = none

The plugin header says a loader must check abi_version and vtbl_size. aivm,
bridgevm and mpcvm now do so before resolving a single launcher: a name was
the only thing being matched, and a name is not a contract.

Bare filenames and the CWD entry are gone. What is left is LUX_GPU_PLUGIN_DIR
and LUXCPP_PREFIX — places an operator named. Unset means no plugin, which is
the configuration every box without a GPU is already in.

Cherry-picked from 634df5e, whose quantumvm hunk is dropped: that seam's file
was deleted in 409d0e4 and the chain no longer loads a plugin.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 01:36:45 -07:00
zeekay 1eb35500ef Merge the tip and Seed fixes into the current line
chain/store.go gains ErrNotOnTip, so Accept re-checks that the block still
extends the accepted tip under the same lock that commits it, and Seed records
the tip it wrote. Open already refuses to read any error as a fresh chain;
a Seed that left no tip was the remaining way to look fresh after writing.

Carries the zkvm, identityvm and graphvm work the fixes were built on.
2026-09-01 01:32:34 -07:00
zeekayandHanzo Dev a5edffeba2 Restore the chain prefix the running nodes answer
The rename to /v1/chain is real in luxd source, but the deployed fleet has not
taken it yet: probed just now, api.lux.network, api.lux-test.network,
api.zoo.network and api.hanzo.network each answer 200 on /v1/bc/C/rpc and 404 on
/v1/chain/C/rpc. Composing /v1/chain against those hosts reaches nothing, which
is the failure the rename set out to prevent.

The move belongs with the node redeploy, not ahead of it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-30 21:21:32 -07:00
zeekayandHanzo Dev 6c2b79d070 chain routes: /v1/bc -> /v1/chain
luxd serves chain routes under /v1/chain; the /v1/bc prefix it replaced is
gone, so anything composing it reaches nothing. Lines that record an
observation - a probe, a status code, a past release - keep the prefix they
were written against, as do URLs on hosts that are not ours.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-30 20:42:54 -07:00
zeekay cce073153d docs: chain routes are /v1/chain, health probes are /v1/health/ops
luxd serves chain APIs under one segment from constants.ChainAliasPrefix
(constants v1.6.4, node v1.36.181), and the health service moved onto typed
ops. Published addresses now match what a node answers on:

  /v1/bc/<alias>/...    -> /v1/chain/<alias>/...
  /ext/bc/<alias>/...   -> /v1/chain/<alias>/...
  /v1/health            -> /v1/health/ops/health     (full report)
  /v1/health/liveness   -> /v1/health/ops/liveness
  /v1/health/readiness  -> /v1/health/ops/readiness

Observations keep their original addresses: a dated note or a recorded status
code is a fact about when it was written, not an instruction to follow.
2026-08-30 17:31:37 -07:00
zeekay 51430e25c1 Name Zen in the hero card's font stack
The social card asked for Inter first. That is not a face this estate
licenses, ships or ever drew, so a renderer with Inter installed painted
the wrong face and one without it silently fell through to system-ui.
Zen leads now; the same tail follows it.
2026-08-30 15:47:44 -07:00
zeekayandHanzo Dev b11515d169 chains: retract v1.7.36, whose zkvm routes answer 404
v1.7.36 serves the zkvm handlers one per flat route, so a caller of /rpc,
/privacy or /proof receives 404. Node's TestChainHandlerServesItsOwnPaths
names /rpc/getStatus and fails against it, and passes against v1.7.33,
against v1.7.35 and against this line.

The tag stays where it is. retract says it was published in error without
rewriting history.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-30 15:16:24 -07:00
zeekayandHanzo Dev 0ac784c33a codec, quantumvm: a lower-case name on the wire reaches an exported Go method
quantumvm registered node's JSON-RPC codec — the one that raises the initial
of the function named after the dot. That codec is now internal/codec, so the
spelling quantumvm serves, quantumvm.getBlock for Service.GetBlock, is decided
in this module.

The spelling is unchanged: an initial that is already upper-case is refused,
and an argument that will not read reports one message. identityvm, keyvm and
bridgevm register gorilla's plain codec and keep it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-30 14:54:14 -07:00
zeekayandHanzo Dev 8f0ec4b5fc codec, quantumvm: a lower-case name on the wire reaches an exported Go method
quantumvm registered node's JSON-RPC codec — the one that raises the initial
of the function named after the dot. That codec is now internal/codec, so the
spelling quantumvm serves, quantumvm.getBlock for Service.GetBlock, is decided
in this module.

The spelling is unchanged: an initial that is already upper-case is refused,
and an argument that will not read reports one message. identityvm, keyvm and
bridgevm register gorilla's plain codec and keep it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-30 14:45:34 -07:00
zeekay 3abc34922f gpu: identify a plugin before trusting it, and load only from a named place
A GPU plugin replaces a VM's state transitions wholesale. Three of the four
seams reached one by dlsym'ing launcher names out of a library nothing had
identified, and all four would fall back to a bare filename — which makes the
dynamic loader's default search path, and in bridgevm's case the working
directory of whoever started the node, the thing that decides which code
computes consensus state.

That is not theoretical. A library exporting the five bridgevm launcher names
and nothing else, compiled from twenty lines of C that return 0:

  before, LUX_GPU_PLUGIN_DIR=/tmp/rogue   AutoBackend = hip
  before, no env, cwd holds the .so       AutoBackend = hip
  after,  either                          AutoBackend = none

Two changes, neither of them new machinery.

The plugin header says a loader MUST check abi_version and vtbl_size, and
quantumvm already does, through lux_gpu_backend_init. aivm, bridgevm and mpcvm
now do the same before resolving a single launcher: a name is what was being
matched, and a name is not a contract.

Bare filenames and the CWD entry are gone from all four. What is left is
LUX_GPU_PLUGIN_DIR and LUXCPP_PREFIX — places an operator named. No new
environment variable: unset means no plugin, which is already the supported
configuration and the one every box without a GPU is in.

Verified with the real HIP plugin from lux-private/gpu-kernels: aivm, bridgevm,
mpcvm and quantumvm all build, vet and test green, aivm TestRoundTripAttestation
passes through the loaded plugin, and the rogue library is refused.
2026-08-29 19:04:26 -07:00
zeekay 85e238e525 Format what the tip change touched 2026-08-29 13:05:58 -07:00
zeekay e82c509a4e Decide the tip under the lock that commits
Every adopter asked whether its block still extended the tip, released
that reading, and only then asked the store for the lock. The tip moves
in between and the commit lands anyway, writing the height index and the
tip pointer for a block on an abandoned branch -- which every peer
bootstrapping from that index is then served as canonical. The lock is
not reentrant, so the check cannot come from the caller and be held
across the commit: it comes from the store or it is not held at all.

Parent joins Height on the Block interface because a place in the
sequence takes both, and height alone is the reading that lets an old
accepted block pass. Two concurrent accepts fall to the same move, not
just the Verify-to-Accept window: the tip a block is judged against is
now read inside the critical section that commits it, so the second is
refused rather than serialised behind a reading taken before the first
landed. The store also exempts nothing, where identityvm exempted height
zero -- and its Verify skips every parent check at height zero, so a peer
could hand a live chain a second genesis carrying records of its choosing
and send the tip and the height back to the start.

mpcvm keeps its root comparison, which answers a different question --
whether applying this block reaches the root the network agreed on -- and
stays true of a node whose state moved for any reason. That a moved tip
also showed up there is a consequence of that chain refusing empty
blocks, not what the comparison is for.

Seed records the tip in that same commit, because seeding is what makes
a chain no longer fresh. Recording none left Open, for which a missing
tip is a fresh chain, reporting a seeded node fresh on every boot and
asking for the allocation again: a duplicate its own state refuses, and
a node that cannot restart until it produces a block it cannot produce.
2026-08-28 20:57:03 -07:00
zeekay 9aed2192a6 Point the citations at the renumbered specs
Corona is Module-LWE, not Ring-LWE, and 0x012205 verifies the P3Q
threshold-signature family rather than a STARK -- the FRI verifier is a
separate precompile at 0x012220.
2026-08-28 20:24:10 -07:00
zeekay fc9a87c1fe zkvm: a prune test that could not see the prune
TestAcceptDrainsWhatTheChainHasPassed accepted one block carrying BOTH
transactions, so Publish removed the expired one as an accepted one and
the pool emptied whether PruneExpired ran or not — deleting the call left
it green. The pool now holds a transaction that never fits in a block,
because a better-paying one keeps taking the slot, and the assertion is
that the chain moving past its expiry is what frees it.

Formatted the two plugin mains.
2026-08-28 15:44:18 -07:00
zeekay a20d44bf52 zkvm: decoding a proof in order to refuse it is not a verifier
PLONK's verification equation was never written: verifyPLONKPairing
refused every proof unconditionally, and above it sat some 200 lines
decoding the proof and the key — exact sizes, subgroup checks, points at
infinity — whose result that refusal discarded. The refusal is the whole
of what the path does, so it is now the whole of what it says.

The aggregate block proof went the same way. VerifyBlockProof re-verified
the very transactions Verify had verified one line above, so it could
only ever agree with them; nothing built a block carrying one; and the
field stood on the wire and in the block id where a reader would take an
aggregate guarantee to be.

Two more second implementations: Verify type-asserted its parent to
*Block after GetBlock had already refused anything that is not one, and
Shutdown asked whether its logger was disabled before logging, which is
what a logger decides.

Coverage 95.5% -> 99.1%. What is left cannot be reached: four bn254.Pair
error returns on one-element slices, two IsInSubGroup refusals on a curve
whose G1 cofactor is 1 and whose Unmarshal already checks G2, and three
boot gates — a store opened through a versiondb view, and fee.Validate on
the floor chain.Floor returns.
2026-08-28 15:39:17 -07:00
zeekay 9f4636b05c identityvm: drive the signatures, the wire and the RPC
Coverage 74.4% -> 99.7%. The two statements left are boot gates that
cannot fail by construction: RegisterService on a service type this
package defines, and fee.Validate on the floor chain.Floor returns.

The tests hold keys, because the chain now requires signatures: a party
signs its own record and the harness submits it, which is the shape a
client has to follow. Each defect the rewrite closed has a test named for
it — an attacker's block claiming a victim's DID, a revocation naming a
public id, a credential naming an issuer it cannot sign for, an allowlist
that admits nobody it does not name, a signature made for another chain,
a restart that must hold what the chain accepted, and a block whose
records reference each other.

Health and getStatus answered an error HealthCheck cannot return.
2026-08-28 15:32:38 -07:00
zeekay f9a999746c identityvm: the chain agrees on what changes its state, and a key authorizes it
Every authorization on I-Chain was "the request said so". RevokeCredential
compared a caller-supplied revokerID against the credential's issuer and
subject, both of which GetCredential publishes — so revoking anyone's
credential was pasting their id into the request. RegisterIssuer checked
nothing and stamped Status "active", while TrustedIssuers was loaded and
never read: an allowlist that allowed everything. VerifyCredential's one
test on a proof was len(ZKProof) != 0. Nothing in the package verified a
signature; nothing was signed.

Records now carry an ML-DSA-65 signature over their content, checked
against the key the chain holds, with the chain's binding —
sha256(ChainID ‖ NetworkID), not on the wire — as the signing context. An
identity's id is the hash of its key and an issuer's is too, so a record
names a key its holder proved; a credential's is the hash of what it says.

A block's Identities and Revocations were verified by NOTHING. Verify
looped over Credentials alone, and Publish applied all three — so a peer's
block naming {victim's id, attacker's key} took over the victim's DID and
one naming any credential revoked it. Nothing else produced either list,
and Write persisted neither, so the takeover was invisible on disk.

Underneath that: CreateIdentity, RegisterIssuer and RevokeCredential wrote
straight to the base database on whichever node received the call, so no
two nodes held the same identity set — and verifyCredential consulted that
node-local issuer map, which makes block validity a property of which node
you ask. All four kinds are now block-carried, verified by one predicate
that assembly and consensus both ask, written in the block's commit, and
rebuilt from the records at boot. Nothing rebuilt them before: a restarted
node held only what genesis named and answered "unknown identity" for
every identity it had itself accepted.

Also:

  A credential's lifetime came from a caller-supplied int64 added to the
  wall clock with no sign check, and Take(0) returns the whole queue, and
  Reject does not drop from it. One negative TTL therefore rode in every
  block the node proposed, was refused by every node including itself, and
  could not be removed: a permanent halt for one fee-paying call.

  Verify required height == parent+1 and nothing more, so a block whose
  parent was a buried ancestor verified; Accept re-checks the tip now,
  because the tip moves between the two.

  ParseBlock never tracked what it parsed and SetPreference discarded it,
  so a follower could verify the first block of a run and not the second.

  ParseGenesis stamped a genesis naming no timestamp with the wall clock —
  a different genesis id per node and per restart — directly under the
  comment explaining why that must not happen.

  Nested frames did not reject trailing bytes, so one logical block had
  unboundedly many ids; unpackStrings truncated silently on a bad length;
  unpackMetadata dropped an unpaired string; parseClaims answered "no
  claims" for a blob that would not decode, which passes the claims bound
  trivially.

  ResolveIdentity scanned every identity and returned whichever of two
  matching DIDs the map reached first. A DID is derived from the id now,
  so it is a lookup.

  GetCredential wrote cred.Status under a READ lock. A credential's state
  is derived from the revocation set and its expiry; it is not a field.

Gone with it: Controllers, Services, Updated and StateRoot, none of which
anything wrote; a CredentialProof carried on the wire whose only check was
its length; and a Status the chain kept in step with two other answers.
2026-08-28 15:27:18 -07:00
zeekay 15fc140211 zkvm: drive the consensus surface, the endpoints and the pairing
Coverage 76.7% -> 95.5%, and three more things the tests found on the way.

A chain that seeds its genesis allocation and has not yet accepted a
block reports itself fresh on every boot, because the store records a tip
when it accepts a block and seeding is not accepting one. Answering that
with a second allocation ends in "UTXO already exists" — a node that
cannot restart until it produces a block. Whether the allocation has been
applied is now asked of the state: Finalize is the last thing it does, so
a committed root that is no longer the empty one is the allocation saying
so.

Three Marshal methods returned an error they never produced, so every
caller wrote an unreachable branch for it — Block.Bytes answered nil in
its one, which is a block with no encoding.

spendTx built every fixture with the same all-zero commitment, so two
different spends created the same output and the set refused the second.
An output commitment now follows what the transaction spends.

The proof tests construct a key and a proof that SATISFY the pairing
equation, so the arithmetic reaches a verified verdict rather than only
refusals — and then show that the same proof bytes on a transaction
spending different notes do not reach it.
2026-08-28 15:12:18 -07:00
zeekay 95a5c2e478 bridgevm: recover the plugin test that .gitignore was hiding
A bare `plugin` line in .gitignore matched every <vm>/cmd/plugin DIRECTORY, so
`git add` inside one printed a hint and staged nothing. This file was written
and passing and was not in the repository. Another lane anchored the pattern to
/plugin; this is the file that was stranded behind it.

Work that is written, green, and invisible is the worst of the three states: it
looks done to whoever wrote it and does not exist to anyone else.
2026-08-28 15:04:51 -07:00
zeekay eb02967a9a aivm: one staging layer, one commit, one chain
A-Chain kept the tip only in memory, staged every pending block's writes
in one shared versiondb, and accepted a block by committing that layer
before storing the block. So a restart came back at genesis, accepting A
published B's intent, rejecting B discarded A's, and a failed store left
the state durable and the block gone.

Verify and BuildBlock now decide against an overlay over committed state
and throw it away, so a block that is proposed or checked moves nothing.
Accept stages the transition, the block, its height entry and the tip
through one view and commits them once. Custody moves into engine state,
so balances commit with the stake records that depend on them instead of
living beside them in memory.

Verify also gains the lineage, time and size checks it never had: the
parent must be the tip or a block in flight above it, the height must
follow, the timestamp must sit between the parent's and the local clock
plus a skew allowance, and the encoding must be one a peer will accept.
Accept re-checks the tip under the lock that commits, because it moves
between the two calls.

A block is now named under its chain id, which never travels, so the same
bytes name different blocks on different chains. Nested payloads must
account for their blob exactly, id lists must be whole ids, and an
element the encoder cannot write is reported rather than dropped. A
merkle proof's index must be spent by the fold, so an out-of-range index
no longer verifies on its low bits.

Also: the running flag is read under the lock that writes it, a vertex no
longer moves the block chain's head, and eligibleSet says what its quorum
is a quorum of.

The ten probe files that proved all this are converted to regression
tests, not deleted.
2026-08-28 15:03:32 -07:00
zeekay 787284d140 mpcvm/fhe: one device round trip, three kernels
BatchNTTForward, BatchNTTInverse and BatchPolyMul were three copies of one
loop — snapshot the session, allocate operand tensors, run a kernel, sync, read
back, copy out, and on any failure at any step close what is open and do the
operation on the CPU instead. ~45 statements each, differing in how many
polynomials an element reads, which kernel runs, and what the CPU does instead.
Three copies of a resource lifecycle are three places to forget a Close, and a
fourth kernel would have been a fourth copy.

Now one offload plus one element, and three wrappers that name their three
differences. 101 lines go, no behaviour with them.

The floors are deliberately NOT unified: forward NTT reaches the device at 64
polynomials and ring degree 8192, the other two at 4 and any degree, and the
reasoning written on the forward path applies to all three. That reads as
drift, but changing a performance threshold needs a device to measure on and
this host has none, so the numbers are carried through as parameters and the
disagreement is stated where the next reader will see it.

Also drops the count == 0 guard, which count < minBatch already subsumes for
every floor; reverting it changed no test result, which is what redundant
means.
2026-08-28 15:02:56 -07:00
zeekay 6919ac8229 zkvm: a transaction's identity is its content, and one predicate decides
The proof cache was keyed on tx.ID as read off the wire, plus the proof
type, the proof and its public inputs — all four the sender's to choose.
Copy an accepted transaction's four onto a transaction spending entirely
different notes and the key matched; the hit returned nil from the cache,
ahead of verifyPublicInputs, which is the only thing tying those inputs to
what the transaction spends. Shielded value, backed by a proof that
attests to nothing about it.

The id is no longer on the wire. parseTransaction derives it, the cache is
keyed on ComputeID(), and a block's id folds in ComputeID() rather than
whatever id arrived — which also ends the block and vertex id malleability
that one flipped byte bought.

Every block and vertex id, and the first public input of every proof, now
open with sha256(ChainID ‖ NetworkID). It is not on the wire, so a block
from another chain has no parent here and its proofs do not verify, rather
than passing a check someone can forget to write.

Also on the consensus surface:

  Verify required height == parent+1 and nothing more, so a block whose
  parent was a long-accepted ancestor verified, and accepting it rewound
  the tip and left the height index naming an orphan to every peer that
  bootstraps from it. Verify now requires the parent to be the tip or a
  block above it, and Accept re-checks against the tip, which moves
  between the two.

  Vertex.Verify checked its transactions and nothing else — no parent, no
  height — so a vertex naming no parent at height 1<<40 verified, and
  accepting it set the store's height to 1<<40 and left the chain unable
  to propose again. Its decoder also ignored trailing bytes, so one
  logical vertex had unboundedly many encodings under one id, each of
  which the store writes to disk.

  BuildBlock skipped the ValidateBasic that Verify runs, so a transaction
  with an out-of-range Type — read straight off the wire — was assembled
  into every block and refused by every node including the proposer, and
  nothing evicted it. admit is now that one predicate, assembly drops what
  it cannot build, and a transaction names an expiry so a bounded pool
  drains.

  BuildBlock stamped an unclamped time.Now(), which is below a parent
  legally up to maxClockSkew ahead — a block its own Verify refuses.

  ParseBlock never tracked what it parsed and SetPreference discarded it,
  so a follower could verify the first block of a run and not the second.

  A genesis naming no timestamp was stamped with the wall clock, hashed
  into the genesis id: one genesis file, a different chain per node and
  per restart.

  VerifyBlockProof took a GPU path on accel.Available() — a second, inline
  copy of the Groth16 checks that consulted neither the cache nor the
  dummy-key refusal — so whether a block was accepted turned on whether
  the node had an accelerator.

Reads that failed were reported as absence: the spent set answered "not
spent" for a set it could not read, which is the whole double-spend gate;
the state root answered "empty" at boot AND on rollback, where it also
returned nil; the UTXO store answered "not found". Each now reports the
failure.

Deleted, none of it reachable: a node-side wallet holding plaintext
spending keys behind an HTTP endpoint, with a loadAddresses that returned
nil so the keys were unusable anyway; note construction and the package's
only panic; an FHE processor whose verifier tested len(proof) >= 128 and
whose "encryption" wrote the plaintext into the first eight bytes; a
zwitness carrying a TODO; a 256-level sparse Merkle tree that computed no
root and wrote its cache under a read lock; six endpoints answering "Not
implemented"; a Signature field nothing verified; and eight config knobs
nothing read. The endpoints that remain answer at the path the node mounts
them on, rather than behind a mux that dispatches on a path it never sees.

Coverage 58.9% -> 76.7% on a denominator 539 statements smaller.
2026-08-28 15:02:32 -07:00
zeekay ec074f11aa evm, oraclevm, relayvm: a vmID nobody compared, and a policy no rollback could reach
The two shims re-exported luxfi/oracle's and luxfi/relay's private vmID
literals. constants.OracleVMID and constants.RelayVMID exist, agree with them,
and nothing made them agree. This repo builds the plugin binary whose FILENAME
is that id, so the shims now name the constant and their tests compare all
three; a drift upstream fails here instead of producing a chain no node starts.

cevm gets ErrNotLinked, so a caller can tell "this build has no C++ EVM" from
"this input is bad". cevm/parallel's sender-recovery step returned a hard error
for the first, which in a build without the library is every block, forever,
and CEVM_STRICT=0 -- the documented rollback to the Go EVM -- did not reach
that line. It now leaves by declineBlock like every other capability refusal.

Its receipt gate asked whether a transaction carried calldata. A contract
creation with empty init code carries none, so it was receipted as a value
transfer: no ContractAddress, and 21000 intrinsic gas against creation's 53000,
into the cumulative total the header commits to.

ExecuteBlock splits into shape / dispatch / assemble so the parity-critical
half needs no library to exercise. strictGPUEVM loses the sync.Once that fixed
its answer at the first decline and left both branches unreachable from one
process.

The native build referenced gpu_execute_block and gpu_free_result, which ABI v6
removed, so nothing behind lux_cevm_native compiled at all.

The three plugins print the version the VM reports rather than a second literal
beside it, and the vmID the binary must be installed under: a plugin under the
wrong filename is invisible and the chain simply never appears.

cevm/plugin.go is gone. It located a binary at ~/work/luxcpp/evm/build/bin/cevm,
a path that has never existed -- the tree is luxcpp/cevm -- claimed the lux CLI
and universe Makefiles used it, which neither does, and returned "cevm" from a
function called VMID. Its two tests skipped and asserted nothing.

.gitignore's bare `plugin` matched every <vm>/cmd/plugin DIRECTORY, so a new
file under one could not be added: git printed a hint and staged nothing.
mpcvm's and bridgevm's plugin tests are written, pass, and are not in the
repository.

Coverage: evm 0.0 -> 85.0, evm/cevm 71.1 -> 100.0, evm/cevm/parallel 0.0 -> 86.1,
oraclevm 0.0 -> 90.0, oraclevm/cmd/plugin 0.0 -> 72.2, relayvm/cmd/plugin 0.0 -> 72.2.
2026-08-28 15:01:34 -07:00
zeekay d1de30ce04 graphvm: a chain that builds no block admits no block
G-Chain declines every BuildBlock, and then ParseBlock accepted 56 bytes
of well-formed ZAP naming any height and any parent, Verify passed it,
and Accept moved lastAccepted to it — after which GetBlock(LastAccepted())
answered not-found and the node could not boot. One block off the wire,
one permanently unstartable node. The refusal is now one predicate that
admission, assembly, preference and consensus all answer with.

The block carried four payload slices no code ever wrote, so Accept's and
Verify's loops over them were vacuous, including the unlocked read of
vm.queries. Deleted, with the four types. The VM also held a second
GraphQL parser and resolver table with no callers, behind two endpoints
that answered "GraphQL endpoint ready" to every query; the real executor
is now what /graphql serves, and the placeholders are gone.

In that executor:

  first: "-1" reached make([]*T, 0, -1) and panicked the process, and
  mints and burns had no upper bound at all. Forty near-identical
  resolvers are now two shapes, so the bound is a property of the shape.

  where: {token0: "0x1"} arrived as the string "{token0" — the argument
  list was split on commas — so every filtered query answered with the
  whole table and reported no error.

  { a b } was one field named "a b": the selection set was split on
  commas and newlines only.

  MaxResultSize was stored and never read, and QueryTimeoutMs bounded
  nothing because no resolver watched the deadline.

  sortTokens returned !cmp to reverse, reporting i<j and j<i for every
  tie, which left sort.Slice free to permute equal rows differently on
  identical data.

  RequireAuth with a configured "" admitted `Authorization: Bearer `,
  because ConstantTimeCompare reports equal for two empty slices. The
  chain refuses to boot on that config instead.

Coverage 31.8% -> 99.8%.
2026-08-28 14:37:46 -07:00
zeekay d5722bde31 mpcvm: a test that re-implements what it tests cannot fail with it
TestUnboundLabelIsNotCounted named the boot-time binding count and then wrote
its own copy of the loop and asserted on that. It passed whatever Initialize
did, including nothing. The property is held against the VM in
TestOnlyAnEntryCarryingARealChainIdCountsAsBound, which boots a node on a table
of labels and asks it who it authorizes; what stays here is the half that is
local — a label does not parse as a chain id.
2026-08-28 13:48:20 -07:00
zeekay 1393f91994 mpcvm: delete the docs for a design that was never built
docs/ and DESIGN.md described protocol/{cggmp21,frost,corona_general,
tfhe_keygen} — four interface-only packages with no implementation and no
importer, deleted this pass — and pointed at a chains/mchain/ tree that does
not exist, which LLM.md already says never did. Prose describing an unbuilt
system reads as a contract; the reader who believes it looks for code that
isn't there.

What is true moves into LLM.md: the constants that must stay constants because
Verify enforces them, the two checks that must read consensus state rather than
the document being checked, and the boot fault that made a share-holding
validator unable to restart.
2026-08-28 13:47:20 -07:00
zeekay 6981a3cd6c mpcvm: M-Chain to 98.6%
The lane that did this was killed before it could commit; the tree it left is
green and is preserved here rather than lost. Root package 51.5% at the start
of the day, 93.3% at its last checkpoint, 98.6% now, with cert, fhe, types and
cmd/plugin all passing.

Includes the fault-injection suite it was building when it stopped: a node that
cannot read its state does not start, and one that cannot enumerate its
registry does not either.

Not reviewed by me beyond build, vet, gofmt and the full suite under -count=1.
The lane's own report is owed and did not arrive, so its mutation count and its
verdict on the mpcvm/fhe duplication are unrecorded.
2026-08-28 13:25:30 -07:00
zeekay 75c971b172 chain: pin that only an absent tip is a fresh chain
The fix in aa4c375 was correct and unpinned -- reverting it left the suite
green, which is the same failure this estate has now hit six times. Two tests
close it: an unreadable tip must surface its cause and must not report a first
run, and a tip of the wrong width must be refused as corruption rather than
rounded down to absence. Restoring the old condition now fails both.

The absent-tip half was already covered by TestOpenOnAnEmptyStoreStartsAtGenesisAndSaysSo,
so it is not restated here.
2026-08-28 13:20:05 -07:00
zeekay 8b6d2c50d4 chain: only an ABSENT tip means a fresh chain
Open read any failure from the tip as "nothing recorded yet" and started the
chain at genesis. A closed database, an unreadable volume or a short read
therefore looked identical to a first run, and a live chain would begin
building height 1 over state it could not see -- durably, with Initialize
returning nil.

Q-Chain and F-Chain each found and fixed this in their own code before the
store existed. Five VMs now share the store, so the same defect was waiting for
each of them again. Not-found starts at genesis; anything else refuses to open,
because a chain that cannot read its own tip does not know where it is.

A wrong-width tip is now refused rather than silently rounded to a fresh chain
for the same reason: it is corruption, not absence.
2026-08-28 12:15:51 -07:00
zeekay 3035c643f0 bridgevm: the plugin reports the version the VM has
It printed a string literal beside bridgevm.Version. Bump either and the
binary an operator asks reports a version it is not. Splitting run() out of
main() makes what the plugin does separable from how a process reports it,
and testable.
2026-08-28 12:05:31 -07:00
zeekay a927ce8a7e aivm: an error that says the opposite of what happened, and a guard that guarded nothing
Two of the fifteen defects a probe suite confirmed and nobody had fixed. Both
are self-contained; the rest are one architectural change to the staging layer
and belong together.

WithdrawStake refused an operator that was NOT unbonding and reported
ErrOperatorUnbonding -- the opposite of what had happened. An operator reading
it would conclude it had already deregistered and wait out a cooldown that
never started. Its own comment said "must Deregister first", which is what the
error now says. The existing test asserted the wrong constant while its comment
described the right behaviour, so the assertion was updated to match the
comment rather than the code.

Initialize guarded the runtime for one field and dereferenced it on the next
line, so the guard read as defensive and the nil panic happened anyway. A VM
with no runtime has no logger, no network and no identity, so it refuses.

Still open, and deliberately not touched here: the tip is never persisted, the
staging layer is shared across in-flight blocks so Accept commits a sibling's
writes and Reject drops them, Accept commits engine state before storing the
block, there is no tip check, no size bound, no chain binding, no timestamp
bounds, and a Merkle proof verifies at an index outside its tree. Those are one
job, not nine, and the shape is already settled in fhevm and chain/.
2026-08-28 12:05:06 -07:00
zeekay 6595c4ff2e bridgevm: an empty group is not a key, and the arms nothing can reach are gone
A CMP group with no shares sums to the identity point, which marshals to a
well-formed thirty-three bytes. Handed back as the custody key it made an
empty config look like a finished keygen: the node reported itself ready to
attest and bridge_getInfo agreed, on a chain that could verify nothing.

The rest is subtraction. A settlement record now holds only the fact that
the transfer is settled, because the block that settled it is already named
by the chain. movedToday reports what it read or why it could not, instead
of reporting zero. RemoveSigner returns what happened rather than an error
it never had. Marshal cannot fail, so Bytes has no failure to handle. The
gorilla-rpc registration was a second way to serve one API. The route table
NewHTTPHandler built was a second copy of the one CreateHandlers names. The
logger is set at Initialize, so guarding every log call against its absence
was guarding against ourselves.

How often the watcher polls is data on the watcher, as the releaser's
backoff already is.
2026-08-28 11:59:59 -07:00
zeekay e5fbbc5201 fhevm: a block extends the tip, a signature names one chain, and a failed read is not an answer
Four defects, each confirmed by executing it before it was fixed and by
mutation after.

A block whose parent was an OLD accepted block satisfied height ==
parent+1, so Verify passed it and Accept applied it: the chain at height 3
rewound to 2, the last-accepted pointer and the height index moved with
it, and the index went on naming the abandoned block at 3. A peer
bootstrapping from it was served an incoherent chain. The parent must now
be the tip or a block verified above it; Accept decides again under its
own hold of the lock, because the tip moves between the two.

Nothing bound a transaction or a block to the chain it was meant for. An
address is the hash of a public key, so the same payer exists on every
F-Chain, and a captured transaction authenticated verbatim on all of them
and burned a balance there. Every chain sharing a genesis timestamp also
shared a genesis id, so one chain's block resolved its parent on the
others. The chain id now enters the signing preimage and the block id
without travelling on the wire: each side supplies its own, so a mismatch
is a failed signature and an unresolvable parent rather than a check
someone can forget.

A read that FAILED was reported as a read that found NOTHING. An
unreadable last-accepted pointer left a live chain looking like a fresh
one, with Initialize returning nil and the node ready to build height 1
over height 2. The same conflation on the epoch pointer seats a committee
nobody elected, and on a nonce it reopens the replay window a nonce exists
to close. One reader now separates absent from unreadable, and nonceOf
reports rather than returns zero. HealthCheck likewise stops reporting a
ledger it could not read as zero burned.

MaxBlockTxs was applied by Verify, and Verify runs after the parse, so an
8 MB message carrying 1,524 transactions parsed to completion before any
check ran. Bytes and count are both bounded at the first byte, and neither
implies the other; selection stops at whichever comes first, so a proposer
cannot build what its own parser refuses.

Deleted: the tx-blob accounting check, which the canonical-encoding rule
subsumes exactly (a blob with unread bytes re-serializes shorter), and the
empty-path branch in NewHTTPHandler, whose one producer returns a literal.

fhevm 89.7% -> 98.9%. The twelve statements left are named unreachable in
the report, not padded. The harness now holds constant what production
holds constant: one chain id and one genesis across every node of a chain,
rather than a fresh id per node.
2026-08-28 11:55:53 -07:00
zeekay 168838436e bridgevm: drive the release path, the EVM client and the plugin ABI
The EVM client had no test at all, so the thing that broadcasts a release
was described rather than exercised; it now runs against a JSON-RPC
endpoint. The release worker's retry policy is data on the worker rather
than literals in its loop.

The GPU plugin loader is driven against a shared object built for the
test, which is the only thing that can catch the Go and C sides
disagreeing about where a field sits — the drift its own comments say
would silently corrupt every state root. The skip it replaced could not
fail.
2026-08-28 11:52:11 -07:00
zeekay a8dbf849b4 bridgevm: B-Chain boots, and what it settles it remembers
The VM never built its chain store, so Initialize dereferenced a nil
store and B-Chain could not start at all.

What a block settles — the transfers, and the day's running total per
destination — is now written in the same commit as the block. Both lived
in maps on the heap, so a restart forgot every transfer the chain had
released and handed back the whole day's allowance: a restart was a free
reset of the cap that bounds what this bridge can move. The counter is
cut from the block's own timestamp, so every node closes the window on
the same block, and it only ever grew before, which stopped the chain for
good once total volume passed the limit.

A request's id is now the digest of the transfer it carries, and Verify
refuses one where it is not. The id was a label the proposer chose while
the replay guard, the daily counter and the release worker all keyed off
it, so one deposit could ride in under any number of names.

Verify now resolves the parent, refuses one beneath the tip, requires the
height and the timestamp to follow it, and registers what it accepted so
a follower can verify the second block of a run. It read none of that
before. Blocks are bounded and canonically encoded, the chain id is
hashed into every block id, and assembly admits exactly what Verify
admits, so an uncarryable transfer no longer wedges the proposer.

The signer set's threshold is ⌈2n/3⌉ in integers rather than a float that
asked for unanimity at three signers and for two of one at one, the bond
it declares is enforced where a signer registers, and a replacement takes
the slot it replaces.

Deleted: the block and request MPC signature carriage, which nothing
produced and whose deserializer could not decode a canonical secp256k1
signature, so it could only ever reject; the chain-name list beside the
routing table; the confirmation depth and observation time that each node
read for itself and then wrote to the wire; a worker pool, a party id and
an engine channel nobody read.
2026-08-28 11:42:07 -07:00
zeekay 67e22c6960 Name Zen in the hero card's font stack
The social card asked for Inter first. That is not a face this estate
licenses, ships or ever drew, so a renderer with Inter installed painted
the wrong face and one without it silently fell through to system-ui.
Zen leads now; the same tail follows it.
2026-08-28 11:41:52 -07:00
zeekay 2b45e26ccc mpcvm: one way for a lane to reach its verifier, and no dead contracts
The lane registry had two paths to a verifier. Register refuses a lane that
does not belong to the registry's owner, and its comment says that is what
enforces M/F orthogonality. RegisterLegacyAlias then mapped any id at all onto
a registered verifier, checking only that the TARGET existed and never that the
legacy id belonged to the owner -- so the alias walked around the invariant
Register exists to hold. The test demonstrated it: an arbitrary id 200, owned by
neither chain, dispatching to an M-Chain verifier.

It bought a grace window for LP-5013 T-Chain lane ids. LP-134 dissolved T-Chain
with zero remainder, so those ids cannot arrive. Nothing outside the test ever
registered an alias. Gone, with the aliases map and ClearAliases: a lane this
chain does not own now has no verifier, which is the ownership rule still
holding at dispatch.

Deleting mpcvm/runtime's two adapter interfaces emptied the package. FChainAdapter
and MChainAdapter were declared, never implemented and never called -- every
reference to either was inside its own file.
2026-08-28 11:03:37 -07:00
zeekay 6419047cdf aivm: a vertex id names the work in it, and no two vertices share one
Two defects in the same function, both confirmed by execution.

The transaction ids never reached the hash, so the payload was unauthenticated:
two vertices carrying different work under the same job names had the same id,
and a peer asking for one could be served the other with no way to tell.

The job names were concatenated with nothing between them, so ["ab","c"] and
["a","bc"] hashed identical bytes -- a distinct vertex that every peer must
treat as the first. Each variable-length part now carries its length ahead of
it, which is what makes an encoding injective. The same framing bug turned up
independently in schain's key builder, spelled with the same two strings.

Mutation: 2 of 4 caught. The two survivors are the element COUNTS on the parent
and job lists. Removing either leaves the encoding injective today, because the
neighbouring field's count delimits it -- so no test can kill them without a
collision I was unable to construct. They stay anyway: they are what keeps each
field independently injective, so adding or reordering a field later cannot
quietly reintroduce this. An identity hash is the wrong place to remove framing
on the strength of not having found the collision yet.
2026-08-28 11:00:05 -07:00
zeekay 8cf453d4b0 chains: a signature of the wrong width is refused, not reshaped
warpmsg is the one place bridgevm, mpcvm and zkvm turn a payload into a signed
cross-chain envelope. It copied whatever the signer returned into a fixed
96-byte array without checking the length. Signer is an interface, so that
width is a runtime fact: a short signature was silently zero-padded and a long
one silently truncated, and either way the envelope came out well-formed and
unverifiable -- a message the sender believes it sent and no receiver can ever
accept, with no error anywhere.

oraclevm's accel host documented an error for a nil VM and never checked,
deferring the failure to whichever method the operator called first.
2026-08-28 10:56:44 -07:00
zeekay 1b02c78541 quantumvm: a block off the wire is held to what a block it built must satisfy
Red confirmed by execution that the receive path was not the build path, and
three of its findings were chain-integrity defects rather than gaps.

Parse discarded the transaction set, so a block arriving over the network had
none -- and the ML-DSA check was gated on having some. The check therefore ran
only on blocks this node built itself. The same bytes verified in memory and
passed off the wire, and a block carrying arbitrary transaction bytes was
accepted and committed. Parse now reconstructs the full set, signatures
included, and refuses a declared count its bytes cannot hold or lengths that do
not partition the blob.

Accept never asked whether the parent was the tip -- only that the height
followed it. So an old sibling rewound a five-block chain and the height index
went on serving the orphaned branch to bootstrapping peers as canonical.

The tip reader answered the zero id for any failure, and the genesis seeder read
that as a fresh chain: one transient read at boot committed genesis over a live
tip, durably, and Initialize returned nil. Not-found is now distinguished from
unreadable, and only the first means new.

The harness that was supposed to defend the signer-identity fix could not see
it: it gave each node its own ChainID, which production never does, so
reverting the fix left the suite green. It now holds the chain constant and
varies the node, which is the shape the defect lives in.

Also: the proposer no longer builds a block its own Verify refuses when the
clock trails the tip.

96.4% coverage.
2026-08-28 10:56:43 -07:00
zeekay 98b2a7fce7 fee: a fee that cannot be recorded is not a fee that was paid
Every VM in this repo burns through this package, and its fail-secure guards
were the part with no tests: the overflow arms, the corrupt-state refusal, and
every storage-failure path. 82.7% -> 100.0%.

An unreadable balance is not a balance of zero. Reporting zero would make an
unfunded account and an unreachable store indistinguishable, and the caller
would charge the one it could not read. Same for a stored value of the wrong
width: that is corruption, not a number, and decoding it invents a balance from
whatever bytes are there.

Burn's two writes are ordered on purpose. Either can fail, and whichever half
stages alone is wrong -- the caller must abort, because the Balances contract
puts both writes in the block's commit. But they are not equally wrong if one
ever did commit: debit-then-record destroys value, record-then-debit invents it,
and an overstated burn counter is unrecoverable because nobody can say which
account should have paid. The survivable failure is the chosen one.

Conservation is now asserted rather than assumed: across an interleaving of
credits and burns, including refused ones, everything credited is still either
spendable or burned -- never both, never neither. That is what makes this a
ledger instead of two counters that happen to move together.

8/8 mutation checks caught, including the write order.
2026-08-28 09:32:05 -07:00
zeekay f28e5d0e30 chain: keep only what something calls
Two pieces of the new package had no caller. Pool.Holds was the read side of a
claim set that Add already enforces, so nothing ever needed to ask; it is gone,
and the tests that used it now assert the same property through Add, which is
the behaviour that matters.

chain.Closed had no caller because M-Chain still declared its own
NoUserTxPolicy. It declares chain.Closed() now — one fewer copy of a policy
five chains state identically, and the one no-user-tx chain in this pass
exercises the value that describes it.
2026-08-28 09:22:52 -07:00
zeekay 7ed6c6d644 zkvm: drop the tip key the store now owns 2026-08-28 09:15:58 -07:00
zeekay 5fb41c5066 chain: drop a lint directive for a method that is used 2026-08-28 09:04:27 -07:00
zeekay 2dee7c2aac chains: answer for the height index that is already written
GetBlockIDAtHeight said "not implemented" on I, Z and B while returning a real
answer on K, F, Q, G, S and M. The store writes the height entry in the same
commit as the block, so on these three it was a stub in front of an index that
already existed — and the engine resolves blocks by height when it bootstraps
and when it state-syncs.

Each is now one line onto that index, which cannot name a block the chain did
not accept because the two are the same commit. A test on each proves it: a
block whose commit is refused has no height entry, and gets one when it lands.
2026-08-28 09:01:29 -07:00
zeekay 87cb72352a schain: an authorization is spent once, and a block commits what it contains
An AllocateTx's nonce was covered by the owner's signature and compared to
nothing. Every gate it passed the first time passed again for the identical
bytes: the epoch was still current, the fingerprint still matched, the signer was
still the range owner, the ML-DSA signature still verified. Anyone who saw an
owner's allocate could resubmit it and consume the range's id space again, for
free, as often as they liked. A range now carries the nonce of the allocation
that last moved it, in the same key as its cursor, and takes only a strictly
greater one. The state root covers it, so a chain cannot disagree about what has
been spent and share a root.

Accept committed whatever was staged. Every apply starts by dropping the version
layer, so the layer belongs to whichever block was processed LAST — verify a
sibling between a block's Verify and its Accept and the commit carried the
sibling's writes under this block's id. Accept re-applies its own transactions
from committed state, commits, and only then advances the chain in memory, under
the lock it was mutating without.

Block position is now checked before block content: the parent must be a block
this node holds, the height must be parent+1, and the timestamp must not rewind
or run past a skew ceiling floored at the parent. None of it was checked, so a
block could claim any height — and the height it claimed became the chain's
height and part of every state root computed after it.

A proposer now leaves out what it cannot build rather than failing the whole
build: one unauthorized allocate anyone could submit used to drain the mempool
and destroy every manifest queued behind it. A verifier still refuses the whole
block, which is the half that carries the safety property.

Also: the height lookup reads an index instead of scanning the block map and
returning whichever entry map iteration reached first; abandoned blocks are
released; a rejected block leaves the index; boot no longer panics on a nil
logger when the manager supplies no runtime; and the empty-set check in the owner
gate is gone — pinning.Owner already answers that question, and answers the wider
one the length check missed.

Coverage 69.9% -> 97.9%, with 32 guards mutation-checked. The stale premise in
SECURITY_review.md is recorded at its head: it reviewed a checkout that did not
contain this VM.
2026-08-28 09:00:18 -07:00
zeekay eaf8e8258b chains: gofmt the files this pass touched 2026-08-28 08:59:56 -07:00
zeekay 6d786b8104 mpcvm: a refused block leaves no ceremony behind, so it can be applied again
Accept folded the operations into the key registry and the ceremony log with
one Put each, and only THEN compared the root it reached against the one the
block claims. Refusing there left every record it had already written — exactly
the disagreeing state the check exists to refuse.

That was worse than losing a block. PutKey and PutCeremony refuse a record they
already hold, so the block that failed could never be applied again: the chain
was wedged at that height, holding ceremonies no block records. PutBlock and
SetLastAccepted were each two Puts with an early return between them, so a fault
could also drop a height entry or advance the root under a stale tip.

State now writes through the chain's view, and the block, its height entry, the
tip and every record it wrote commit together. A refused block leaves nothing,
and applying it again works — which the test proves by doing it.

The staging queue becomes the shared pool: it was already a map keyed by
ceremony id plus a slice for order, rebuilt from the map when entries went, which
is what the pool derives. The store owns the tip, the height index, the blocks
in flight and the engine's preference; State keeps the registry, the ceremony log
and the root, which are M-Chain's own.

One lock order, written where the locks are: the chain's, then the VM's, then
the pool's. Verify resolves its parent before taking the VM's lock, because
holding it across a block lookup takes the two in the order Accept does not.

No existing test changed.
2026-08-28 08:58:40 -07:00
zeekay 0ff6430a7b bridgevm: a transfer is released only once its block is durable
Accept credited the daily volume, completed the requests, advanced
lastAcceptedID, dropped the block from the in-flight set and handed the
transfers to the release worker — which broadcasts on an external chain — and
only then wrote the block. Every one of those survived a failed write, so a
release could be in flight for a transfer no block records.

lastAcceptedID was never written at all. The tip lived in memory, reset to
genesis on every restart, and the daily volume that bounds the bridge limit
went with it.

The block now commits through the shared store, which writes it with its height
entry and the tip in one batch, and its effects — including the handoff to the
release worker — happen only afterwards. A proposal follows the engine's
preference, which the store now holds, rather than always the accepted tip.

The bridge's own state keeps its own lock. The transfers in flight, the signer
set, the chain clients and the release plumbing are not the chain's state, and
folding them into the chain's lock would say they were. The order is the
chain's lock, then the bridge's, then the registry's, and it is written down
where the locks are.

Still in memory only, and NOT changed here because it needs its own design: the
completed-bridge registry and the daily volume. A restart still forgets both,
which re-opens the daily bridge limit.
2026-08-28 08:42:29 -07:00
zeekay d4c7d5440b quantumvm: an accepted block is one the node still holds after a restart
Accept wrote the block, the tip pointer and the height through the version
layer and never committed, so a node that had accepted a thousand blocks came
back from a restart naming genesis — while its peers held it to the tip it had
already told them about. The three writes were also three early returns, so a
failure part-way left the block stored under a tip that named its parent. They
are now one commit with a rollback, alongside the height index that makes
GetBlockIDAtHeight an answer rather than a refusal.

Verify checked only that the parent existed, and only above height one. A
height-one block could name a parent no node has ever seen; any block could
name genesis as the parent of height 500, rewind chain time to revive expired
quantum stamps, or jump forward and expire every stamp in flight. It now sits
on its parent in height and in time, under a skew cap — and BuildBlock never
stamps behind its own parent, so a node whose clock trails a slightly fast peer
does not build blocks it will itself refuse.

Verify held the VM's read lock and then called GetBlock, which takes it again.
Go queues a waiting writer ahead of any later reader, so a build arriving
between the two acquisitions deadlocked the chain with nothing to time out.

The Quasar validator id was the CHAIN id — a field never assigned — so every
node signed as the empty id, each peer's signature arrived as a duplicate of
the first, and no threshold above one could ever be met. It is the node id.
The default threshold was derived from a committee size that had not yet been
defaulted, so a config naming neither got one-of-three. Cleanup released only
FINALIZED entries, which are exactly the ones that do not accumulate. Corona
keyed its PRF off the first 32 bytes of the message, so any shorter message
through StampBlock killed the process; the signature counts were read outside
the lock that appends them.

A transaction whose quantum stamp has aged out never verifies again; left in
the pool it held its slot for good, and enough of them stopped the chain
accepting work. BuildBlock evicts what it could not verify, and re-arms the
builder when the batch limit leaves work behind — the latch holds one signal,
so the remainder had nothing to wake it.

A wire too short to hold the header decoded to height 0, time 0 and the empty
parent rather than failing, so every truncation named one value under as many
ids as an attacker cared to send.

Deleted, all proven unreferenced: the dlopen GPU plugin bridge (a second way to
do what quantum.ParallelVerifyWithThreshold already does through accel), the
stamper package (near-verbatim copy of the signer's accel path, with a
mode-confusion bug the original does not have, and the only reason the tree
needed geth/core), the Q-witness adapter, the NTT helpers (whose inverse did
not invert), and a signature cache that was written and never read — and whose
ids.ToID over a >32-byte input gave every entry the empty id, so all ten
thousand slots were one.

Coverage 20.0% -> 97.0%, with every load-bearing guard mutation-checked.
2026-08-28 08:37:25 -07:00
zeekay 17734274d2 keyvm: one predicate decides a transaction, so no layer can wedge another
Admission accepted any nonce above the committed one while Verify demanded
exactly committed+1. That split was a free, permanent halt: one gapped
transaction from any funded account entered the mempool, was drained into every
block alongside honest work, failed Verify, was requeued by Reject, and the next
build produced the same doomed block forever. Admission, assembly and consensus
now decide through one checkTx, and assembly drops what it cannot build, so a
block this node builds is a block this node verifies.

Block position is now checked before block content. The parent must exist (a
block claiming height 0 skipped the check outright), the height must be
parent+1, and the timestamp must not rewind or run past a skew ceiling floored
at the parent. Block time is the clock every authorization decision is made
against, so an unbounded timestamp let a proposer revive a permit the chain had
already retired.

Also: payload bytes are priced (a megabyte of commitments rode on chain for the
flat fee of an empty one); blocks are canonical on the wire, as transactions
already were; the processing-block index lives under one mutex instead of two,
which the race detector answers with a fatal throw; abandoned blocks are
released rather than retained forever; in-memory state advances after the commit
rather than before it; the height index is real, so GetBlockIDAtHeight answers;
and checkAuth resolves the record Apply works from instead of Apply resolving
and re-authorizing it a second time.

Deleted: keyvm/config, 25 knobs of which one was ever read, including a
ShareCacheSize for a VM whose defining invariant is that it never holds a share;
the duplicate networkID the factory set and Initialize silently discarded; the
duplicate state handle beside versdb; and a path-normalizing branch for a path
that cannot occur.

Coverage 58.9% -> 97.9%, with 30 guards mutation-checked.
2026-08-28 08:37:11 -07:00
zeekay 479d676264 zkvm: a note is spent with its block or not at all
Accept marked the block accepted and moved lastAccepted before writing
anything, then issued a Put per nullifier and per output, each returning early.
A failure partway left some notes spent and some outputs created under a tip
the chain had already advanced.

Worse, the spent-nullifier cache advanced as it went, and MarkNullifierSpent
refuses a nullifier it already holds — so the block that failed could never be
applied again. The chain was stuck at that height holding notes it believed
spent with no block recording the spend.

Everything a block writes — nullifiers, outputs, the state root — now stages
through the store's view and commits in one batch with the block and the tip.
On failure the view is rolled back and the three caches are rebuilt from what
committed, so the block can simply be applied again. The DAG vertex had the
same defect and goes through the same store.

That is why the store's Block is not the engine's block interface: a vertex has
several parents and no timestamp, so it cannot satisfy it, and it changes state
exactly the same way. The store is generic in what a chain tracks, so Propose,
Open and Block hand back the chain's own type and no caller asserts.

Also here: genesis is seeded through Seed, which commits on its own, because a
staged allocation would ride on whichever block landed first and vanish from a
chain that never accepted one. StateTree.Finalize records the root before
holding it, rather than after.

Restoring the original Accept fails the atomicity test; removing the cache
rebuild fails it on the line that says the cache must agree with the database.
2026-08-28 08:34:00 -07:00
zeekay 338d59e0cd identityvm: a credential is recorded with its block or not at all
Accept set the status and moved lastAccepted before writing anything, then
issued one Put per credential with the error DROPPED on the credential writes
themselves. A failure partway left earlier credentials on disk, the tip already
advanced, and the chain believing a block it had only half applied.

It now goes through the shared store: the credentials are staged, committed in
one batch with the block and the tip, and the revocations, identities and
status become visible only afterwards. A failure discards all of it and leaves
the tip where it was. The dropped error is checked.

Beside the fix, what this chain kept restating goes away: the pending queue
becomes the shared pool, the blocks in flight and the tip come from the store
under its one lock, the fee declaration is a value rather than a policy plus a
network id, and the factory no longer half-populates a VM that Initialize
rebuilds anyway.

Two things change that were not behaviour anyone could rely on. Genesis was
stamped with time.Now, so its id — the hash of its own fields — differed on
every node and every restart; it now comes from genesis itself, as on K and F.
And the per-VM GPU session the factory allocated was never read and never
released.

Restoring the original Accept fails all three atomicity tests.
2026-08-28 08:23:46 -07:00
zeekay 6220024728 chain: a block lands whole or not at all
Nine chains each write their own Accept, and five of them write it with
individual Puts that return early. A later write failing leaves the earlier
ones on disk and the chain already believing the block was accepted: state is
half-applied and there is no path back.

The discipline two chains already have becomes the only one there is. A block
declares what it Writes and what it Publishes; the store stages the writes
through a versiondb, commits them in ONE batch with the block, its height entry
and the tip pointer, and on any failure rolls the view back and rebuilds the
caches from committed state. Nothing the block claimed survives and the tip
does not move. Publish runs after the commit, so there is no window in which
the chain has advanced past state that is not on disk.

Beside it: the blocks in flight, under the store's one lock, pruned at or below
the accepted height because the engine may abandon a block without ever
deciding it; the pool, whose claim set is derived from its queue so a claim
cannot outlive the entry that made it; the fee a chain declares, which admits
nothing until it is declared; and the factory, whose VM holds nothing until
Initialize.

Seven mutations of the store and three of the pool each fail at least one test.
2026-08-28 08:15:44 -07:00
zeekay af188362b1 fhevm: a verified parent is findable, whoever built it
The in-flight block set was written in exactly one place — BuildBlock — so a
block arriving from a peer was never findable by id and its child failed
Verify with "parent not found". That is the ordinary shape whenever more than
one block is in flight, so a follower could not follow.

Both paths now register through trackVerified: the block we build, and the
block we verify from a peer. The read lock a Verify holds cannot be upgraded,
so the checks finish under it and the registration takes the write lock on its
own — pendingBlocks keeps one owner, which is the property this VM already has
a test for.

trackVerified also prunes, because nothing else would. The engine may drop a
block it never accepts and never rejects, so a set that only ever grew would
leak the way an unreleased mempool claim did; anything at or below the last
accepted height is decided or orphaned, which bounds it to what is actually in
flight.

This fixes the parent lookup and not the layer under it: Verify reads nonces
from committed state, so two blocks in flight from the SAME payer still cannot
both verify. Doing that needs a per-block state overlay. keyvm has the same
limit, and the test says so rather than implying otherwise.
2026-08-28 07:44:42 -07:00
zeekay 2e7c1fb58c fhevm: a block every validator certifies, every validator can apply
Adversarial review built thirteen attacks and landed thirteen. The worst
was a permanent chain halt for the price of two transactions, and it
came from the function written to prevent exactly that.

effect() named an epoch-advance vote by the PROPOSAL. One member voting
for two different committees therefore produced two different effects:
both passed admission, both passed Verify against committed state, and
Accept applied the first and refused the second. Verify is deterministic,
so every validator certifies that block; Accept is deterministic, so no
validator can apply it, and the engine stops at that height by design.
The decision a member votes on is the epoch, of which exactly one is ever
open, so the vote is named by the member alone.

That class runs deeper than one function. checkAuth reads state an
earlier transaction in the same block can change, so ANY block-time
authorization verdict can differ from the application-time one. So
authorization is no longer asked in Verify at all: it is decided once, in
Accept, and a transaction that fails it REVERTS — burns its fee, consumes
its nonce, changes nothing. Verify asks only that a block is well-formed,
authentic, ordered and paid for.

The queue could be poisoned for free. Admission took any nonce above the
committed one; Verify demanded exactly the next; and a Verify-failed
block is DISCARDED without Reject, so the transaction vanished while its
effect claim did not. Register's effect is payer-independent, so any
funded account could permanently block any handle, unlimited handles,
zero cost, taking the rest of the mempool along each round. Admission now
applies the same nonce rule consensus does, counted over what is queued;
BuildBlock SELECTS from the mempool rather than draining it, so a
discarded proposal costs nothing; and one shared rule (batch.admit) both
proposes and verifies, so a proposer cannot build a block it would itself
reject.

Chain time was whatever the proposer said. Nothing bounded a block's
timestamp or height against its parent, and chain time drives every
expiry — so a block could be back-dated to revive an expired permit or
stamped year 36812 to expire everything at once. Height is consecutive
now, time is monotone, and the future is capped; a proposer whose clock
trails its own tip declines to propose rather than emit a block nobody
can verify. What remains is the gap since the last block, which is what a
chain can promise about time, and a test measures it rather than leaving
it unexamined.

"Structural, not a matter of discipline" was discipline. Gas had no
length term, nothing bounded Payload or Scheme, and encoding/json ignores
what it does not recognise — so a megabyte of ciphertext body rode on for
the price of the handle meant to replace it, and came back out of the
block store. Payloads decode as EXACTLY their schema, bulk is bounded
before it is decoded, stored bytes are priced by the byte, and the
persisted field list is pinned so a new field has to be written down.

A committee deduplicated NodeID while voting identity is
addressOf(PublicKey): n seats could share one key, seating a threshold
one account could never reach, for decryption or for rotating itself out
— and it reported Healthy. And a vote was spent by being cast, so a split
committee could never converge; at unanimity one member wedged rotation
alone, and an honest DKG race did it with nobody attacking. Seats are
deduplicated by the identity that votes, and a member's latest vote
replaces its earlier one.

pendingBlocks had two mutexes and one map, which in Go is a fatal throw.
It has one owner. Fixing it surfaced the same bug in HealthCheck, which
read height and the burned counter after releasing the lock.

Revocation did not reach an in-flight decryption: the committee answered
anyway and the plaintext went to the callback. It reaches it now. Expiry
deliberately does not — it bounds the ask, not the answer.

Also: block parsing walked past bytes no length covered, so a block had
many encodings and one id; the type walk skipped interfaces and exempted
every 32-byte array from its own name check.

Two things F still does not have, for one reason, now written down rather
than implied: no state root, and no verification of a child of a block
not yet accepted. Both need a state layer per in-flight block. A root
checked only at Accept would be worse than none — a wrong one would pass
Verify everywhere and fail Accept everywhere, which is the halt this
commit is mostly about. Until that layer exists, determinism is enforced
from outside consensus by replaying a chain onto independently built
nodes on different clocks and requiring byte-identical databases.

The package doc claimed a state root as the reason F owns its
persistence. It named a mechanism the chain does not have. The decision
stands; the justification is now the true one.
2026-08-27 17:28:32 -07:00
zeekay 2656b30487 fhevm: F-Chain coordinates confidential compute without holding it
F-Chain existed as a reserved vmID and a spec. luxfi/constants declares
FHEVMID and node/node/vms.go lists fhevm in OptionalVMs, so luxd scanned
--plugin-dir for a binary nothing produced. This builds it.

F is the chain of record for encrypted values it never holds. It stores a
ciphertext's 32-byte handle, the digest of the off-chain body, its owner,
the capability permits granted over it, and the threshold decryptions
asked for and answered — and no ciphertext body, no FHE key, no share.
invariant_test.go proves that by walking the type graph and scanning the
package source for the runtime's secret-bearing entry points.

Six operations, each fee-settled through consensus on the shared
chains/fee primitive and priced by scheme and ring dimension, because a
TFHE boolean and a CKKS n=2^15 vector cost the committee amounts that
differ by an order of magnitude. Two of them are threshold decisions: a
decryption completes when Threshold distinct members attest the same
result handle, and an epoch installs when Threshold members of the
sitting committee attest the same successor. Votes are tallied per value
and a member votes once, so one member naming a false result delays
nothing and pays for the attempt; the deciding transaction carries the
proposal, so nothing pending is ever stored.

It WRAPS mpcvm/fhe rather than restating it — the records embed that
package's types and the public parameters come from its threshold
configuration — but owns its persistence, because that package's Registry
stamps time.Now(). That is right for the off-chain daemon it was written
for and wrong for a state root: two validators replaying one block would
write different bytes. Every timestamp F writes comes from the accepting
block.

Shape follows keyvm: versiondb with a single commit and a rollback that
reloads caches, fail-closed verification, a nonce replay guard, and a
per-payer cumulative fee check within a block. Two things go further.
GetBlockIDAtHeight answers from a real index written in the block's own
commit rather than refusing. And a transaction names the state entry it
writes, so admission refuses a second claim on it and consensus refuses a
peer's block carrying both — without that, one payer's nonces n and n+1
can each pass every check and only collide in Accept, and a rejected
block requeues, so the pair rebuilds indefinitely.

The Makefile names its targets instead of globbing the directory: the
glob swept in dexvm, fee, ownership, internal and cmd, and stopped at the
first with no plugin entrypoint, so `make` built nothing at all.
2026-08-27 16:23:29 -07:00
zeekayandHanzo Dev 8daf8f12aa aivm: a task reaches its verdict because the chain advanced
Operators were selected, bonded, committed and revealed, and then nothing paid
them. Settle — which tallies reveals, credits the honest majority, slashes
withholders and folds a receipt into the root — ran only from the demonstration
driver. The production block path imported intents, created tasks, and never
gave any of them a verdict, so a task sat past its reveal deadline forever and
the work done for it was credited to nobody. Open mining rests on the opposite:
an operator that did the work is paid without anyone asking on its behalf.

Every block now settles the tasks whose reveal window has closed. The set is a
function of the state and the height alone, which is what lets a follower reach
the same verdicts without being told: it applies the block's imports, runs the
same pass at the same height, and compares receipt roots. A disagreement about
who was paid changes the root, and verifyImported already refuses that — so this
needs nothing recorded in the block.

Settling requires enumerating tasks and engine state is keyed by slot hash with
nothing to iterate, so tasks awaiting a verdict are held in an array in state:
appended at creation, removed on reaching one. It holds only what is open, so a
block's work is bounded by the tasks still outstanding rather than by everything
the chain has ever done. An entry that cannot settle is dropped rather than
retried, since leaving it would make every later block re-attempt the same
refusal.

The test drives the real lifecycle and then does nothing but produce blocks: it
fails without this, reporting a task past its deadline with the work unpaid.

Registration was already open by default; its comment now says so, and the
permissioned variants are described as deployment policy.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-26 14:58:10 -07:00
zeekayandHanzo Dev 34226894ca zkvm: say why the PLONK precompile accepts nothing
The reason is the property, not a version that had it wrong: a verifier that
does not evaluate the statement is not a verifier, since it would accept any
well-formed blob. The equation is unimplemented here, so this accepts nothing.

Comment only.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-26 13:35:19 -07:00
zeekayandHanzo Dev 729eedaffa docs: record how a chain here tells consensus there is work
Two sections. The first is the one way a VM reports pending work — vm.Latch, on
the pool that learns of it rather than on the VM — and the two chains that
deliberately do something else: schain and dexvm notify on the ToEngine channel,
and graphvm builds no blocks because it indexes what other chains have already
agreed. That distinction is worth writing down: a parked WaitForEvent is not
automatically a defect, and the check is whether anything sends on toEngine.

The second is where a B-Chain request comes from — the source chain's Locked
events, keyed by the transfer digest the gateway replays against — and what the
watcher will not do: read within reorg range of the head, ask for an unbounded
backlog, or skip a chain it could not reach.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-26 13:32:57 -07:00
zeekayandHanzo Dev f377c29746 chains: comments state the contract, not the history behind it
Across these files the comments explained a rule by recounting what happened
without it — which key was mistaken for which, which payer a full pool released,
what a verifier accepted, what a chain did with work it never announced. That
tells a reader what changed rather than what holds, and it dates: the account
stays in the file long after anyone needs it, while the rule it was meant to
convey has to be inferred back out.

They now state the rule. A circuit is judged on its own key. A witness is
exactly as long as the key's circuit says. A count describes the records it is
read from. A full pool releases what pays least. Custody membership costs stake
because an identifier can be aimed and stake cannot. Consensus builds when
WaitForEvent returns, so accepting work and reporting it are one step.

Behaviour is unchanged; the diff is comments, with one exception worth naming:
gofmt realigned a block of one-line methods in quantumvm/feegate_test.go that
was already unformatted before this commit.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-26 13:17:16 -07:00
zeekayandHanzo Dev e900f2b6d2 zkvm: the same witness rule at the precompile's door
The 0x80 precompile takes both the verifying key and the public-input count from
its caller's calldata, so a caller picks both and they have to agree. Its comment
said so — "numInputs must match vk.K length minus 1" — while the code checked
only that there were not too many. Too few leaves the trailing K points out of
the linear combination and judges the proof against a smaller statement than the
key describes, which is the same hole just closed in the VM's own verifier and
reachable here by anyone who can call the precompile.

The comment described the rule; now the code does.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-26 10:20:43 -07:00
zeekayandHanzo Dev 645aef166f zkvm: a witness is exactly as long as the key's circuit says
A verifying key holds one K point per public input plus a constant term, so it
states how many inputs the circuit it was made for takes. The witness arrives
with the transaction, so a peer picks its length, and only the upper end was
checked. Too few is quieter and no better: the linear combination runs over the
witness, so a short one leaves the trailing K points out and judges the proof
against a smaller statement than the circuit was compiled for.

The count is now exactly what the key states. That is read off the key, not
assumed about the circuit — how many inputs a circuit takes is the key's to say,
while what the trailing ones MEAN still needs whoever owns the circuit. They
remain bound in count and unbound in value.

🪤 The test named for reaching the pairing kept passing after this change while
no longer reaching it: it built a key stating three inputs, supplied two, and
asserted only that the error said "verification failed" — which the new refusal
also says. Its key now states the two it supplies, and it asserts the pairing's
own verdict, so it fails if anything in front of the arithmetic answers first.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-26 10:19:28 -07:00
zeekayandHanzo Dev dfdf4fb222 evm: the Metal anchor's C half carries the same build tag as its Go half
The Go side of the secp256k1 Metal anchor is built only under
cgo && lux_cevm_native && darwin. Its C file carried no constraint at all, so
every other build still saw a C source in a package it was compiling without
cgo, and refused: "C source files not allowed when not using cgo or SWIG".
That took out `go build ./...` for the whole repository, not just this package.

A file and its other half now agree about when they exist. Under the tag the
anchor is still compiled in — go list reports it in CFiles — and without it,
neither half is.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 13:13:14 -07:00
zeekayandHanzo Dev ea1b0efe60 graphvm: say that this chain builds no blocks, and bridgevm: one status type
G-Chain indexes what other chains have already agreed, so it holds no state of
its own to agree on and never advances past genesis. That was written down in a
comment on its state and nowhere the code could be read from: BuildBlock
answered a generic "not implemented", which reads as an omission, and
WaitForEvent's comment said it would one day wait for queued schema updates,
which invites exactly the latch the other chains just got. Both now name the
reason. A block here would carry a query result, which a peer recomputes from
state, or a schema, which is local configuration; neither is something the
network votes on. A test pins it, so the difference between a chain with nothing
to say and a chain with no way to say it stays visible.

BridgeRequest.Status was a string whose valid values lived in a comment beside
it, while the same lifecycle already had a type and eight constants on
BridgeRequestRecord. One concept, two representations, and the one the watcher
wrote to was the untyped one. It now uses the type.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 13:09:57 -07:00
zeekayandHanzo Dev 6732e966d0 keyvm, mpcvm: one latch, not one per chain
Both had grown the same thing: a depth-one channel, a non-blocking send guarded
by a select with an empty default, and a WaitForEvent selecting over it and the
context. That is what vm.Latch is, so they use it. Four copies of the pattern
become none, and the comment explaining why the send is non-blocking lives with
the primitive rather than beside each caller.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 13:06:58 -07:00
zeekayandHanzo Dev 0855da5ec2 zkvm: a PLONK proof is whole or it is not a proof
The decoder documented 736 bytes and accepted anything from 544 up, leaving
whichever of the five evaluations were missing at zero. A truncated proof
therefore decoded as a well-formed one carrying values nobody sent, and the
comment explaining it said the scalars were optional in some formats without
naming one. It now takes exactly the size it describes, and refuses trailing
bytes for the reason they are refused elsewhere here: bytes the format does not
describe are bytes two different proofs can differ in.

Also removes bytesReader, which nothing called.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 12:52:31 -07:00
zeekayandHanzo Dev 7f1590dd32 zkvm: a circuit nobody keyed has no key
An unkeyed circuit was given an all-zero placeholder verifying key, and whether
those placeholders counted as real was decided for the whole verifier at once —
"dummy" was true only while EVERY key was zero. So keying a single circuit
turned that protection off for the rest, and their zeros were then used as
verifying keys. The rule failed in the worst direction there is: the more of a
chain an operator configured, the less of it was guarded. That is what let a
shield proof be judged against zeros on a chain keyed for transfers.

Rejecting the identity at decode already closes that, but the placeholder is
what made it reachable. A key that is absent cannot be mistaken for one that is
present, so no placeholder is installed and the circuit is refused by name — at
the lookup that has always checked for a missing key and never fired, because
one was always there.

dummyKeys keeps its meaning for the classical path, which asks whether this
verifier holds any key at all; it is now read off the keys rather than
recomputed from their bytes.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 12:50:47 -07:00
zeekayandHanzo Dev e9fe7d7ec3 chains: ignore .DS_Store
macOS writes one into any directory it is browsed through, and one had already
turned up untracked beside the quantumvm sources.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 12:42:08 -07:00
zeekayandHanzo Dev 9724b6629d zkvm: refuse a PLONK proof rather than bind it to nothing
verifyPLONKPairing checked e(Wz + u·Wzw, [α]₂) = e(z·Wz + u·zω·Wzw, [1]₂) and
nothing else. It computed the public-input polynomial at the challenge point and
threw the value away, and never read the selector, permutation, quotient or
evaluation commitments. So it related the two opening proofs to each other and
said nothing about the statement being proved, and a proof bound to no statement
is a proof of anything.

The precompile at 0x81 reached this conclusion about its own copy and fails
closed for it, with the reasoning written down. This is the same rule at the
second door. Refusing costs nothing that works today: an honest prover's proof
does not satisfy that equation either, so the path accepted only what should
never have been accepted.

Decoding still runs, so a caller can tell a malformed proof from one this will
not judge, and no path through it returns nil.

The test that covered this asserted only that the error mentioned PLONK, which
the refusal itself satisfies — it would have passed without testing anything. It
now pins the refusal to its stated reason, so a decode failure or a panic cannot
stand in for it, and it still proves the decode path runs without the nil
dereference that used to panic there.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 12:41:15 -07:00
zeekayandHanzo Dev 5397b6f5da zkvm: a point at infinity is not a point this verifier will read
Three of the four verifier failures were one mistake: BigInt(nil) assigns
through a nil receiver. msmCPU panicked on every call, the Groth16 linear
combination on every proof carrying a public input, and PLONK unconditionally.

Removing the PLONK panic uncovered what it had been hiding. gnark decodes
all-zero bytes to the point at infinity without error, reports it as in the
prime-order subgroup, and a pairing check drops every term whose argument is
infinity — so an element at infinity removes itself from the equation, and a
check with every term removed is a product of nothing, which is one, which is
true. loadVerifyingKeys installs an all-zero placeholder for each circuit an
operator did not key. So on a chain keyed for transfers, a shield proof was
judged against zeros and every one of them verified. Mint from nothing.

checkG1 and checkG2 are now the one place that decides what a usable point is —
in the subgroup and not the identity — and every decoder routes through them, so
a decoded proof carries the invariant instead of each caller re-deriving it. The
precompile had the same hole from the other direction: it takes its verifying
key from calldata, so the caller chose it, and a degenerate key made Run answer
0x01.

The witness bound was off by one. K holds a point per public input plus the
constant term, so it covers len(K)-1 inputs, and a peer-chosen count of exactly
len(K) read one past the end — a remote kill from a well-formed message. It was
off by one in two places; the second was a duplicate of the whole pairing check
on a GPU path that also overwrote the CPU's linear combination with an unverified
GPU result. A GPU returning a wrong point flips a verdict on the nodes that have
one, which is a fork rather than a slow node, so that copy is gone and one
deterministic check serves both the single and batch paths.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 12:37:40 -07:00
zeekayandHanzo Dev b384ef94bb identityvm: tell consensus a credential was issued
I-Chain accepted credentials and never built a block from them. WaitForEvent
answered with <-ctx.Done(), which never returns, so BuildBlock was never called
and the chain could not leave genesis however many credentials were issued.
IssueCredential now signals, and WaitForEvent is the shared latch.

BuildBlock also built a block whatever was pending, including nothing. An empty
block says nothing and still has to be voted on, and every other chain here
refuses one, so it refuses too. Three tests built a block off an empty VM as
setup rather than as a claim about empty blocks; they now issue a credential
first, which exercises more than they did before.

The liveness test fails without the signal, waiting its full timeout for a wake
that never comes.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 12:34:59 -07:00
zeekayandHanzo Dev 318794d558 zkvm: rebuild the UTXO set from the records, and stop writing under a read lock
loadUTXOs was `return nil`. The in-memory set was never rebuilt, so a restarted
node came back with an empty one: it accepted a commitment it already held —
reaching the opposite verdict from a running node on the same block — and
reported no commitments and no UTXOs at any height. The count came from a
separately persisted key, a second copy of the truth that a crash between the
two writes leaves disagreeing with the records, and one removal from there
drives an unsigned counter below zero. That is the same defect just removed from
the nullifier count, so the UTXO set now works the same way: the records are the
set, and the count is read off it.

Blocks live in the same database under their raw ids, and one id in 256 begins
with the UTXO prefix. The loader now counts a record as a UTXO only when it
names the commitment its own key is made of; a block frame parses as a UTXO
otherwise, since the accessors are bounds-safe and return zeros, and a garbage
commitment would join the set.

Both read paths memoized what they loaded while holding only a read lock. The
memo is deleted rather than the lock upgraded: the map is loaded with the whole
set at startup and maintained by the writers, so the disk read already returns
what the memo would have, and a write lock on a path consensus and RPC share
would serialize every reader to save a lookup. A nullifier value that is not
eight bytes is now a miss rather than a panic in a consensus path; the loader
already skipped those.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 12:30:37 -07:00
zeekayandHanzo Dev d924766854 zkvm: decide canonicality once, and refuse a length the blob cannot back
parseTransaction never compared the frame's declared size against the buffer it
was handed, so bytes appended to a transaction decoded to the same transaction.
parseUTXO and parseBlockBytes each carried their own copy of that check and the
transaction, along with every sub-frame, had none. That is also how a padded
transaction slipped past the block canonicality check one level up: a tx blob
inside a block carries its own length, so padding it leaves the outer frame
honestly sized and the outer check blind. Now parseFrame does Parse and the size
check together and all ten parsers go through it, so canonicality is decided in
one place instead of per type.

unpackBytesList answered a length that overran its blob by breaking out of the
loop, returning fewer nullifiers than the peer declared. The transaction a node
acts on is then not the one it was sent, and a nullifier declared spent is never
marked spent. It now refuses a length nothing backs, and refuses blob bytes no
length claims — the other half of the same lie, which would otherwise re-encode
to different bytes. Capacity still comes from the length vector, which the frame
bounds, never from a declared length.

One assertion changed. The audit held that a padded block parses and then
re-encodes to the bytes it parsed from; those cannot both hold once the inner
frame refuses its own padding. Keeping the first would mean storing and
re-gossiping a non-canonical frame whose id does not commit to the padding,
which is malleability by construction. Refusing at parse keeps it out of state
entirely, and a positive control shows the same block without padding still
parses.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 12:30:22 -07:00
zeekayandHanzo Dev 68a2d33eb9 bridgevm: serve the API that exists, and answer health with what is true
The node served three handlers that answered the same JSON whatever the state:
/status said "operational" always, /validators said none always, and /bridge
took nothing. Meanwhile the bridge's real API — estimate a fee, submit a
request, ask after one, read the signer set, seventeen methods in rpc.go — was
written and never reached CreateHandlers, so nothing served it. Same shape as
the lock reader: the code was there and nobody called it. CreateHandlers now
returns it and the three canned handlers are gone.

HealthCheck answered Healthy: true unconditionally, which routes transfers at a
node that cannot carry them. It now reports what the node can do. A node with no
chains wired is not a relayer and stays healthy — that is a configuration, not a
fault, and calling it unhealthy would take validators that never meant to relay
out of rotation. A node that IS configured to relay but has no threshold key to
attest with, or is not reading the chains it relays from, says so.

One function decides this and both surfaces read it, so bridge_health and the
node's own check cannot disagree about the same node.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 12:29:04 -07:00
zeekayandHanzo Dev 9a898cb620 bridgevm: read the source chains, so a lock becomes a block
B routes an attested release after value is locked on the source chain, and it
learns of that lock from the chain's own Locked events. Nothing read them.
FetchLockEvents was written, was not on the ChainClient contract, and had no
callers, so nothing outside a test ever wrote to the pending queue: BuildBlock
answered "no pending bridge requests" forever and B could not leave genesis
however much was bridged through the gateways. WaitForEvent waited only on the
context, so even a full queue would not have been built from.

The watcher polls each configured chain, reads the range it has not read yet,
and turns each lock into a request. It is keyed by the transfer's canonical
digest — what M signs and what the destination gateway keys its replay guard by
— so two chains reusing a nonce cannot collide and the same lock read twice is
the same request rather than a second one.

Three things it will not do. It reads twelve blocks behind the head, so a lock a
reorg takes back never becomes a request. It bounds one pass, so a node that has
been down asks for its backlog in pieces rather than in a query no endpoint will
answer. And a chain it cannot reach keeps its cursor, so an endpoint having a bad
minute does not skip that chain's blocks.

The depth a lock is buried at rides along as its confirmation count, which is
what BuildBlock already gates on, and the source transaction rides along with it.

Tested end to end against a fake chain: a lock is read, consensus is told, and
the block that gets built carries that transfer and no other.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 12:24:07 -07:00
zeekayandHanzo Dev d1f510cde2 zkvm: a full mempool gives up its cheapest transaction, not its best
Eviction popped the heap. The heap is ordered highest-fee-first, so a full pool
discarded its best payer — inverting the fee market and throwing away exactly
the transaction the next block would have taken first. It then removed that same
transaction a second time by ID. The variable was called lowest.

A full pool now gives up whatever pays least, and refuses an arrival that is
itself the cheapest, since accepting it would mean dropping a better transaction
for a worse one. The heap's root is the most expensive, so the cheapest is found
by walking it; the pool is bounded and this runs only when it is full.

MempoolTx.priority was written in three places and read in none. That would be
harmless dead weight except that GetPendingTransactions sorts a copy of the heap
which holds the same pointers, so sorting wrote through them while holding only
a read lock. Removing the field removes the race with it — the detector reports
it as a write in TxHeap.Swap once the field is put back.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 12:16:28 -07:00
zeekayandHanzo Dev fdf55575b2 zkvm: a transaction ID that commits to the transaction
ComputeID hashed the type, version, nullifiers, commitments, proof, fee and
expiry — and nothing else. The transparent inputs and outputs were absent, so
who is paid and how much could be changed without changing the transaction's
identity. Consensus decides between blocks by ID and the mempool keys on it, so
that is a payee an attacker can rewrite for free; the block-level test failed for
the same reason one level up.

The variable-length fields were also concatenated with nothing between them,
which lets a byte move from the end of one field into the start of the next
without the hash noticing — ["ab","c"] and ["a","bc"] are the same bytes. Every
field now carries its length and every list its count, so one preimage means one
transaction. The memo and the remaining shielded fields are covered too, since a
field that is not hashed is a field that can be changed.

The signature stays out: it is made over this value, so including it would be
circular and would let a second valid signature give a different identity to a
transaction moving the same value.

Identities change, which is a hard fork. Z-Chain has produced no blocks — until
the WaitForEvent fix it could not — so there is nothing to migrate.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 12:12:39 -07:00
zeekayandHanzo Dev 7cce60fbe7 zkvm: count the spent set instead of keeping a total beside it
The spent-nullifier count was a running total persisted next to the records it
described. Two writes, so a node that died between them came back with a number
that disagreed with its own set — and RemoveNullifier decremented an unsigned
counter with no floor, so unwinding from there wrapped to 2^64-1 and every
dashboard reading it was wrong from then on.

Flooring the decrement would have hidden that; the count would still have been a
second thing that has to agree with a first. Every nullifier record is loaded at
startup and nullifiers are never pruned, so the cache is the whole set, and the
count is now read off it. It cannot disagree with what it describes, the stored
key is gone, and so is the arithmetic that could underflow.

Starts the test from the state that used to be unrecoverable: a record on disk
with no counter beside it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 12:09:09 -07:00
zeekayandHanzo Dev c7c3e30baf zkvm, quantumvm, aivm: tell consensus there is work
zkvm and quantumvm accepted transactions and never built a block from them.
Consensus calls WaitForEvent and builds nothing until it returns; both answered
with <-ctx.Done(), which never returns. So BuildBlock was never called and
neither chain could leave genesis, however many transactions arrived. The
comment on one of them said it had to block to avoid flooding the engine, and
the flooding was real — but never returning is not the way to avoid it.

The pool that accepted the transaction is what knows there is work, whichever
path it arrived by, so the pool holds the latch and signals on a successful add.
That covers the paths that reach it directly as well as the HTTP handler, and
the VM's WaitForEvent is then one line. aivm had grown its own copy of this;
it now uses the shared one.

Each test fails without its signal, waiting the full timeout for a wake that
never comes.

Bridge and graph VMs are untouched: bridgevm has nothing in production that
writes a pending request, and graphvm's BuildBlock is not implemented, so a
latch in either would signal work that cannot be built.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 12:04:47 -07:00
zeekayandHanzo Dev ceb9487dc5 aivm: wake the engine when work arrives, so A-Chain can build
WaitForEvent returned only on ctx.Done(), so the engine was never told a block
could be built and A-Chain could not leave genesis. The comment called the
permanent block CRITICAL to avoid a notification flood — a depth-1 channel sent
non-blocking answers that concern properly: concurrent submissions collapse into
one pending signal.

SubmitTask and SubmitResult now signal after a successful submit, and the mutex
is released before signalling so a full channel can never hold the VM lock.
Mirrors the working shape in keyvm.

toEngine remains declared but unused here; the engine is driven through
WaitForEvent, which is the interface the manager actually calls.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-24 10:54:28 -07:00
zeekay a0b4f9aaec evm: cgo is not a claim that luxcpp is installed
The evm plugin turns cgo on for blst. Doing so also pulled in two
optional native backends keyed on bare cgo, neither of which a build
host carries by default, so `go build ./evm` needed a luxcpp and a
luxgpu install or it did not compile:

  # github.com/luxfi/chains/evm/cevm
  # [pkg-config --cflags -- lux-cevm lux-crypto-secp256k1]
  Package lux-cevm was not found in the pkg-config search path.

  # github.com/luxfi/accel/ops/code
  code_cpu.go:102:10: fatal error: lux/gpu/hqc.h:
  No such file or directory

Both are the same conflation: "a C compiler exists" is being written
where "this C library is installed" is meant. evm/cevm gets the second
predicate it was missing, lux_cevm_native, and accel v1.3.2 restores
lux_hqc_native on its side. cevm_nocgo.go and cevm_secp256k1_nocgo.go
already mirrored the full API for exactly this case, so the default
build takes them and reports CPUSequential; -tags=lux_cevm_native links
libevm/libevm-gpu unchanged.

The tests move with the predicate they actually need. TestABIVersion
locks the linked library's ABI constant, so it belongs beside
TestLibraryABIVersion in the native file rather than in the untagged
one, where it asserted 6 against the stub's documented 0 and failed
every CGO_ENABLED=0 run.

Verified: all twelve VMs build at their images.json cgo setting;
go build ./... and go test ./evm/cevm/... green at CGO=0 and CGO=1;
-tags=lux_cevm_native still selects cevm_cgo.go + cevm_secp256k1.go.
2026-08-21 00:14:14 -07:00
zeekayandHanzo Dev b7a9c2110e aivm: state the handler contract as it is, not as one case of it
The rule is that a handler must not dispatch on the request path, because the
node delivers the path the request arrived on. A flat map of endpoints answering
at their mounts is the simplest shape that satisfies it, and this VM uses it —
but an endpoint also owns the paths beneath it, and a VM that wants a subtree may
mount once and read the remainder. Both are served. Writing the simple case as if
it were the only one made the comment false the moment the other landed.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-20 21:01:18 -07:00
zeekay 203cfbc643 carry the github copy's 1 commits onto the forge
This repo is written on git.hanzo.ai and mirrored to github.com, and the two had drifted apart — 8 commits here the mirror lacked, 1 there this host lacked. Merging rather than overwriting so neither side's work is lost.
2026-08-20 20:56:57 -07:00
zeekayandHanzo Dev 74ebf70301 chains: consensus v1.36.63
The catch-up epoch door is walked through by a test rather than described by a
comment.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-20 19:58:14 -07:00
zeekayandHanzo Dev 8781ffbd7a bridgevm: the release path answers to the key it was configured with
VerifyAgainst refuses a signature made by a key it was not told to expect, and a
test proves it. Nothing proved the CALLER passes the right key. Handing it
att.GroupPubKey instead of vm.mpcGroupPublicKey() -- the exact pre-fix shape, the
one that let a self-consistent forgery through -- left the whole bridgevm suite
green, because no test had ever entered releaseTransfer.

Nothing could. releaseTransfer routes through evmByChainID, typed to
*evmChainClient, and that type dials RPC endpoints in its constructor. The
package already declares ChainClient and already asserts evmChainClient satisfies
it; the sibling map chainClients is already typed to it. Only the id-keyed index
reached past the interface to the implementation. Widening it and moving
IsProcessed onto the contract leaves every object and every call unchanged and
lets the funds path be given a double.

Three refusals now hold: an attestation signed by a key it supplied itself, a
genuine attestation on a chain whose custody keygen has not completed, and no
attestation at all. Each asserts that the destination gateway received zero
broadcasts, because broadcasting is the release. An already-processed transfer is
the positive control, proving the rig reaches the gateway and the refusals are
decided where they claim to be.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-20 19:35:27 -07:00
zeekayandHanzo Dev d94a393b60 aivm, graphvm: the handler answers at its mount, it does not route
CreateHandlers returns endpoint -> handler, and the node mounts each KEY as an
exact route, then delivers the request on the path it arrived on. A handler that
dispatches on r.URL.Path therefore never matches: it is asked for
/v1/bc/<chainID>/rpc and looks for /stats.

aivm returned one ServeMux under "/rpc" carrying twelve endpoints, and graphvm
switched on r.URL.Path behind a single mount. Both were mounted correctly and
both 404'd every request, so every endpoint either VM offers was unreachable by
construction — not misconfigured, unreachable.

The chains that serve do so because their handlers are path-blind: the gorilla/rpc
servers in keyvm, quantumvm and mpcvm read only the body, and bridgevm mounts bare
HandlerFuncs. That is the whole difference, and it is the contract: one map entry
per endpoint, and the key IS the route.

The old aivm test asserted the handler map contained "/rpc" — it locked in the
broken shape and never asked whether a request could reach it. The new test asks
for each endpoint at the path the node actually mounts, which is the only question
that distinguishes the two designs.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-20 19:28:26 -07:00
zeekayandHanzo Dev fbfeb9a49d bridgeattest: verify against the key we trust, not the one supplied
Verify() checked the signature against a.GroupPubKey — a field of the very struct
being verified. That answers "was this signed by whoever signed it", which every
attestation satisfies, including one an attacker minted with its own key and
shipped alongside. B-Chain called it immediately before releasing funds, so a
self-consistent forgery passed the one check standing between a request and a
real transfer.

The expected key is an argument now, because it is the whole check: a signature
is evidence only when the verifier already knows whose signature it will accept.
The caller has it — B-Chain holds the group public point from M-Chain's keygen
and never holds a custody secret. A nil or empty expected key fails closed, which
is the state of a chain whose keygen has not completed, and exactly when
releasing funds would be worst.

The attestation still carries its own key so a holder can see which group signed,
but a disagreement with the key we trust is a refusal, not a preference for
theirs.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-20 17:39:48 -07:00