Files
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

124 lines
4.5 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"fmt"
"github.com/luxfi/chains/fee"
)
// GasPrice is nLUX per unit of gas. It is chosen so the cheapest priced
// operation still settles >= node/vms/types/fee.MinTxFeeFloor (1 mLUX),
// unifying per-operation settlement with the pre-existing admission floor.
// gas_test.go asserts that relationship for every (operation, scheme) pair so
// the two fee surfaces can never silently drift apart.
const GasPrice = fee.Gas(1_000)
// GasPerByte prices the bytes a transaction puts on the chain FOREVER: its
// payload and its scheme, the two fields whose length the payer chooses. It
// follows Ethereum's non-zero calldata rate for the same reason — storage is
// the cost a base fee cannot express — and it is what stops a ciphertext body
// riding onto F for the price of the handle that was supposed to replace it.
// The fixed part of a transaction (header, public key, signature) is bounded by
// construction and is covered by the operation's base cost.
const GasPerByte = fee.Gas(16)
// opBaseGas prices the STRUCTURAL cost of an operation — signature
// authentication, state writes, indexing — independent of any FHE scheme.
var opBaseGas = map[uint8]fee.Gas{
TxRegisterCiphertext: 21_000,
TxGrantPermit: 8_000,
TxRevokePermit: 3_000,
TxRequestDecrypt: 15_000,
TxFulfillDecrypt: 10_000,
TxAdvanceEpoch: 5_000,
}
// schemeGas prices the cryptographic work an operation DISPATCHES to the
// off-chain threshold committee, BY SCHEME AND RING DIMENSION — the two
// parameters that actually set the cost. A ciphertext's size is linear in the
// ring dimension N and its transform cost is N log N, so doubling N roughly
// doubles the gas; and at equal N the schemes differ because they carry
// different numbers of ring elements and moduli (TFHE's small LWE ciphertexts
// are cheapest, CKKS's rescaling chain the dearest). Pricing every FHE
// operation at one flat rate would charge a TFHE boolean the same as a CKKS
// n=2^15 vector, which is off by an order of magnitude.
//
// Membership of this map is ALSO the single source of truth for "which schemes
// F accepts": an operation naming a scheme absent here is refused (fail
// closed), never priced at the bare base cost.
var schemeGas = map[string]fee.Gas{
"tfhe-n10": 12_000,
"tfhe-n11": 24_000,
"bfv-n13": 25_000,
"bfv-n14": 50_000,
"bgv-n13": 26_000,
"bgv-n14": 52_000,
"ckks-n13": 30_000,
"ckks-n14": 60_000,
"ckks-n15": 120_000,
}
// usesScheme reports whether an operation's price depends on the FHE scheme.
// Registering a ciphertext and requesting its decryption both scale with the
// scheme — the first in the size the network carries and indexes, the second in
// the committee's partial-decryption and combination work. Granting, revoking,
// attesting a finished result, and advancing an epoch are fixed-size record
// writes that touch no ciphertext, so they are scheme-independent.
func usesScheme(txType uint8) bool {
return txType == TxRegisterCiphertext || txType == TxRequestDecrypt
}
// GasFor returns the metered gas for a transaction, pricing by operation and —
// for committee-dispatching operations — by scheme. It fails closed on an
// unknown operation type or an unknown/missing scheme for an operation that
// requires one.
func GasFor(tx *Transaction) (fee.Gas, error) {
base, ok := opBaseGas[tx.Type]
if !ok {
return 0, fmt.Errorf("fhevm gas: unknown tx type %d", tx.Type)
}
total := base
if usesScheme(tx.Type) {
sg, ok := schemeGas[tx.Scheme]
if !ok {
return 0, fmt.Errorf("fhevm gas: %w: %q", ErrUnknownScheme, tx.Scheme)
}
total += sg
}
return total + fee.Gas(len(tx.Payload)+len(tx.Scheme))*GasPerByte, nil
}
// FeeFor returns the nLUX fee a transaction settles: GasFor(tx) * GasPrice.
func FeeFor(tx *Transaction) (uint64, error) {
g, err := GasFor(tx)
if err != nil {
return 0, err
}
return fee.Cost(g, GasPrice)
}
// SupportedScheme reports whether scheme is priced (and therefore accepted) by
// the F-Chain gas schedule.
func SupportedScheme(scheme string) bool {
_, ok := schemeGas[scheme]
return ok
}
// MinScheduledFee is the smallest fee any valid operation can settle (the
// cheapest base operation at GasPrice). gas_test.go asserts it is
// >= node/vms/types/fee.MinTxFeeFloor.
func MinScheduledFee() uint64 {
min := fee.Gas(0)
first := true
for _, g := range opBaseGas {
if first || g < min {
min, first = g, false
}
}
f, _ := fee.Cost(min, GasPrice)
return f
}