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

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

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

go list -deps names no luxfi/node for aivm, bridgevm, dexvm, evm, fhevm,
graphvm, identityvm, keyvm, mpcvm, quantumvm, schain or zkvm, and the built
plugin binaries carry no such string. oraclevm and relayvm still do: they are
re-export shims over luxfi/oracle and luxfi/relay, which build their fee
policies upstream. Their tests keep naming the daemon's fee package because
that is the type their value has.
2026-09-03 21:10:40 -07:00
zeekayandHanzo Dev 9a568c2bbb feat(fhevm): add plugin entrypoint for binary plugin build
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-03 13:21:35 -07:00
zeekay e5fbbc5201 fhevm: a block extends the tip, a signature names one chain, and a failed read is not an answer
Four defects, each confirmed by executing it before it was fixed and by
mutation after.

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

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

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

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

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

fhevm 89.7% -> 98.9%. The twelve statements left are named unreachable in
the report, not padded. The harness now holds constant what production
holds constant: one chain id and one genesis across every node of a chain,
rather than a fresh id per node.
2026-08-28 11:55:53 -07:00
zeekay af188362b1 fhevm: a verified parent is findable, whoever built it
The in-flight block set was written in exactly one place — BuildBlock — so a
block arriving from a peer was never findable by id and its child failed
Verify with "parent not found". That is the ordinary shape whenever more than
one block is in flight, so a follower could not follow.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The Makefile names its targets instead of globbing the directory: the
glob swept in dexvm, fee, ownership, internal and cmd, and stopped at the
first with no plugin entrypoint, so `make` built nothing at all.
2026-08-27 16:23:29 -07:00