10 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
zeekay e82c509a4e Decide the tip under the lock that commits
Every adopter asked whether its block still extended the tip, released
that reading, and only then asked the store for the lock. The tip moves
in between and the commit lands anyway, writing the height index and the
tip pointer for a block on an abandoned branch -- which every peer
bootstrapping from that index is then served as canonical. The lock is
not reentrant, so the check cannot come from the caller and be held
across the commit: it comes from the store or it is not held at all.

Parent joins Height on the Block interface because a place in the
sequence takes both, and height alone is the reading that lets an old
accepted block pass. Two concurrent accepts fall to the same move, not
just the Verify-to-Accept window: the tip a block is judged against is
now read inside the critical section that commits it, so the second is
refused rather than serialised behind a reading taken before the first
landed. The store also exempts nothing, where identityvm exempted height
zero -- and its Verify skips every parent check at height zero, so a peer
could hand a live chain a second genesis carrying records of its choosing
and send the tip and the height back to the start.

mpcvm keeps its root comparison, which answers a different question --
whether applying this block reaches the root the network agreed on -- and
stays true of a node whose state moved for any reason. That a moved tip
also showed up there is a consequence of that chain refusing empty
blocks, not what the comparison is for.

Seed records the tip in that same commit, because seeding is what makes
a chain no longer fresh. Recording none left Open, for which a missing
tip is a fresh chain, reporting a seeded node fresh on every boot and
asking for the allocation again: a duplicate its own state refuses, and
a node that cannot restart until it produces a block it cannot produce.
2026-08-28 20:57:03 -07:00
zeekay 75c971b172 chain: pin that only an absent tip is a fresh chain
The fix in aa4c375 was correct and unpinned -- reverting it left the suite
green, which is the same failure this estate has now hit six times. Two tests
close it: an unreadable tip must surface its cause and must not report a first
run, and a tip of the wrong width must be refused as corruption rather than
rounded down to absence. Restoring the old condition now fails both.

The absent-tip half was already covered by TestOpenOnAnEmptyStoreStartsAtGenesisAndSaysSo,
so it is not restated here.
2026-08-28 13:20:05 -07:00
zeekay 8b6d2c50d4 chain: only an ABSENT tip means a fresh chain
Open read any failure from the tip as "nothing recorded yet" and started the
chain at genesis. A closed database, an unreadable volume or a short read
therefore looked identical to a first run, and a live chain would begin
building height 1 over state it could not see -- durably, with Initialize
returning nil.

Q-Chain and F-Chain each found and fixed this in their own code before the
store existed. Five VMs now share the store, so the same defect was waiting for
each of them again. Not-found starts at genesis; anything else refuses to open,
because a chain that cannot read its own tip does not know where it is.

A wrong-width tip is now refused rather than silently rounded to a fresh chain
for the same reason: it is corruption, not absence.
2026-08-28 12:15:51 -07:00
zeekay f28e5d0e30 chain: keep only what something calls
Two pieces of the new package had no caller. Pool.Holds was the read side of a
claim set that Add already enforces, so nothing ever needed to ask; it is gone,
and the tests that used it now assert the same property through Add, which is
the behaviour that matters.

chain.Closed had no caller because M-Chain still declared its own
NoUserTxPolicy. It declares chain.Closed() now — one fewer copy of a policy
five chains state identically, and the one no-user-tx chain in this pass
exercises the value that describes it.
2026-08-28 09:22:52 -07:00
zeekay 5fb41c5066 chain: drop a lint directive for a method that is used 2026-08-28 09:04:27 -07:00
zeekay 6d786b8104 mpcvm: a refused block leaves no ceremony behind, so it can be applied again
Accept folded the operations into the key registry and the ceremony log with
one Put each, and only THEN compared the root it reached against the one the
block claims. Refusing there left every record it had already written — exactly
the disagreeing state the check exists to refuse.

That was worse than losing a block. PutKey and PutCeremony refuse a record they
already hold, so the block that failed could never be applied again: the chain
was wedged at that height, holding ceremonies no block records. PutBlock and
SetLastAccepted were each two Puts with an early return between them, so a fault
could also drop a height entry or advance the root under a stale tip.

State now writes through the chain's view, and the block, its height entry, the
tip and every record it wrote commit together. A refused block leaves nothing,
and applying it again works — which the test proves by doing it.

