369 Commits
Author SHA1 Message Date
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
zeekayandHanzo Dev f24ec103d2 zkvm: refuse a block or vertex that spends one shielded note twice
verifyTransaction checks each nullifier against nullifierDB, which only holds
nullifiers spent in ACCEPTED state; a block's own nullifiers are marked spent in
Accept, after Verify. So two transactions in one block sharing a nullifier both
passed — neither was spent yet — and one shielded note funded two spends, each
creating outputs. Supply inflation, which is the exact event the nullifier set
exists to prevent. A single transaction listing the same nullifier twice got
through the same way: the mempool consults its conflict map before inserting the
transaction's own nullifiers, so [n, n] passes admission too.

Proven: Vertex.Verify on a vertex whose two txs share a nullifier returned nil.
BuildVertex already refuses to batch conflicting txs (usedNullifiers), so the
invariant was intended — it just was not enforced on anything that arrived over
the wire, and BuildBlock never had it at all.

Block.Verify and Vertex.Verify now require every nullifier in the block to be
distinct, checked before proof verification because it is the cheaper gate.

Also bounds the vertex decoder's counts by the bytes actually present.
parentCount and txCount are attacker-controlled uint32 read straight into
make(); the length checks came after the allocation. A 16-byte vertex claiming
2^32-1 parents requested 128 GiB, and a 20-byte one claiming 2^24 txs measurably
allocated 671 MB before returning its error — remote unrecoverable
out-of-memory from a payload smaller than a UDP header.

Tests: TestBlockVerifyRejectsDuplicateNullifier,
TestBlockVerifyRejectsNullifierRepeatedInOneTx,
TestVertexVerifyRejectsDuplicateNullifier,
TestDeserializeVertexBoundsParentCount, TestDeserializeVertexBoundsTxCount.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:48:10 -07:00
zeekayandHanzo Dev df5ccabc99 zkvm: make the state root one pure function, not a hardware-dependent one
Two defects in the same computation, both consensus-fatal.

1. StateTree.ComputeRoot returned a GPU Poseidon digest when accel.Available()
   and a SHA-256 digest otherwise, over inputs truncated to 8 bytes each on the
   GPU path. The root is committed in the block and hard-compared in
   Block.Verify, so a validator with an accelerator and one without computed
   different roots for identical inputs and rejected each other's blocks —
   a chain split decided by host hardware.

2. computeStateRoot mutated the tree it was measuring: Block.Verify called it,
   ApplyTransaction appended to pendingAdds/pendingRemoves, and only Accept ->
   Finalize cleared them. Verifying the same block twice, or verifying a block
   that loses a poll and then its winner, folded the earlier block's
   commitments into the later root. Proven: computing the root for one tx set
   twice in a row returns two different roots, so a valid block is rejected
   with errInvalidStateRoot and the node stalls.

The root is now RootAfter: SHA-256 over currentRoot, then every output
commitment in tx order, then every nullifier in tx order. It reads the tree
and never writes it; Finalize is the only mutation and Accept its only caller.
Genesis now finalizes its own root instead of leaving its txs to be re-folded
into every later block.

Also deletes the dead computeRootFromLeaves/updateLeafPath cluster, which
ranged a map while sequentially rewriting st.currentRoot.

Tests: TestComputeStateRootIsPure, TestStateRootHasOneDefinition,
TestStateRootAdvancesOnlyOnFinalize.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:48:10 -07:00
zeekayandHanzo Dev 66b2b1809b deps: reconcile luxfi/pq v1.0.3 onto the hash the tag actually resolves to
The v1.0.3 tag was re-pointed by a history rewrite that scrubbed a downstream
brand name from one comment line in mode.go, so the recorded zip hash no longer
matched the remote and clean-cache builds failed with a checksum SECURITY ERROR.

The new content is canonical and the difference is that single comment — no API,
no behaviour, and the module go.mod is byte-identical across both, so the
recorded /go.mod hash is unchanged. Hash re-derived by the toolchain, not
hand-written. luxfi/pq v1.1.1 retracts v1.0.3 so the moved tag stays visible
instead of being laundered away here.

