Commit Graph
238 Commits
Author SHA1 Message Date
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.
v1.7.34 mirror/main-70a2333
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]>
v1.7.33
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]>
v1.7.31
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]>
v1.7.30
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]>
v1.7.29
2026-08-20 17:39:48 -07:00
zeekayandHanzo Dev 1df746407a mpcvm: custody seats follow stake, and two seats are no longer a quorum
Seats went to the first N validators in canonical order, and canonical order is a
lexicographic sort of node ids. A node id is a hash of the node's certificate, so
an attacker grinds certificates offline until its ids sort first -- roughly 256
hashes per leading zero byte, and a zero byte encodes as the smallest base58
digit. Validator entry is permissionless, so that bought a seat in the share set
of EVERY key the chain would ever generate, at the price of some hashing.

Under the 2-of-3 default, two such seats were a quorum. Nothing needed to be
reconstructed at that point: the holder runs the signing protocol locally with
both shares and signs whatever it likes, off-chain, with no block produced and
nothing to observe.

Seats now follow weight, which cannot be ground -- entering the set costs stake
proportional to the stake already there, the same cost as attacking consensus, so
custody stops being the cheaper target. Node id still breaks ties, but now only
between validators of equal stake, where grinding buys a position among peers you
already had to match. Selection stays a pure function of replicated state, so
every validator derives the same set without negotiating one; the ordering test
runs it repeatedly because Go randomises map iteration and a selection that leaks
it would give each validator a different set.

The default policy is 3-of-5. Two seats are deliberately not a quorum, 2K > N
still holds so the quorum is unique, and the end-to-end DKG-and-sign test now
runs the real ceremony at that policy over the gossip fabric.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.28
2026-08-20 14:29:52 -07:00
zeekayandHanzo Dev 830ed497ae mpcvm: no ceremony primitive outside the authorized wrapper
RunSign and RunKeygen stayed exported through the authorization change, still
taking the chain-name string that change removed. The wrapper became unforgeable
while the thing it wraps was reachable beside it, consulting no permission, no
quota and no Caller. Lowercased; the only callers were in-package.

A permission entry whose chainId is a LABEL binds to no chain, exactly like an
empty one, because a label never equals a base58 chain id. Counting it as bound
made a stock node report five authorized chains while authorizing none -- which is
the misreading the boot warning was added to prevent, reintroduced by the warning
itself. Bound now means the chainId parses as a chain id.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.27
2026-08-20 13:55:29 -07:00
zeekayandHanzo Dev d87600fbef quantumvm: a block carries no signature of its own
ParseBlock demanded a field the wire has no room for. The block format is
timestamp, height, parentID, tx lengths, tx blob; the stamp lived only on the
object the builder made and was gone the moment the block was serialized, which
is every time it leaves the process. So the gate refused every block that ever
crossed the wire, genesis included, and Q-Chain accepted nothing on any network
for as long as it ran -- certAccepted=0, appliedHead=0, everywhere.

The two doors into the format disagreed, which is what made it a loop rather
than a stall. A responder answers a catch-up request through vm.parseBlock, which
has no gate and succeeds; the requester admits the reply through VM.ParseBlock,
which had the gate and failed. Same bytes, two verdicts: every node served the
block every node refused.

Putting the stamp on the wire would be worse than leaving it off. The block id is
sha256 of its own bytes, so a stamp in those bytes makes the id depend on who
signed, and two honest nodes compute two ids for one block. seedGenesis already
refuses wall-clock time for exactly this reason. The stamp could not have been
load-bearing anyway: it was made with a key generated fresh for each block, and a
signature that ships its own throwaway public key states nothing an attacker
cannot also state. Its thirty-second validity window is also the opposite of what
catch-up needs, which is to serve blocks from hours ago.

A block-level signature belongs to consensus, where it already is: a cert is a
quorum's statement about a block, checkable by anyone holding the validator set,
and it is not a field on the block. Quasar still signs there. Transaction-level
quantum signatures are untouched -- those are serialized, and they are verified.

