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.
15 KiB
chains
Org: luxfi · Ecosystem: lux · Path: /Users/a/work/lux/luxfi/chains
Origin: https://github.com/luxfi/chains.git
Discovery
This file (CLAUDE.md) is the canonical agent-facing readme; LLM.md is a symlink to it. Update either name and both stay in sync.
Where to look first
README.md— human-facing overview (if present)package.json/Cargo.toml/pyproject.toml/go.mod— language & deps.github/workflows/— CI surfacedocs/— extended docs (if present)
Canonical chain roster (LP-0130)
The topology, UTXO ownership rule, and cross-chain fee model are normatively specified by LP-0130 (Chain Topology, UTXO Ownership, and Fee Model). Read that LP before touching any VM's fee/settlement path.
Only P (in node/vms/platformvm) and X (in node/vms/xvm) are
canonical UTXO state machines. Every VM in THIS repo is a
domain-execution chain that MUST consume UTXO settlement via the
chains/fee primitive funded from X.
| Dir | Letter | Role | LP |
|---|---|---|---|
evm/ |
C | Contract / EVM account state | LP-1200 |
dexvm/ |
D | DEX matching + settlement receipts | LP-9000 |
quantumvm/ |
Q | Finality-cert / Quasar aggregation. No user-payable blockspace (validator obligation, LP-0130 §6) | LP-1300 |
zkvm/ |
Z | Rollup / private commitment + nullifier. Shielded supply is an X-escrow wrapper (LP-0130 §2, I-5) | LP-8000 |
aivm/ |
A | Inference receipt + attestation. Rides B's settlement engine (LP-0130 §9) | LP-5000 |
bridgevm/ |
B | Cross-chain message lifecycle. Fees deducted from bridged amount (LP-0130 §8) | LP-6000 |
mpcvm/ |
M | MPC signing / custody. Service fees paid by originating chain (LP-0130 §7); no user M-balance | LP-7100 |
fhevm/ |
F | Confidential-compute coordination: ciphertext handles, access permits, threshold decryption | LP-8200 |
mpcvm/fhe/ |
— | FHE runtime library — a package inside mpcvm, wrapped by fhevm/, not itself a chain |
LP-8200 |
Deployment reality (probed on mainnet 96369, platform.getBlockchains). Ten
chains are live: P X + C D Q Z A B G K. M and F are NOT deployed; both now
build and ship a plugin binary, and neither has a chain registered on any
network. make produces build/mpcvm and build/fhevm, and luxd's
--plugin-dir scan (node/node/vms.go OptionalVMs) finds each under the CB58
of its vmID — qCURact1n41FcoNBch8iMVBwc9AWie48D118ZNJ5tBdWrvryS for M,
n6sSsSfbpQBrU9sY4R29U6z8VrmnTo2CntW6da4rRS7qmnGdv for F.
F-Chain (fhevm/)
F is the coordination plane for confidential compute, modelled on keyvm/: the
chain of record for encrypted values that 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. It stores no ciphertext body, no plaintext, no FHE secret key and no
decryption share — fhevm/invariant_test.go proves that by walking the type
graph and scanning the package source.
Six operations, each fee-settled through consensus and priced by scheme and ring
dimension (fhevm/gas.go): register a ciphertext, grant a permit, revoke a
permit, request a decryption, attest its result, advance the epoch. The last two
are threshold decisions — a request completes when Threshold distinct members
of the epoch's committee attest the same result handle, and an epoch installs
when Threshold members of the sitting committee attest the same successor. A
member votes once, and votes are tallied per value, so one member naming a false
result delays nothing and pays for the attempt.
F wraps mpcvm/fhe/ rather than restating it: its records embed that
package's types and its public parameters come from DefaultThresholdConfig().
It does not use that package's Registry, which stamps records with
time.Now() — right for the off-chain daemon it was written for, wrong here,
because two validators replaying one block would store different bytes and
return different answers to the same query. F owns its persistence and every
timestamp it writes comes from the accepting block.
A transaction that fails authorization REVERTS: it burns its fee, consumes
its nonce, and changes nothing. Authorization is decided once, in Accept,
because an earlier transaction in the same block can change the state a later
one is judged against — so a verdict reached in Verify can differ from the one
reached at application, and a block every validator certifies and no validator
can apply halts the chain at that height. Verify therefore asks only that a
block is well-formed, authentic, correctly ordered and paid for; batch.admit
is that rule, and BuildBlock runs the same rule and simply leaves out what
does not fit, so a proposer cannot build a block its own Verify would reject.
Two things F does not have, and the one change that gives both
F commits no state root, and it cannot verify a child of a block it has
not yet accepted (the second is shared with keyvm/). Both come from the same
gap: there is one state layer, so Verify reads committed state and a proposer
cannot know its post-state until Accept.
- A root checked only at
Acceptwould be worse than none — a wrong root in a proposed block would passVerifyeverywhere and failAccepteverywhere, which is a halt an adversary triggers at will. - Verifying
b2on top of an unacceptedb1needsb1's post-state, which is the same missing layer.
The fix for both is a state layer per in-flight block (versiondb chains, with
the record caches moving under it). Until then, determinism is enforced from
outside consensus by replay_test.go, which replays a chain onto independently
built nodes running different clocks and requires their databases to be
byte-identical.
Chain time is monotone and bounded ahead of the verifier's clock by
MaxFutureSkew; within [parent timestamp, clock + skew] the proposer still
chooses, so a permit that lapsed during a gap in block production can authorize
one request in the block that closes the gap. TestH2_ProposerFreedomIsExactly‑ TheGapSinceTheLastBlock measures exactly that. Removing it means counting
lifetimes in block heights, which no proposer can rewind — a change to the grant
API, not a patch.
T-Chain is gone entirely — LP-134 dissolves it with zero remainder (threshold
signing → M, FHE → F, cross-chain messaging/teleport → B). There is no
teleportvm here and no teleportvm VM ID in luxfi/constants.
Service VMs (not canonical primary chains under LP-0130):
| Dir | Letter | Purpose |
|---|---|---|
schain/ |
S | S3-style object storage (on-chain metadata, off-chain blob) |
keyvm/ |
K | Key registry / cryptographic algorithm addressing |
identityvm/ |
I | DID / identity |
graphvm/ |
G | Graph indexing / GraphQL surface |
relayvm/ |
R | Relay message queue |
oraclevm/ |
O | Oracle price / data feed |
Service VMs MAY have domain state and MAY charge fees, but MUST fund their fee balance from X and MUST NOT hold canonical UTXO asset supply.
Fee settlement primitive — chains/fee
chains/fee/{balance,meter,settle,ledger}.go is the shared
admission → meter → burn primitive every non-P/X VM in this repo
uses. Its Burn is a native LUX burn (no coinbase credit) that
reconciles against X-side fee escrow at the epoch fee root (LP-0130
§4).
chains/fee/doc.go is the design intent.
Σ-escrow invariant
At every Q checkpoint (LP-0130 I-8):
Σ (non-P/X fee balances) == Σ (X-side fee escrow)
Drift is a finality-blocking fault. This is the audit invariant every VM's epoch-fee-root emission and reconciliation path exists to satisfy.
VMs in this repo
Each *vm/ dir is a self-contained block.ChainVM plugin. dexvm is the
reference for VM structure + commit discipline (dual-DB: durable baseDB +
per-block versiondb; one db.CommitBatch() at block Accept).
- schain (
schain/) — the Lux S-Chain storage VM, forked fromdexvm's structure. VMID =ids.ID{'s','c','h','a','i','n'}(dexvm's byte pattern). M0 (DONE): one mutationPutManifest{bucket,object,fileIds,size,etag}round-trips through a real Lux block (BuildBlock→Verify→Accept) and commits the manifest to a real on-disk zapdb in ONE atomic batch at Accept;GetManifestreturns it only after Accept. Commit discipline copied verbatim fromdexvm/vm.go:1194(acceptBlock), minus the cross-chain shared-memory leg a storage VM does not have — accept reduces toCommitBatch()+batch.Write()+db.Abort(). State = typed manifest accessors overversiondb(no protobuf; JSON value bodies, prefix keys), mirroringdexvm/state/state.go. Tx wire = type-byte + JSON, one codec (mirrordexvm/txs/tx.go). No blobs/pinning/ networking yet — that is M1+. Test:schain/schain_test.godrives the genuine VM↔engine contract (SubmitTx→ToEngine PendingTxs→BuildBlock→Verify→Accept) over a REALzapdb.New(t.TempDir(),…); proves staged-not-durable before Accept, durable after, and Reject→Abort discards. M1 (DONE): TWO parts. PART A — manifest STATE ROOT (the multi-validator safety prerequisite M0 omitted):state.Root()hashes the committed manifest keyspace deterministically via the zapdb prefix iteratorNewIteratorWithStartAndPrefix(nil, prefixManifest), folding each entry length-prefixed (len(k)||k||len(v)||v) in lexicographic key order (mirror ofdexvm/state/state.go:395StateHash, narrowed to the one keyspace; last-block pointer excluded — it is consensus binding folded via blockHash/height, not object state).VM.computeStateRootfolds blockHash+height withstate.Root()(mirrordexvm/vm.go:1260);ProcessBlockstamps it intoBlockResult.StateRoot(mirrordexvm/vm.go:584). The root travels in the block HEADER (new 32-byte field after parentID; block id commits to it) andBlock.Verifyrecomputes it and REJECTS a mismatch (errStateRootMismatch) + Aborts staged writes — this is the >1-validator safety gate dexvm computed but never compared. Tests (schain/stateroot_test.go): determinism across write order, change-sensitivity (object set / etag / size / fileIds each move the root), and Verify-rejects-tampered-root. PART B — S3 OBJECT path, on-chain-metadata / off-chain-blob split proven end to end (schain/object/):Store.PutObjectstreams the blob to aVolume(OFF chain) → fid, then commits onlyPutManifest{bucket,object,[fid],size,etag}to the VM through a real block (ON chain);GetObjectreads the manifest back and reconstructs the blob from the volume.object.Volumeis the SEAM (Write(blob)→fid, Read(fid)→blob); M1 satisfies it with a faithful in-memoryMemVolumewhose fid shape mirrors hanzo/s3'sneedle.FileId(volumeId,needleIdCookie) and whose etag isbase64(md5(blob))(= hanzo/s3ContentMd5). M2 PLUG POINT (marked inobject/volume.go): swapMemVolumefor a thin adapter overgithub.com/ hanzoai/s3s3/operation.SubmitFiles(assign volume + stream needle →.Fid) and a needle GET — nothing in the VM orobject.Storechanges. Kept inside chains/schain (not importing the hanzoai/s3 module into luxfi/chains) to respect the org/module boundary while proving the exact data model. Test (schain/object_roundtrip_test.go): asserts the blob bytes appear in NO accepted block (bytes.Containsoverblk.Bytes()) and the block stays manifest-small (<4KiB for a 68KiB blob), the manifest IS on chain, and GET byte-reconstructs.
Test/build env gotcha (macOS)
The whole chains test suite links CGO deps that need -lresolv; the CLT cc
fails with library 'resolv' not found unless the macOS SDK syslibroot is set.
Run tests with SDKROOT="$(xcrun --show-sdk-path)" go test ./schain/.... This
affects dexvm and every other VM here too — it is NOT schain-specific.
Sibling repos
See the org-level LLM.md at /Users/a/work/lux/luxfi/LLM.md for the full inventory of sibling repos and inter-repo dependencies.
Telling consensus there is work
Consensus calls WaitForEvent and builds nothing until it returns, so a VM that
accepts work has to report it. There is one way to do that: vm.Latch in
github.com/luxfi/vm. Signal() says there is work, WaitForEvent(ctx) blocks
until there is, and signalling twice before the signal is taken is the same as
once — a builder needs to know that there is work, not how much, so a burst
coalesces into one build. The zero value is usable, so whatever owns the pending
work holds one.
Put the latch on the pool, not the VM. The pool is what learns work arrived,
whichever path it came by, so it covers internal callers as well as the HTTP
handler and the VM's WaitForEvent is one line. zkvm, quantumvm, identityvm,
bridgevm, aivm, keyvm and mpcvm all work this way.
Two chains deliberately do not, and both are correct:
- schain and dexvm notify on the
ToEnginechannel they receive invm.Initinstead, and park on the context. That is the other of the two ways a VM can report work, and a VM needs one. Before changing a parkedWaitForEvent, check which:grep -rn "toEngine <-" <vm>/*.go. - graphvm builds no blocks at all. G-Chain indexes what other chains have
already agreed, so it holds no state of its own to agree on; a block there
would carry a query result, which a peer recomputes, or a schema, which is
local configuration.
BuildBlockdeclines witherrReadOnlyChain.
B-Chain: where a bridge request comes from
B learns of a bridge from the source chain's own Locked events, not from its
API. watch.go polls each configured chain, reads the range it has not read
yet, and turns each lock into a pending request keyed by the transfer's
canonical digest — the same value M signs and 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.
It reads twelve blocks behind the head, so a lock a reorg takes back never
becomes a request; it bounds one pass, so a backlog is asked for in pieces; and
a chain it cannot reach keeps its cursor rather than skipping that chain's
blocks. The depth a lock is buried at rides along as its confirmation count,
which is what BuildBlock gates on.
The bridge's API is the JSON-RPC service in rpc.go, and bridge_health
reports what the node can do: a node with no chains wired is not a relayer and
is healthy, while one that is configured to relay but cannot attest says so.