Co-authored-by: Hanzo Dev <[email protected]>
mirror/main-0da2db3
2026-07-28 22:43:35 -07:00
zeekayandHanzo Dev ae28f35b97 deps: drop go.sum lines that disagree with the checksum log
Recorded from direct VCS fetches made while GOPRIVATE covered
github.com/luxfi/*, which switches off the proxy and checksum verification
together. proxy.golang.org still serves the originally-published bytes and they
verify against the log, so dropping only the disagreeing lines restores a
verified graph with no version change.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.12
2026-07-28 16:18:27 -07:00
zeekayandHanzo Dev 10eafbf673 build: mark lux-private private, not all of luxfi
GOPRIVATE switches off the module proxy AND checksum verification together.
Covering github.com/luxfi/* meant every luxfi module was fetched straight from
git and trusted without verification — which is how moved tags got captured into
module caches and served from them indefinitely, making the same commit build on
one machine and fail on another.

Nearly all of luxfi is public and on proxy.golang.org, so it belongs under
normal verification. The private org is lux-private.

Twelve luxfi repos are still private AND still 404 on the proxy, so they are
listed individually rather than by wildcard — coverage now matches what actually
needs a direct fetch, and every other luxfi module is verified against
sum.golang.org again:

  bridge broker compliance erc20-go fasthttp graph hsm indexer
  mpc ordering password pool

The right end state is those repos moving to lux-private; until then the
explicit list keeps the exemption honest instead of blanket.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 16:08:52 -07:00
zeekayandHanzo Dev e845b509c5 chore: reconcile go.sum after luxfi history rewrite
Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 10:51:28 -07:00
zeekayandHanzo Dev 1231964a90 fix: give the Metal anchor a package-unique symbol name
deps: gpu v1.1.2 — the older tags name the wrong pkg-config package

gpu v1.0.1/v1.1.0 declare `#cgo pkg-config: lux-gpu` while including
<lux/gpu.h>. Those are two different libraries with confusingly similar names:

  luxgpu.pc   unified GPU acceleration, -lluxgpu,   Cflags -I${includedir}
  lux-gpu.pc  the DEX order matcher,    -llux_gpu,  Cflags -I${includedir}/lux

Under lux-gpu the include resolves to .../include/lux/lux/gpu.h, so every cgo
build failed with "fatal error: lux/gpu.h file not found" — the arm64 stall
signature. libluxgpu.dylib is also what actually exports _lux_gpu_ecrecover_batch.

gpu v1.1.2 already declares luxgpu; only the pin was stale.

With that fixed the link got one step further and hit a duplicate symbol:
cevm and dex/pkg/lx both define a global lux_secp256k1_metal_anchor, so any
binary linking both refuses. The anchor exists only to keep the Metal driver
alive through linker dead-code elimination — luxcpp resolves the DRIVER symbol
(secp256k1_ecrecover_address_batch_metal) via dlsym, never the anchor, so the
anchor name is internal and safe to qualify. Named lux_cevm_secp256k1_metal_anchor
after the package that owns it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 08:43:24 -07:00
zeekayandHanzo Dev 7a830a1666 deps: drop go.sum lines that disagree with the checksum log
These hashes were recorded from direct VCS fetches made while GOPRIVATE covered
github.com/luxfi/*, which switches off the module proxy and checksum
verification together. Tags moved afterwards, so what was written down no longer
matches what sum.golang.org holds, and the build refuses to verify.

proxy.golang.org still serves the originally-published bytes and those verify
against the log, so dropping only the disagreeing lines and re-tidying restores
the build with no version change. Lines that agree were left alone — this is not
a regenerated go.sum.

The mismatch followed the machine, not the repository: the same commit built
wherever the cache was clean. Any machine or CI runner that fetched luxfi
modules under the old GOPRIVATE holds the same poisoned entries.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 02:14:08 -07:00
zeekay c2f513536a chore: sync working tree
Commits 2 outstanding change(s) that were sitting uncommitted.
No build artifacts and no secrets in the changeset (both checked).
2026-07-26 10:02:10 -07:00