The tests stayed green throughout because none of them sent a block through
Bytes() and back. Three do now: parse what we serialize, parse GENESIS, and the
two parse paths reach the same verdict on identical bytes. Restoring the gate
fails all three.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.26
2026-08-20 11:44:10 -07:00
zeekayandHanzo Dev b9f95308a8 mpcvm: custody answers to the transport, not to the request body
Signing authority was resolved from a name the requester wrote about itself.
CrossChainRequest already receives the sender's chain id from the transport,
which authenticated it, and then passed req.RequestingChain to the permission
lookup instead -- so the proven identity sat unused in the signature while a
field the sender controlled decided what it was allowed to do. The stock table
grants B-Chain sign, keygen and reshare at 100k/day, so the claim was worth
having.

Caller carries an authenticated chain and nothing else can be substituted for
it: the only constructor takes the transport's chain id, the fields are
unexported, and the zero value grants nothing, so a path that forgets to
authenticate fails closed instead of quietly inheriting rights. Making it a type
rather than a convention is what turns "remember not to read the body" into
something the compiler checks -- it listed every call site that had been passing
a claim.

Authorization binds to the chain ID, never to the label. A label is an
operator's convenience and anyone can spell it; the id is the identity consensus
itself uses. An entry with no chainId is bound to no chain and grants nothing,
and Initialize names those entries at boot, because silence there would read as
"custody is configured" right up until the first release failed.

HTTP keygen and sign are gone rather than guarded. That transport authenticates
no chain, so there was no check to write -- a body can say it is the bridge as
easily as it can say anything else. Ceremonies are requested by chains over the
cross-chain path; the HTTP surface reads replicated state, which needs no
identity.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.25
2026-08-20 10:47:08 -07:00
zooqueen 2082b0d1ec resolve modules through the public proxy with the checksum database authoritative
GONOSUMCHECK and GONOSUMVERIFY are not Go variables and Go never reads them.
The luxfi and lux-private patterns in GOPRIVATE/GONOPROXY/GONOSUMDB routed
public, notarized modules around sum.golang.org. Removing both keeps
verification on for every module these builds resolve.
2026-08-20 00:37:36 -07:00
zeekayandHanzo Dev a449465dc6 fhe: batch work follows the cores the machine has
The pure-Go batch paths split every batch across exactly four goroutines, so a
machine with more cores than that ran the remainder idle and throughput stopped
improving once a batch outgrew four chunks. Measured on an 8-core node (6
allocatable), forward NTT:

     batch    before          after
         8    10,229/s        11,223/s
        64    10,878/s        15,252/s
       256    12,617/s        18,989/s

Worker count now follows GOMAXPROCS, capped by the batch (an empty chunk is a
scheduler slot spent on nothing) and floored at one (a constrained GOMAXPROCS
must not yield a zero-worker loop that skips the batch).

The path had no test of any kind: every other file in the package is
//go:build cgo, so a CGO_ENABLED=0 build compiled this file and ran nothing
against it -- while being the build a CPU-only node runs, and the only one that
cross-compiles to a cluster node. Adds a round-trip correctness test and
benchmarks at the same ring parameters as the cgo ones, including 4 and 8 to
straddle the sequential/parallel boundary.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.24
2026-08-18 21:50:07 -07:00
zeekayandHanzo Dev 4a0611f1fb Merge remote-tracking branch 'origin/main'
Co-authored-by: Hanzo Dev <[email protected]>
v1.7.23
2026-08-18 18:25:32 -07:00
zeekayandHanzo Dev b0763957cc quantumvm: write the genesis block so Q-Chain can start
Bootstrap opens by asking each beacon which block it holds, and an answer
naming no block is not counted as a responder -- it neither backs a tip nor
raises the response floor (tally, node chains/bootstrap_trust.go). The VM
derived its state lazily from the signer config and never wrote a block, so
last-accepted stayed empty, every node answered with nothing, every reply was
dropped, and all five waited on each other for as long as the chain ran:

  waiting for the beacon quorum: 0 of 5 beacons responded, need 3

State you can execute against is not the same as a block you can name.

Initialize now writes height 0 when the chain has none. The block is a
constant -- fixed timestamp, empty parent, no transactions -- so every node
computes the same id alone; wall-clock time would give each node its own
genesis and make this a fork. It commits, or the tip lives in the version
layer only and the next start names nothing again.

