Commit Graph
247 Commits
Author SHA1 Message Date
zeekay 634df5e5ae gpu: identify a plugin before trusting it, and load only from a named place
A GPU plugin replaces a VM's state transitions wholesale. Three of the four
seams reached one by dlsym'ing launcher names out of a library nothing had
identified, and all four would fall back to a bare filename — which makes the
dynamic loader's default search path, and in bridgevm's case the working
directory of whoever started the node, the thing that decides which code
computes consensus state.

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

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

Two changes, neither of them new machinery.

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

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

Verified with the real HIP plugin from lux-private/gpu-kernels: aivm, bridgevm,
mpcvm and quantumvm all build, vet and test green, aivm TestRoundTripAttestation
passes through the loaded plugin, and the rogue library is refused.
2026-08-29 19:04:26 -07:00
zeekay 67d0853fc2 Name Zen in the hero card's font stack
The social card asked for Inter first. That is not a face this estate
licenses, ships or ever drew, so a renderer with Inter installed painted
the wrong face and one without it silently fell through to system-ui.
Zen leads now; the same tail follows it.
2026-08-28 11:41:52 -07:00
Hanzo AI 8c8fcb5770 aivm: a task reaches its verdict because the chain advanced
Operators were selected, bonded, committed and revealed, and then nothing paid
them. Settle — which tallies reveals, credits the honest majority, slashes
withholders and folds a receipt into the root — ran only from the demonstration
driver. The production block path imported intents, created tasks, and never
gave any of them a verdict, so a task sat past its reveal deadline forever and
the work done for it was credited to nobody. Open mining rests on the opposite:
an operator that did the work is paid without anyone asking on its behalf.

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

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

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

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

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

Comment only.

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

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

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

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

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

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

The comment described the rule; now the code does.

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

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

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

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

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

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 13:13:14 -07:00
Hanzo AI 8051af13de 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
Hanzo AI 9c7a054af8 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
Hanzo AI bc855fd926 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
Hanzo AI ffffe023ec 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
Hanzo AI 376692b1ad 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
Hanzo AI a8ba512cbe 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
Hanzo AI 9dc59df3bf 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
Hanzo AI fea1038479 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
Hanzo AI 48faf7abd4 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
Hanzo AI 92f1003538 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
Hanzo AI 5b3da0034a 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
Hanzo AI b556f4decd 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
Hanzo AI ab4b0a428c 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
Hanzo AI 9bf27d040e 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
Hanzo AI 7e87683b45 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
Hanzo AI 0ee8686a59 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
Hanzo AI 997b0e09b3 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
hanzo-dev 70a23338b0 evm: cgo is not a claim that luxcpp is installed
The evm plugin turns cgo on for blst. Doing so also pulled in two
optional native backends keyed on bare cgo, neither of which a build
host carries by default, so `go build ./evm` needed a luxcpp and a
luxgpu install or it did not compile:

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

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

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

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

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

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-20 21:01:18 -07:00
z 53090f998a 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
Hanzo AI a33b8be150 chains: consensus v1.36.63
The catch-up epoch door is walked through by a test rather than described by a
comment.

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

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

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

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

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

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

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

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-20 17:39:48 -07:00
Hanzo AI 3bb5c45c3e 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]>
2026-08-20 14:29:52 -07:00
Hanzo AI bf8b4e33ee 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]>
2026-08-20 13:55:29 -07:00
Hanzo AI 11841f2392 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]>
2026-08-20 11:44:10 -07:00
Hanzo AI a5d56a07c4 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]>
2026-08-20 10:47:08 -07:00
zooqueen 372d40e703 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
Hanzo AI a495a38efe 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]>
2026-08-18 21:50:07 -07:00
Hanzo AI 9590656369 Merge remote-tracking branch 'origin/main'
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-18 18:25:32 -07:00
Hanzo AI 2d426a0dd1 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 4afdf6fd90 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]>
2026-08-15 05:04:29 -07:00
zeekayandHanzo Dev d40ea57eb8 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]>
2026-08-15 00:52:02 -07:00
Hanzo Dev f6f0054d50 evm v1.104.30 — the builder waits for the pool to reach the head
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 12:02:51 -07:00
Hanzo Dev 0f2356e84d 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]>
2026-08-13 10:58:37 -07:00
zooqueen f842350ccf 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.
2026-08-10 21:56:16 -07:00
zeekayandHanzo Dev 87deded2af 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]>
2026-08-08 05:00:49 -07:00
zeekayandHanzo Dev ee6ac7bde3 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]>
2026-08-08 04:58:32 -07:00
zeekayandHanzo Dev a3f7bd6015 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]>
2026-08-07 16:32:00 -07:00
zeekayandHanzo Dev 0a88a5e8ff 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