369 Commits
Author SHA1 Message Date
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.
v1.7.35
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