Tests pin the id rather than compare two local VMs: the wire carries Unix
seconds, so two VMs seeded in the same second agree even with a wall-clock
timestamp, and that assertion would pass for a build that forks in production.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-17 15:08:21 -07:00
zeekayandHanzo Dev 464956eded the signing endpoint no longer asks for a private key
It took one as a JSON argument and then refused every call — but the body is
decoded before the handler runs, so the key arrived regardless, at an endpoint
that needs no credential to reach. A method that cannot sign and has no callers
is not worth the parameter it advertises, so it goes.

The test beside it reads the package's own argument types and fails if any of
them asks a caller for a secret again.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.22
2026-08-15 05:04:29 -07:00
zeekayandHanzo Dev 5493e3a2ed quantumvm: a validator key parses its secret once and keeps it
Sign now asks the key for its ML-DSA form instead of rebuilding one per
call. A key that arrives as bytes parses on first use and reuses that
key for every signature after; a freshly generated key already holds its
parsed form. A secret that will not parse fails closed on every call.

Takes crypto v1.20.5, where a private key copies the secret it is given
and hands back a copy, so the bytes a caller persists out of a generated
key stay readable for as long as the caller holds them.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.21
2026-08-15 00:52:02 -07:00
zeekayandHanzo Dev 9427d78408 evm v1.104.30 — the builder waits for the pool to reach the head
Co-authored-by: Hanzo Dev <[email protected]>
v1.7.20
2026-08-13 12:02:51 -07:00
zeekayandHanzo Dev abf54b46ee evm v1.104.29 — an empty build no longer halts the chain
The C-Chain discarded any assembled block with no transactions, which reads
"nobody asked for anything" and also reads "the assembler missed work that is
sitting in the pool". The second one is fatal: no block means no nonce advances,
so the pending transaction is never mined, so the pool never drains.

lux-devnet stopped at 20:38 UTC on 2026-08-11 holding one executable transaction
and stayed down 41 hours. v1.104.29 asks the pool instead of the block.

Skips .11 through .28, which this module never pinned.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.19
2026-08-13 10:58:37 -07:00
zooqueen f9a900f7cb three minio modules down to one, and the last is not ours
The S3 client moves off hanzos3/go-sdk, and consensus, database, geth, container
and zapdb all come up to the releases where each stopped resolving minio.

go-sdk pointed at our checksum forks with `replace`, which Go ignores in a
non-main module — so it redirected nothing for anything importing chains, and the
real minio packages resolved regardless.

What remains is github.com/minio/sha256-simd, reached through
github.com/ferranbt/fastssz. Third-party, and not this repo's to remove.

Verified package by package against an untouched clone, because a full build
cannot run on this machine — luxfi/dex needs dex_gpu.h and cevm needs pkg-config
lux-cevm, neither installed. Both trees: 48 packages build, the same 4 fail.
Identical.

go directive held at 1.26.4.
v1.7.18
2026-08-10 21:56:16 -07:00
zeekayandHanzo Dev 67e2aaa177 ownership: a lookup starts from a node, so Subject is a function too
The verifier looks up an attestation by node before it holds any claim, so the
subject encoding has to be reachable without one. Exporting it keeps a single
definition instead of every caller re-deriving the same 20-into-32 copy.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.17
2026-08-08 05:00:49 -07:00
zeekayandHanzo Dev 85e1287852 mpcvm: an ownership claim is a signed fact about a token, not a copy of it
An entitlement-gated chain needs a credential its operator cannot forge. The
collection that confers the entitlement lives on another chain's EVM, so the
node cannot read it and must not be asked to: a check a node runs against data
its own operator supplies is not a control.

So M threshold-signs the fact instead. chains/ownership is the frozen wire
format both sides share -- the claim's field layout, the domain tag, the signed
payload, and one verification entry point. It is dependency-free and
non-internal because the verifier is a node in another module that must never
link the M-Chain VM.

Authorizes answers authenticity and node-binding together. There is no exported
way to ask only one, because a caller that checked the signature and forgot the
binding would let any node activate on a peer's entitlement.

The domain registers additively, reusing ComputeAttestationPayload rather than
defining a parallel digest, so equivocation detection and quorum verification
already apply. The claim's block is the epoch, which makes two facts about one
node at one block contradictory while re-attesting later is how ownership moves.