The staging queue becomes the shared pool: it was already a map keyed by
ceremony id plus a slice for order, rebuilt from the map when entries went, which
is what the pool derives. The store owns the tip, the height index, the blocks
in flight and the engine's preference; State keeps the registry, the ceremony log
and the root, which are M-Chain's own.

One lock order, written where the locks are: the chain's, then the VM's, then
the pool's. Verify resolves its parent before taking the VM's lock, because
holding it across a block lookup takes the two in the order Accept does not.

No existing test changed.
2026-08-28 08:58:40 -07:00
zeekay 0ff6430a7b bridgevm: a transfer is released only once its block is durable
Accept credited the daily volume, completed the requests, advanced
lastAcceptedID, dropped the block from the in-flight set and handed the
transfers to the release worker — which broadcasts on an external chain — and
only then wrote the block. Every one of those survived a failed write, so a
release could be in flight for a transfer no block records.

lastAcceptedID was never written at all. The tip lived in memory, reset to
genesis on every restart, and the daily volume that bounds the bridge limit
went with it.

The block now commits through the shared store, which writes it with its height
entry and the tip in one batch, and its effects — including the handoff to the
release worker — happen only afterwards. A proposal follows the engine's
preference, which the store now holds, rather than always the accepted tip.

The bridge's own state keeps its own lock. The transfers in flight, the signer
set, the chain clients and the release plumbing are not the chain's state, and
folding them into the chain's lock would say they were. The order is the
chain's lock, then the bridge's, then the registry's, and it is written down
where the locks are.

Still in memory only, and NOT changed here because it needs its own design: the
completed-bridge registry and the daily volume. A restart still forgets both,
which re-opens the daily bridge limit.
2026-08-28 08:42:29 -07:00
zeekay 479d676264 zkvm: a note is spent with its block or not at all
Accept marked the block accepted and moved lastAccepted before writing
anything, then issued a Put per nullifier and per output, each returning early.
A failure partway left some notes spent and some outputs created under a tip
the chain had already advanced.

Worse, the spent-nullifier cache advanced as it went, and MarkNullifierSpent
refuses a nullifier it already holds — so the block that failed could never be
applied again. The chain was stuck at that height holding notes it believed
spent with no block recording the spend.

Everything a block writes — nullifiers, outputs, the state root — now stages
through the store's view and commits in one batch with the block and the tip.
On failure the view is rolled back and the three caches are rebuilt from what
committed, so the block can simply be applied again. The DAG vertex had the
same defect and goes through the same store.

That is why the store's Block is not the engine's block interface: a vertex has
several parents and no timestamp, so it cannot satisfy it, and it changes state
exactly the same way. The store is generic in what a chain tracks, so Propose,
Open and Block hand back the chain's own type and no caller asserts.

Also here: genesis is seeded through Seed, which commits on its own, because a
staged allocation would ride on whichever block landed first and vanish from a
chain that never accepted one. StateTree.Finalize records the root before
holding it, rather than after.

Restoring the original Accept fails the atomicity test; removing the cache
rebuild fails it on the line that says the cache must agree with the database.
2026-08-28 08:34:00 -07:00
zeekay 6220024728 chain: a block lands whole or not at all
Nine chains each write their own Accept, and five of them write it with
individual Puts that return early. A later write failing leaves the earlier
ones on disk and the chain already believing the block was accepted: state is
half-applied and there is no path back.

The discipline two chains already have becomes the only one there is. A block
declares what it Writes and what it Publishes; the store stages the writes
through a versiondb, commits them in ONE batch with the block, its height entry
and the tip pointer, and on any failure rolls the view back and rebuilds the
caches from committed state. Nothing the block claimed survives and the tip
does not move. Publish runs after the commit, so there is no window in which
the chain has advanced past state that is not on disk.

Beside it: the blocks in flight, under the store's one lock, pruned at or below
the accepted height because the engine may abandon a block without ever
deciding it; the pool, whose claim set is derived from its queue so a claim
cannot outlive the entry that made it; the fee a chain declares, which admits
nothing until it is declared; and the factory, whose VM holds nothing until
Initialize.

Seven mutations of the store and three of the pool each fail at least one test.
2026-08-28 08:15:44 -07:00