KAT vectors freeze the bytes; a test asserts M's payload and the verifier's are
byte-identical, since nothing else forces those two functions to agree.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.16
2026-08-08 04:58:32 -07:00
zeekayandHanzo Dev dd3ff1c9b5 dexvm: delete the D-Chain proxy, keep the asset registry
The proxy built for the canonical D-Chain slot (VMID "dexvm",
mDVT5EWMumBp3LCqvKwuyZQeY1VXr1jvjGNAt8nL4UFiXvqXr) that luxfi/dex cmd/dexd
already fills, and its wire spoke clob_* while the venue registers dex_*.
Installing it produced a node that loaded a VM, passed health, and answered
nothing — a failure that reads as a working chain. Nothing imported it but its
own plugin main, and the release matrix published it as an image on every tag.

dexvm/registry stays: it is the DEX asset registry, imported by the EVM value
wiring and dex-assets-validate, and it holds the Gate-A primitives its own
gate_test.go covers. The deleted enforce.go was a config adapter over it.

internal/slot pins the invariant by AST, so a comment may discuss the slot but
a declaration may not claim it: no package here names the D VMID, none declares
a clob_* method, and the release matrix ships no image for the slot. Runs
unfiltered on every push — a path filter would miss a NEW package claiming an
already-filled slot, which is the case worth catching.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.15
2026-08-07 16:32:00 -07:00
zeekayandHanzo Dev 3e9fd77e98 rpc: /ext/ -> /v1/ on our own hosts
luxd serves /v1/*; /ext/* 404s. Third-party chains keep their own paths.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-03 21:15:23 -07:00
zeekayandHanzo Dev 230fbb3fe0 ci: one CI path — .hanzo/workflows
None of the three workflows here could run. release.yml's `plan` job named
`lux-build-amd64` and validate-asset-manifests.yml named the bare
`['self-hosted', 'linux', 'amd64']` triple; the pool on this forge is
`lux-build-linux-amd64` and nothing answers to either. A job naming a label no
runner carries does not fail — it waits. So Gate A has not been gating
anything, and it is the gate that proves every DEX asset manifest entry
resolves to a real on-chain asset.

release.yml had a second, independent reason to never produce anything: it
delegated the actual build to
`hanzoai/.github/.github/workflows/docker-build.yml@main`, and a cross-org
workflow_call from luxfi/* into hanzoai/.github fails before any job starts.
luxfi/operator recorded exactly this against exactly this reusable —
"startup_failure on EVERY run since the migration to that reusable ... every
attempt failed at 0-2s with zero jobs dispatched" — and inlined its own build
for that reason. These thirteen plugin images have not been built by this
workflow.

So the plugin build is inlined too, in .hanzo/workflows/release.yml. It is
short: compile the VM to build/<vm> on the runner, then Dockerfile.plugin copies
that one binary into a scratch image, one matrix leg per entry in
.github/images.json. The manifest file stays where it is and is still read from
`.github/images.json` — it is repo metadata, not a workflow.

Gate A becomes .hanzo/workflows/ci.yml with its three checks intact.

Two other things had to change for these to work off GitHub:

  * ghcr auth. The reusable was handed the ambient credentials; the direct build
    uses GHCR_USER and GHCR_TOKEN, the org Actions secrets sourced from KMS. The
    instance GITHUB_TOKEN is a Hanzo Git token and has no rights at ghcr.io.

  * arm64. The reusable was asked for an arm64 leg on a
    `["self-hosted","linux","arm64"]` pool that does not exist. These images are
    amd64-only until we own an arm64 runner and it can return as a native matrix
    entry.

Deleted rather than migrated: workflow-sanity.yml. It is
`uses: hanzoai/.github/.github/workflows/workflow-sanity.yml@main` — the same
cross-org reusable call that fails before dispatch, so it has never reported on
anything. The label checking it was meant to provide is what
.github/actionlint.yaml is for, locally.

All three files are accounted for, which is the constraint: the forge stops at
the first workflow directory that exists, so a repo's entire .github/workflows
suite goes dark the moment .hanzo/workflows appears.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-30 17:22:19 -07:00
zeekayandHanzo Dev 37d8835a07 cevm: doc ExecuteBlock as itself
The comment read "ExecuteBlock is the legacy name for ExecuteBlock ... Deprecated:
use ExecuteBlock" — a rename rewrote both the old and new name to the same string,
leaving the only ExecuteBlock marked deprecated in favour of itself.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.14
2026-07-30 05:48:02 -07:00
zeekayandHanzo Dev 3e85a4dc82 quantumvm/stamper: batch verification must check the same things, for the right stamp
The accelerated batch path re-implemented two things the sequential path already
did, and got both wrong.

1. It re-listed the block-correspondence fields itself and omitted ReceiptsRoot,
   which verifyStampSync checks. A stamp carrying a forged receipts root was
   therefore accepted whenever accel was available and the batch cleared
   DilithiumBatchThreshold, and refused otherwise — acceptance decided by host
   hardware. Both paths now call stampMatchesBlock, the one definition.

2. The hybrid post-quantum leg read signDataSlice[0] for every stamp:
   signDataSlice is indexed by BATCH POSITION while indices maps position to stamp
   index, and the loop discarded the position. Every hybrid stamp's SLH-DSA
   signature was verified against the FIRST batch entry's signing bytes. That both
   false-rejects honest stamps and, since a batch submitter chooses what entry zero
   contains, accepts a signature made over data the stamp does not commit to.

Proven against the pre-fix logic: a correctly signed hybrid stamp at batch
position 1 failed; a stamp carrying a signature over another stamp's bytes passed;
a forged ReceiptsRoot passed the batch field list.

Tests: TestHybridBatchVerifiesEachStampAgainstItsOwnData,
TestHybridBatchRejectsSignatureOverAnotherStampsData,
TestStampMatchesBlockCoversReceiptsRoot.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:48:10 -07:00
zeekayandHanzo Dev af5096e6fe quantumvm: count distinct validators toward both finality thresholds
AddCoronaSignature and AddBLSSignature appended caller-supplied signatures with no
signer check, and TryFinalize compares len(signatures) against q.threshold. One
peer resending the same signature therefore drove the count to the threshold on
its own. Proven: three calls carrying one validator's signature left three entries
in the list.

The Corona leg is the sharp end: TryFinalize finalizes it on count alone and
verifies none of the signatures, unlike the BLS leg which aggregates and verifies.
So a single peer satisfied the post-quantum half of quantum finality by itself.

Insertion for both legs now goes through addBLS/addCorona, which record one
signature per ValidatorID, and SignBlock's own contribution goes through the same
door instead of appending directly.

Tests: TestOneValidatorCannotReachCoronaThreshold,
TestDistinctValidatorsReachCoronaThreshold, TestOneValidatorCannotReachBLSThreshold.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:48:10 -07:00
zeekayandHanzo Dev bcf93b97db bridgevm: one acceptance predicate for a block MPC signature
batchVerifyBlockSignatures had two answers to "is this signature valid", chosen
by accel.Available():

  - the CPU loop verified against blockHash[:] (accel_verify.go:69) while the GPU
    layout verified against sha256(blockHash[:]) (accel_verify.go:96,107). Same
    signature set, two different signed messages, so a node with an accelerator
    and a node without reached opposite verdicts on the same block —
    bridgevm/block.go:219 turns validSignatures into accept or reject, so this is
    an accept/reject split decided by host hardware.

  - a signature shorter than 64 bytes, or a public key that would not marshal,
    was silently zero-filled (`if len >= sigSize`, `if mErr != nil { continue }`)
    while the entry kept its row in the result vector, and validCount was taken
    straight from the kernel's output for that row. The CPU path rejects those
    entries outright via deserializeSignature. Correctness of the count therefore
    rested on an out-of-tree kernel refusing degenerate input, and one
    spuriously-true row is enough to admit a block.

The predicate is now verifyBlockSig, and the CPU loop is its only evaluator.
packBlockSigBatch lays out the accelerator's rows over the same message bytes and
returns an error for any row it cannot represent exactly, which sends the batch to
that one authority instead of guessing.

Tests: TestBatchSignsTheBlockHashItself, TestBatchRefusesRowsItCannotRepresent,
TestVerifyBlockSigIsTheOneAuthority, TestBatchVerifyCountsNoSignatureWithoutConfig.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:48:10 -07:00
zeekayandHanzo Dev 9a2e4fa7d3 bridgevm: build a block from its inputs, not from map iteration order
BuildBlock collected the block's bridge requests by ranging vm.pendingBridges, a
map. Go randomises map iteration, and that iteration decided two things that must
be functions of the pending set:

  - the ORDER of BridgeRequests, which is hashed into the block id
    (block.go computeID writes each req.ID in slice order) and serialised to the
    wire. The same pending set therefore produced a different block on every
    build. The sibling map in the same wire format is already sorted by NodeID
    (wire.go:140) — BridgeRequests was the one that was missed.
  - WHICH requests the block carries, because the 100-request cap broke out of
    the range. With more than 100 ready, the included subset was a random draw,
    so a request could be passed over indefinitely for no reason.

Proven: two builds over one 24-request pending set produced different orders, and
the capped build over 140 requests carried a random subset.

Candidates are now sorted by request id and the cap takes a prefix of that order.

Tests: TestBuildBlockOrdersRequestsDeterministically,
TestBuildBlockCapIsADeterministicPrefix.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:48:10 -07:00
zeekayandHanzo Dev c7aa384e15 aivm: compare the recorded receipt_root unconditionally
verifyImported exists so every validator reaches byte-identical engine state from
the same committed intents, and its comparison was written
`wantRoot != (common.Hash{}) && got != wantRoot`. The zero-hash exemption made the
guarantee opt-out: a proposer recorded intents, wrote an all-zero receipt_root,
and every follower applied those intents and returned nil without ever checking
its resulting engine state matched the proposer's. Proven: a block whose
receipt_root was zeroed while the engine's real root was non-zero verified with
no error.

Nothing honest relied on the exemption. A proposer stamps the engine's own root
(vm.go:622), and before anything settles the root is legitimately zero on both
sides — so plain equality admits every honest block and rejects only a genuine
divergence.

Test: TestVerifyRejectsBlockClaimingZeroReceiptRoot.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:48:10 -07:00
zeekayandHanzo Dev 14b1b66956 identityvm: judge credential expiry at block time, and stop letting a missing
proof satisfy RequireZKProofs

Two defects.

1. Block.verifyCredential compared cred.ExpirationDate against time.Now() inside
   block verification. The verdict therefore depended on when a node happened to
   verify: the same block is valid before the expiry instant and invalid after,
   so two validators either side of it disagree. Worse, it is unconditional on
   replay — a node bootstrapping the chain rejects every historical block whose
   credentials have since expired, which after enough time is all of them.
   Proven: a block timestamped 48h ago carrying a credential that was live then
   failed verification. Now compared against b.BlockTimestamp, the one clock
   every node agrees on, already bounded against the parent and local time
   earlier in Verify.

2. VerifyCredential gated the ZK requirement on the presence of the thing it
   requires: `if vm.config.RequireZKProofs && cred.Proof != nil`. A credential
   carrying no proof object skipped the branch and returned (true, nil) on a
   chain configured to require proofs. Proven: VerifyCredential returned true for
   a proofless credential with RequireZKProofs set. The requirement now holds
   whenever it is configured.

Tests: TestCredentialExpiryIsJudgedAtBlockTime,
TestRequireZKProofsCannotBeSkippedByOmittingTheProof.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:48:10 -07:00
zeekayandHanzo Dev 3d38bef46f zkvm/fhe: count distinct parties toward the decryption threshold
SubmitDecryptionShare stored the share in a map keyed by partyID, which dedups,
but incremented a separate ShareCount on every call and compared THAT to the
threshold. One party calling three times on a 3-of-5 session drove ShareCount to
3 and flipped the session to StatusProcessing, so a single share holder
satisfied the quorum and the value t-of-n exists to protect was released to it.
Proven: three calls from partyID 1 left ShareCount == 3.

The share set is now the only source of truth — ShareCount is len(Shares) — and
a repeat submission from a party that already contributed is refused outright.
The sibling implementations in mpcvm/fhe already dedup before counting.

Tests: TestOnePartyCannotReachDecryptionThreshold,
TestDistinctPartiesReachDecryptionThreshold.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:48:10 -07:00