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.
This commit is contained in:
2026-08-27 16:23:29 -07:00
parent 8daf8f12aa
commit 2656b30487
22 changed files with 5491 additions and 17 deletions
+34 -11
View File
@@ -35,20 +35,43 @@ domain-execution chain that MUST consume UTXO settlement via the
| `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 |
| `mpcvm/fhe/` | **F** | FHE runtime **library** — a package inside `mpcvm`, not a chain. See the deployment note below. | LP-8200 |
| `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**, and the
two are not equally close to shipping:
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.
- **M** has a full VM (`mpcvm/`, `mpcvm/cmd/plugin`) — it builds, it just has no
chain registered on any network.
- **F** has **no VM at all**. `luxfi/constants` reserves
`FHEVMID = {'f','h','e','v','m'}` and `luxfi/node/node/vms.go` lists it in
`OptionalVMs` as plugin `fhevm`, so luxd scans `--plugin-dir` for a binary
that this repo never produces (no `fhevm/` directory ⇒ no `make` target ⇒ no
binary). `mpcvm/fhe/` above is the FHE runtime library, not a standalone
F-Chain. **F-Chain is a spec (LP-8200, LP-167) with no shipping VM.**
### 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 for a
state root, because two validators replaying one block would write different
bytes. F owns its persistence and every timestamp it writes comes from the
accepting block.
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
+5 -1
View File
@@ -1,4 +1,8 @@
VMS := $(shell ls -d */ 2>/dev/null | sed 's|/||' | grep -v '.github\|build\|node_modules')
# The VMs that ship a luxd plugin binary, named rather than globbed: this
# directory also holds packages that are libraries (dexvm, fee, ownership,
# internal) and a tool (cmd), and a glob swept those in and stopped the build at
# the first one with no plugin entrypoint.
VMS := aivm bridgevm evm fhevm graphvm identityvm keyvm mpcvm oraclevm quantumvm relayvm schain zkvm
.PHONY: all test clean $(VMS)
+9 -1
View File
@@ -13,11 +13,19 @@ operator daemons (no luxd validator required), the same way `mpcd` and
| R-Chain (relay) | `chains/relayvm/` (shim) | `luxfi/relay/vm/` | `luxfi/relay/cmd/relayd` |
| O-Chain (oracle) | `chains/oraclevm/` (shim) | `luxfi/oracle/vm/` | `luxfi/oracle/cmd/oracled` |
| M-Chain (MPC threshold) | `luxfi/chains/mpcvm/` (canonical, MPC mode) | (same) | n/a — runs in luxd |
| F-Chain (FHE) | `luxfi/chains/mpcvm/` (canonical, FHE mode) | (same) | `luxfi/fhe/cmd/fhed` (standalone FHE daemon) |
| F-Chain (FHE) | `luxfi/chains/fhevm/` (canonical) | (same) | n/a — runs in luxd |
| C-Chain (EVM) | `chains/evm/` | uses `chains/evm/cevm` (C++/GPU FFI shim) or pure-Go (default) via build tags | n/a — runs in luxd |
| A-Chain (AI) | `chains/aivm/` | `luxfi/ai` | n/a |
| I-Chain (identity) | `chains/identityvm/` | `luxfi/id` | n/a |
M and F are both canonical here — they are written in this repo, not shimmed
from another. They divide the work LP-134 took off T-Chain: M does threshold
signing and bridge custody, F coordinates confidential compute. F wraps the FHE
runtime library at `mpcvm/fhe/`, which stays where it is because `mpcvm` also
uses it; F reuses that package's types and threshold parameters and owns its own
persistence, since the runtime's `Registry` stamps `time.Now()` and cannot back
a state root.
The shim pattern is uniform:
```go
+16 -4
View File
@@ -25,9 +25,12 @@ Or copy binaries to `~/.lux/plugins/<vmid>`.
## VMs
One row per directory in this repo. `make` derives its target list from the
directory listing, so a VM that has no directory here has no binary — the table
below and `ls` can never disagree.
`make`'s target list is named in the Makefile, and every name in it is a
directory here that ships a plugin entrypoint (`<vm>/cmd/plugin`, or `evm/main.go`).
`dexvm/` is the exception in the table below: it is a library the EVM plugin
embeds — it has no entrypoint of its own, so `make` builds no `dexvm` binary.
The other directories here (`fee/`, `ownership/`, `internal/`, `cmd/`) are
libraries and a tool, and are not VMs.
**Live** means the VM has a chain registered on Lux mainnet 96369
(`platform.getBlockchains`).
@@ -46,6 +49,7 @@ below and `ls` can never disagree.
| oraclevm | O-Chain | ❌ built, not deployed | Oracle/off-chain data |
| relayvm | R-Chain | ❌ built, not deployed | Cross-chain relay |
| mpcvm | M-Chain | ❌ built, not deployed | MPC ceremonies (CGGMP21, FROST, Pulsar-general) — bridge custody for external wallets (LP-7100) |
| fhevm | F-Chain | ❌ built, not deployed | Confidential compute (LP-8200): ciphertext handles, access permits, threshold decryption. Records the public coordinates of encrypted values and holds none of the encrypted part — no ciphertext body, no FHE key, no share. Wraps the FHE runtime at `mpcvm/fhe/` |
| schain | S-Chain | ❌ built, not deployed | S3-style object storage (on-chain metadata / off-chain blob). Declares its own `VMID = ids.ID{'s','c','h','a','i','n'}` in `schain/factory.go` and is **not** in the canonical `luxfi/constants` VM-ID registry |
`P-Chain` (platformvm) and `X-Chain` (xvm) are in-process core VMs registered by
@@ -58,4 +62,12 @@ are the ten chains live on 96369.
|------|--------|
| `teleportvm` / T-Chain | **Removed.** LP-134 dissolves T-Chain with zero remainder: threshold signing → M-Chain, FHE → F-Chain, cross-chain messaging/teleport → B-Chain (`bridgevm`). LP-5013 and LP-7330 are deprecated by LP-134; see LP-7050 for the migration map. No `teleportvm` VM ID exists in `luxfi/constants`. |
| `servicenodevm` | **Never existed.** The service-node registry was never built under this name; the S-Chain directory here is `schain/` and is object storage, not a registry. |
| `fhevm` / F-Chain | **Unbuilt.** `luxfi/constants` reserves `FHEVMID = {'f','h','e','v','m'}` and `luxfi/node/node/vms.go` lists it in `OptionalVMs` as a plugin named `fhevm`, so luxd will scan `--plugin-dir` for it — but there is **no `fhevm/` directory in this repo and no plugin binary anywhere**, so that scan always comes up empty. The FHE *runtime library* lives at `mpcvm/fhe/`; it is a package inside `mpcvm`, not a standalone chain. F-Chain is a spec (LP-8200, LP-167) with no shipping VM. |
### Plugin filenames
luxd resolves a `CreateChainTx`'s vmID to an implementation by looking for a file
of that name in `--plugin-dir`, so a plugin installed under any other name is
invisible and its chain silently never starts. The filename is the CB58 of the
vmID — `n6sSsSfbpQBrU9sY4R29U6z8VrmnTo2CntW6da4rRS7qmnGdv` for `fhevm`,
`qCURact1n41FcoNBch8iMVBwc9AWie48D118ZNJ5tBdWrvryS` for `mpcvm`. Each VM pins its
own in a test, because a vmID cannot change once a chain has been created with it.
+254
View File
@@ -0,0 +1,254 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"context"
"crypto/sha256"
"encoding/binary"
"fmt"
"time"
"github.com/luxfi/chains/fee"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
// Block is an F-Chain block: an ordered batch of fee-settled confidential-
// compute operations.
type Block struct {
id ids.ID
parentID ids.ID
height uint64
timestamp time.Time
transactions []*Transaction
vm *VM
}
func (b *Block) computeID() ids.ID {
h := sha256.New()
h.Write(b.parentID[:])
var u8 [8]byte
binary.BigEndian.PutUint64(u8[:], b.height)
h.Write(u8[:])
binary.BigEndian.PutUint64(u8[:], uint64(b.timestamp.Unix()))
h.Write(u8[:])
for _, tx := range b.transactions {
id := tx.ID()
h.Write(id[:])
}
return ids.ID(h.Sum(nil))
}
func (b *Block) ID() ids.ID {
if b.id == ids.Empty {
b.id = b.computeID()
}
return b.id
}
func (b *Block) ParentID() ids.ID { return b.parentID }
func (b *Block) Parent() ids.ID { return b.parentID }
func (b *Block) Height() uint64 { return b.height }
func (b *Block) Timestamp() time.Time { return b.timestamp }
// Verify checks the block can be accepted WITHOUT mutating state: the parent
// exists, every transaction is well-formed and authenticated, no two
// transactions bring about the same effect, and every payer can afford its fee
// — including the cumulative fees of multiple transactions from the same payer
// within this block. A block that fails any check is never accepted (fail
// closed); verifying a block never moves funds.
func (b *Block) Verify(ctx context.Context) error {
if b.height > 0 {
if _, err := b.vm.GetBlock(ctx, b.parentID); err != nil {
return fmt.Errorf("fhevm: verify parent: %w", err)
}
}
b.vm.stateLock.RLock()
defer b.vm.stateLock.RUnlock()
spent := make(map[fee.Account]uint64) // running per-payer debit within this block
expectedNonce := make(map[fee.Account]uint64) // running per-payer nonce within this block
effects := make(map[[32]byte]struct{}) // effects already claimed within this block
for _, tx := range b.transactions {
if err := tx.SyntacticVerify(); err != nil {
return err
}
if err := tx.authenticate(); err != nil {
return err
}
// Replay/order guard runs BEFORE authorization so a replayed tx is
// rejected as such regardless of the operation's other preconditions.
expN, ok := expectedNonce[tx.Payer]
if !ok {
expN = b.vm.nonceOf(tx.Payer) + 1
}
if tx.Nonce != expN {
return ErrBadNonce
}
expectedNonce[tx.Payer] = expN + 1
// Uniqueness guard: checkAuth reads COMMITTED state, so two transactions
// claiming the same effect both pass it and only collide in Accept,
// where the collision aborts the whole block. Catch it here instead, so
// a block carrying such a pair is refused rather than accepted and then
// failed.
eff := tx.effect()
if _, dup := effects[eff]; dup {
return ErrDuplicateEffect
}
effects[eff] = struct{}{}
if err := tx.checkAuth(b.vm, b.timestamp.Unix()); err != nil {
return err
}
gasUsed, err := GasFor(tx)
if err != nil {
return err
}
if uint64(gasUsed) > tx.GasLimit {
return fmt.Errorf("fhevm: %w: gas %d > limit %d", fee.ErrOutOfGas, gasUsed, tx.GasLimit)
}
feeAmt, err := fee.Cost(gasUsed, GasPrice)
if err != nil {
return err
}
bal, err := b.vm.ledger.Balance(tx.Payer)
if err != nil {
return err
}
prev := spent[tx.Payer]
next, over := addBlockSpend(prev, feeAmt)
if over || bal < next {
return fee.ErrInsufficientFunds
}
spent[tx.Payer] = next
}
return nil
}
func addBlockSpend(a, b uint64) (uint64, bool) {
s := a + b
return s, s < a
}
// Accept settles and applies the block atomically. For each transaction it
// METERS the operation's gas, BURNS the fee from the payer (debit + supply
// reduction), then APPLIES the state effect — all written through the VM's
// versiondb, which is committed exactly once. Any failure aborts the whole
// block (no partial application, no unpaid operation): the versiondb is rolled
// back and the caches are reloaded from the unchanged base DB.
func (b *Block) Accept(ctx context.Context) error {
now := b.timestamp.Unix()
b.vm.stateLock.Lock()
defer b.vm.stateLock.Unlock()
if err := b.settleAndApply(now); err != nil {
b.abort()
return err
}
// Persist block, height index and last-accepted pointer in the same commit.
if err := b.vm.state.Put(append([]byte(BlockPrefix), b.id[:]...), b.Bytes()); err != nil {
b.abort()
return err
}
if err := b.vm.state.Put(heightKey(b.height), b.id[:]); err != nil {
b.abort()
return err
}
if err := b.vm.state.Put(lastAcceptedKey, b.id[:]); err != nil {
b.abort()
return err
}
if err := b.vm.versdb.Commit(); err != nil {
b.abort()
return fmt.Errorf("fhevm: commit block %s: %w", b.id, err)
}
b.vm.lastAccepted = b.id
b.vm.lastBlock = b
b.vm.height = b.height
b.vm.shutdownLock.Lock()
delete(b.vm.pendingBlocks, b.id)
b.vm.shutdownLock.Unlock()
b.vm.dropFromMempool(b.transactions)
b.vm.log.Info("F-Chain block accepted",
log.String("blockID", b.id.String()),
log.Uint64("height", b.height),
log.Int("txs", len(b.transactions)),
)
return nil
}
// abort rolls back the versiondb and reloads caches from the unchanged base DB.
// The caller (Accept) holds stateLock.
func (b *Block) abort() {
b.vm.versdb.Abort()
if err := b.vm.loadStateLocked(); err != nil {
b.vm.log.Error("fhevm: reload caches after abort", log.String("error", err.Error()))
}
}
// settleAndApply burns each tx fee and applies its effect. Caller holds
// stateLock and is responsible for Abort on error.
func (b *Block) settleAndApply(now int64) error {
for _, tx := range b.transactions {
// Replay/order guard: nonce must be exactly the payer's next. Reads the
// versiondb so earlier txs in this same block (buffered) are seen.
if tx.Nonce != b.vm.nonceOf(tx.Payer)+1 {
return ErrBadNonce
}
gasUsed, err := GasFor(tx)
if err != nil {
return err
}
// Meter the operation against the payer's gas limit.
meter := fee.NewGasMeter(fee.Gas(tx.GasLimit))
if err := meter.Consume(gasUsed); err != nil {
return err
}
feeAmt, err := fee.Cost(meter.Used(), GasPrice)
if err != nil {
return err
}
// Debit + burn the fee from the payer's balance.
if err := fee.Charge(b.vm.ledger, tx.Payer, feeAmt); err != nil {
return err
}
// Apply the operation's state effect (atomically with the burn).
if err := tx.Apply(b.vm, now); err != nil {
return err
}
// Advance the payer's nonce (atomically with the burn + effect).
if err := b.vm.setNonce(tx.Payer, tx.Nonce); err != nil {
return err
}
}
return nil
}
// Reject discards the block and returns its transactions to the mempool so they
// can be retried in a later block.
func (b *Block) Reject(ctx context.Context) error {
b.vm.shutdownLock.Lock()
delete(b.vm.pendingBlocks, b.id)
b.vm.shutdownLock.Unlock()
b.vm.requeue(b.transactions)
return nil
}
// Status returns 0=processing, 1=accepted.
func (b *Block) Status() uint8 {
b.vm.stateLock.RLock()
defer b.vm.stateLock.RUnlock()
if b.id == b.vm.lastAccepted {
return 1
}
if ok, _ := b.vm.state.Has(append([]byte(BlockPrefix), b.id[:]...)); ok {
return 1
}
return 0
}
+39
View File
@@ -0,0 +1,39 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"github.com/luxfi/constants"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/node/vms"
)
var _ vms.Factory = (*Factory)(nil)
// VMID identifies F-Chain: coordination of confidential compute — ciphertext
// handles, access permits, and threshold decryption (LP-8200, LP-167). It is
// constants.FHEVMID and nothing else.
//
// A vmID is an immutable one-way door: it is baked into the CreateChainTx at
// genesis, it is the plugin binary's filename, and it is what the P-Chain
// stores forever. Every declaration of it must agree, so there is exactly one —
// this alias — and it points at the single source of truth in luxfi/constants,
// which node/node/vms.go already lists in OptionalVMs under the plugin name
// "fhevm".
var VMID = constants.FHEVMID
// Assert at compile time that the alias really is the shared constant, so a
// future edit cannot silently introduce a private literal.
var _ = map[bool]struct{}{VMID == ids.ID{'f', 'h', 'e', 'v', 'm'}: {}}
// Factory creates F-Chain VM instances.
type Factory struct{}
// New returns a new F-Chain VM. Everything that needs configuration, a
// database, or a committee is set up in Initialize; a Factory-built VM holds no
// state.
func (f *Factory) New(log.Logger) (interface{}, error) {
return &VM{}, nil
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"github.com/luxfi/constants"
nodefee "github.com/luxfi/node/vms/types/fee"
)
// newFeePolicy returns the F-Chain ADMISSION policy: a non-zero floor so the
// chain Manager's boot-time fee.Validate never flags F as a zero-fee
// user-facing chain (see node/vms/types/fee/policy.go).
//
// This is ORTHOGONAL to SETTLEMENT. Admission (here) is a static declaration
// "this chain charges at least the floor"; settlement (github.com/luxfi/chains/
// fee, driven in block Accept) performs the actual per-operation debit + burn,
// priced by the per-scheme gas schedule (gas.go). The floor declared here is
// below MinScheduledFee(), so the two surfaces agree — proven in gas_test.go.
func newFeePolicy(networkID uint32) nodefee.Policy {
return nodefee.FlatPolicy{
Fee: nodefee.MinTxFeeFloor,
AssetID: constants.UTXOAssetIDFor(networkID),
}
}
// FeePolicy exposes the chain's declared admission policy for diagnostics and
// the boot-time Validate gate.
func (vm *VM) FeePolicy() nodefee.Policy { return vm.feePolicy }
+530
View File
@@ -0,0 +1,530 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/chains/mpcvm/fhe"
)
// These tests exercise the operations F actually exposes — registering an
// encrypted value, granting and withdrawing access to it, asking the committee
// to decrypt it, and rotating the committee — and the threshold rules that make
// each of them safe.
// fundAll builds a genesis allocation funding every given key.
func fundAll(keys ...testKey) map[string]uint64 {
alloc := make(map[string]uint64, len(keys))
for _, k := range keys {
alloc[k.hexAddr()] = testFund
}
return alloc
}
// newDecryptVM seats an n-member committee at threshold t and funds the members
// plus the two given users.
func newDecryptVM(t *testing.T, n, threshold int, users ...testKey) (*VM, []fhe.CommitteeMember, []testKey) {
t.Helper()
committee, keys := newCommittee(t, n)
vm := newTestVM(t, fundAll(append(append([]testKey{}, keys...), users...)...), committee, threshold)
return vm, committee, keys
}
// TestConfidentialLifecycle walks one encrypted value from registration to a
// completed threshold decryption — the whole reason F exists, end to end.
func TestConfidentialLifecycle(t *testing.T) {
owner, grantee := newTestKey(t), newTestKey(t)
vm, _, members := newDecryptVM(t, 3, 2, owner, grantee)
// 1. The owner registers an encrypted value. F stores its digest, never it.
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, 0)
ct, ok := vm.Ciphertext(handle)
require.True(t, ok)
require.Equal(t, uint64(0), ct.Epoch, "a ciphertext is bound to the epoch it was registered in")
// 2. The grantee, holding the permit, asks the committee to decrypt.
req := requestTx(t, grantee, testScheme, handle, permitID, 0, 1)
acceptOne(t, vm, req)
requestID := deriveRequestID(handle, grantee.addr, 1)
rec, ok := vm.Decrypt(requestID)
require.True(t, ok)
require.Equal(t, fhe.RequestPending, rec.Status)
require.Equal(t, handle, rec.CiphertextHandle)
require.Equal(t, permitID, rec.PermitID)
require.Equal(t, uint64(0), rec.Epoch)
require.Greater(t, rec.Expiry, rec.CreatedAt, "an unbounded request would outlive the committee that can answer it")
require.Equal(t, rec.CreatedAt+DefaultRequestWindow, rec.Expiry)
// 3. Two of three members attest the same result handle. F never sees the
// plaintext — the committee combines its shares off-chain and agrees on
// the handle of what came out.
result := digestOf("decrypted-result-handle")
acceptOne(t, vm, fulfillTx(t, members[0], requestID, result, 1))
rec, _ = vm.Decrypt(requestID)
require.Equal(t, fhe.RequestPending, rec.Status, "one member is not a threshold")
require.Len(t, rec.Attestations, 1)
acceptOne(t, vm, fulfillTx(t, members[1], requestID, result, 1))
rec, _ = vm.Decrypt(requestID)
require.Equal(t, fhe.RequestCompleted, rec.Status, "the threshold answers the request")
require.Equal(t, result, rec.ResultHandle)
require.Len(t, rec.Attestations, 2)
require.NotZero(t, rec.CompletedAt)
// 4. The answer is final: a third attestation is refused.
_, err := vm.SubmitTx(fulfillTx(t, members[2], requestID, result, 1))
require.ErrorIs(t, err, ErrRequestClosed)
}
// TestConflictingAttestationCannotStall proves a lying member buys nothing but
// its own burnt fee. It attests first, and wrong; the honest members still
// reach the threshold on the true result, because votes are tallied per value
// rather than pinned by whoever spoke first.
func TestConflictingAttestationCannotStall(t *testing.T) {
owner, grantee := newTestKey(t), newTestKey(t)
vm, _, members := newDecryptVM(t, 3, 2, owner, grantee)
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, 0)
acceptOne(t, vm, requestTx(t, grantee, testScheme, handle, permitID, 0, 1))
requestID := deriveRequestID(handle, grantee.addr, 1)
liar, truth := digestOf("forged"), digestOf("true-result")
acceptOne(t, vm, fulfillTx(t, members[2], requestID, liar, 1))
rec, _ := vm.Decrypt(requestID)
require.Equal(t, fhe.RequestPending, rec.Status)
acceptOne(t, vm, fulfillTx(t, members[0], requestID, truth, 1))
acceptOne(t, vm, fulfillTx(t, members[1], requestID, truth, 1))
rec, _ = vm.Decrypt(requestID)
require.Equal(t, fhe.RequestCompleted, rec.Status)
require.Equal(t, truth, rec.ResultHandle, "the honest majority decides the answer")
require.Len(t, rec.Attestations, 3, "the false vote is recorded, and counted against its own value")
// The liar paid for the privilege.
bal, _ := vm.Balance(members[2].addr)
require.Less(t, bal, testFund)
}
// TestOnlyCommitteeAnswers proves a stranger cannot answer a decryption
// request, and a member cannot vote twice.
func TestOnlyCommitteeAnswers(t *testing.T) {
owner, grantee := newTestKey(t), newTestKey(t)
stranger := newTestKey(t)
committee, members := newCommittee(t, 3)
vm := newTestVM(t, fundAll(append(append([]testKey{}, members...), owner, grantee, stranger)...), committee, 2)
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, 0)
acceptOne(t, vm, requestTx(t, grantee, testScheme, handle, permitID, 0, 1))
requestID := deriveRequestID(handle, grantee.addr, 1)
result := digestOf("r")
_, err := vm.SubmitTx(fulfillTx(t, stranger, requestID, result, 1))
require.ErrorIs(t, err, ErrNotCommittee)
// Even the requester, who has every right to the answer, is not a member.
_, err = vm.SubmitTx(fulfillTx(t, grantee, requestID, result, 2))
require.ErrorIs(t, err, ErrNotCommittee)
acceptOne(t, vm, fulfillTx(t, members[0], requestID, result, 1))
// One member, one vote — including a member trying to change its mind.
_, err = vm.SubmitTx(fulfillTx(t, members[0], requestID, result, 2))
require.ErrorIs(t, err, ErrAlreadyAttested)
_, err = vm.SubmitTx(fulfillTx(t, members[0], requestID, digestOf("other"), 2))
require.ErrorIs(t, err, ErrAlreadyAttested)
}
// TestUnknownRequestCannotBeAnswered proves an attestation to a request that
// was never made is refused, so the committee cannot invent answers.
func TestUnknownRequestCannotBeAnswered(t *testing.T) {
vm, _, members := newDecryptVM(t, 3, 2)
_, err := vm.SubmitTx(fulfillTx(t, members[0], digestOf("never-asked"), digestOf("r"), 1))
require.ErrorIs(t, err, ErrRequestNotFound)
}
// TestExpiredRequestCannotBeAnswered proves a request outlives nothing: once
// its window closes, no attestation is accepted and it can never complete.
func TestExpiredRequestCannotBeAnswered(t *testing.T) {
owner, grantee := newTestKey(t), newTestKey(t)
vm, _, members := newDecryptVM(t, 3, 2, owner, grantee)
now := time.Now()
vm.clock.Set(now)
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, 0)
expiry := now.Unix() + 60
acceptOne(t, vm, requestTx(t, grantee, testScheme, handle, permitID, expiry, 1))
requestID := deriveRequestID(handle, grantee.addr, 1)
// Still answerable inside the window.
require.NoError(t, fulfillTx(t, members[0], requestID, digestOf("r"), 1).checkAuth(vm, expiry))
// One second past it, closed.
vm.clock.Set(time.Unix(expiry+1, 0))
_, err := vm.SubmitTx(fulfillTx(t, members[0], requestID, digestOf("r"), 1))
require.ErrorIs(t, err, ErrRequestExpired)
// And the read surface says so, rather than reporting it as still pending.
var reply GetDecryptReply
require.NoError(t, (&Service{vm: vm}).GetDecrypt(nil,
&GetDecryptArgs{RequestID: hexOf(requestID)}, &reply))
require.Equal(t, fhe.RequestExpired.String(), reply.Request.Status)
}
// TestPermitGatesDecryption proves the permit is the whole authority for a
// decryption request: it must exist, be unrevoked, be unexpired, name THIS
// handle, name THIS grantee, and confer decrypt specifically.
func TestPermitGatesDecryption(t *testing.T) {
owner, grantee := newTestKey(t), newTestKey(t)
stranger := newTestKey(t)
committee, members := newCommittee(t, 1)
vm := newTestVM(t, fundAll(append(append([]testKey{}, members...), owner, grantee, stranger)...), committee, 1)
now := time.Now()
vm.clock.Set(now)
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, 0)
t.Run("no such permit", func(t *testing.T) {
_, err := vm.SubmitTx(requestTx(t, grantee, testScheme, handle, digestOf("nope"), 0, 1))
require.ErrorIs(t, err, ErrPermitNotFound)
})
t.Run("permit belongs to someone else", func(t *testing.T) {
_, err := vm.SubmitTx(requestTx(t, stranger, testScheme, handle, permitID, 0, 1))
require.ErrorIs(t, err, ErrUnauthorized)
})
t.Run("permit is for another handle", func(t *testing.T) {
// A second ciphertext the grantee has no permit for.
other := registerTx(t, owner, testScheme, digestOf("other-value"), 3)
acceptOne(t, vm, other)
_, err := vm.SubmitTx(requestTx(t, grantee, testScheme, other.Subject, permitID, 0, 1))
require.ErrorIs(t, err, ErrPermitInvalid)
})
t.Run("ciphertext was never registered", func(t *testing.T) {
_, err := vm.SubmitTx(requestTx(t, grantee, testScheme, digestOf("unregistered"), permitID, 0, 1))
require.ErrorIs(t, err, ErrCiphertextNotFound)
})
t.Run("permit confers compute but not decrypt", func(t *testing.T) {
computeOnly := grantTx(t, owner, handle, grantee.addr, fhe.PermitOpCompute, 0, 4)
acceptOne(t, vm, computeOnly)
id := derivePermitID(handle, owner.addr, grantee.addr, fhe.PermitOpCompute, 0, 4)
_, err := vm.SubmitTx(requestTx(t, grantee, testScheme, handle, id, 0, 1))
require.ErrorIs(t, err, ErrPermitInvalid)
})
t.Run("permit has expired", func(t *testing.T) {
expiry := now.Unix() + 30
short := grantTx(t, owner, handle, grantee.addr, fhe.PermitOpDecrypt, expiry, 5)
acceptOne(t, vm, short)
id := derivePermitID(handle, owner.addr, grantee.addr, fhe.PermitOpDecrypt, expiry, 5)
vm.clock.Set(time.Unix(expiry+1, 0))
_, err := vm.SubmitTx(requestTx(t, grantee, testScheme, handle, id, 0, 1))
require.ErrorIs(t, err, ErrPermitExpired)
vm.clock.Set(now)
})
t.Run("permit was revoked", func(t *testing.T) {
acceptOne(t, vm, revokeTx(t, owner, permitID, 6))
pm, _ := vm.Permit(permitID)
require.Equal(t, StatusRevoked, pm.Status)
_, err := vm.SubmitTx(requestTx(t, grantee, testScheme, handle, permitID, 0, 1))
require.ErrorIs(t, err, ErrPermitRevoked)
})
}
// TestOnlyOwnerGrantsAndRevokes proves a capability over an encrypted value
// comes from its owner and from nobody else — including the grantee, who cannot
// pass its access on, and cannot keep it after the owner withdraws it.
func TestOnlyOwnerGrantsAndRevokes(t *testing.T) {
owner, grantee := newTestKey(t), newTestKey(t)
stranger := newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, fundAll(owner, grantee, stranger), committee, 1)
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, 0)
// A stranger cannot grant over someone else's ciphertext...
_, err := vm.SubmitTx(grantTx(t, stranger, handle, stranger.addr, fhe.PermitOpDecrypt, 0, 1))
require.ErrorIs(t, err, ErrUnauthorized)
// ...nor can the grantee pass its own access along.
_, err = vm.SubmitTx(grantTx(t, grantee, handle, stranger.addr, fhe.PermitOpDecrypt, 0, 1))
require.ErrorIs(t, err, ErrUnauthorized)
// Granting over a ciphertext nobody registered is refused.
_, err = vm.SubmitTx(grantTx(t, owner, digestOf("ghost"), grantee.addr, fhe.PermitOpDecrypt, 0, 3))
require.ErrorIs(t, err, ErrCiphertextNotFound)
// A stranger cannot withdraw someone else's grant.
_, err = vm.SubmitTx(revokeTx(t, stranger, permitID, 1))
require.ErrorIs(t, err, ErrUnauthorized)
// Revoking something that was never granted is refused.
_, err = vm.SubmitTx(revokeTx(t, owner, digestOf("no-permit"), 3))
require.ErrorIs(t, err, ErrPermitNotFound)
// The owner can, and once withdrawn it stays withdrawn.
acceptOne(t, vm, revokeTx(t, owner, permitID, 3))
_, err = vm.SubmitTx(revokeTx(t, owner, permitID, 4))
require.ErrorIs(t, err, ErrPermitRevoked)
}
// TestRegisterIsContentAddressed proves a handle names its content: the same
// body under the same scheme is the same handle, so it cannot be registered
// twice, and a handle cannot be claimed by someone who does not have the body
// to hash.
func TestRegisterIsContentAddressed(t *testing.T) {
a, b := newTestKey(t), newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, fundAll(a, b), committee, 1)
body := digestOf("the-encrypted-body")
acceptOne(t, vm, registerTx(t, a, testScheme, body, 1))
// A different account cannot re-register the same content.
_, err := vm.SubmitTx(registerTx(t, b, testScheme, body, 1))
require.ErrorIs(t, err, ErrCiphertextExists)
// The same content under a DIFFERENT scheme is a different value, and is
// registrable — a CKKS ciphertext and a BFV one are not the same object.
acceptOne(t, vm, registerTx(t, b, "bfv-n13", body, 1))
require.Len(t, vm.Ciphertexts(), 2)
one, ok := vm.Ciphertext(deriveHandle(body, testScheme))
require.True(t, ok)
require.Equal(t, testScheme, one.Scheme)
two, ok := vm.Ciphertext(deriveHandle(body, "bfv-n13"))
require.True(t, ok)
require.Equal(t, "bfv-n13", two.Scheme)
}
// TestEpochAdvance proves the committee installs its own successor by threshold
// vote, and that the change takes effect only when the threshold is reached.
func TestEpochAdvance(t *testing.T) {
vm, _, members := newDecryptVM(t, 3, 2)
next, nextKeys := newCommittee(t, 3)
nextPK := []byte("epoch-1-network-key")
// Fund the incoming committee so it can answer once seated.
for _, k := range nextKeys {
vm.stateLock.Lock()
require.NoError(t, vm.ledger.Credit(k.addr, testFund))
require.NoError(t, vm.versdb.Commit())
vm.stateLock.Unlock()
}
// One vote is not enough.
acceptOne(t, vm, advanceTx(t, members[0], 1, next, 2, nextPK, 1))
require.Equal(t, uint64(0), vm.CurrentEpoch(), "one member is not a threshold")
cur, _ := vm.Epoch(0)
require.Len(t, cur.Attestations, 1)
require.Equal(t, fhe.EpochActive, cur.Status)
// The second vote installs it, atomically with the block.
blk := func() *Block {
_, err := vm.SubmitTx(advanceTx(t, members[1], 1, next, 2, nextPK, 1))
require.NoError(t, err)
return acceptQueued(t, vm)
}()
require.Equal(t, uint64(1), vm.CurrentEpoch())
old, _ := vm.Epoch(0)
require.Equal(t, fhe.EpochEnded, old.Status)
require.Equal(t, blk.timestamp.Unix(), old.EndTime)
installed, ok := vm.Epoch(1)
require.True(t, ok)
require.Equal(t, fhe.EpochActive, installed.Status)
require.Equal(t, 2, installed.Threshold)
require.Equal(t, nextPK, installed.PublicKey)
require.Len(t, installed.Committee, 3)
require.Empty(t, installed.Attestations, "a fresh epoch starts with no votes cast")
// The outgoing committee no longer decides anything.
further, _ := newCommittee(t, 3)
_, err := vm.SubmitTx(advanceTx(t, members[2], 2, further, 2, []byte("k"), 1))
require.ErrorIs(t, err, ErrNotCommittee)
// The incoming one does.
require.NoError(t, advanceTx(t, nextKeys[0], 2, further, 2, []byte("k"), 1).checkAuth(vm, time.Now().Unix()))
}
// TestEpochAdvanceRefusals proves the rules around who may rotate the committee
// and to what.
func TestEpochAdvanceRefusals(t *testing.T) {
stranger := newTestKey(t)
committee, members := newCommittee(t, 3)
vm := newTestVM(t, fundAll(append(append([]testKey{}, members...), stranger)...), committee, 2)
next, _ := newCommittee(t, 3)
pk := []byte("k")
// A stranger cannot propose a successor.
_, err := vm.SubmitTx(advanceTx(t, stranger, 1, next, 2, pk, 1))
require.ErrorIs(t, err, ErrNotCommittee)
// Nor can a member skip an epoch or re-elect the current one.
_, err = vm.SubmitTx(advanceTx(t, members[0], 2, next, 2, pk, 1))
require.ErrorIs(t, err, ErrEpochMismatch)
_, err = vm.SubmitTx(advanceTx(t, members[0], 0, next, 2, pk, 1))
require.ErrorIs(t, err, ErrEpochMismatch)
// One member, one vote — and voting for a rival proposal is still a second
// vote, so a member cannot split the tally by itself.
acceptOne(t, vm, advanceTx(t, members[0], 1, next, 2, pk, 1))
_, err = vm.SubmitTx(advanceTx(t, members[0], 1, next, 2, pk, 2))
require.ErrorIs(t, err, ErrAlreadyAttested)
rival, _ := newCommittee(t, 3)
_, err = vm.SubmitTx(advanceTx(t, members[0], 1, rival, 2, pk, 2))
require.ErrorIs(t, err, ErrAlreadyAttested)
}
// TestRequestBindsToTheSeatedEpoch proves an answer comes from the committee
// that was seated when the request was made. A later committee holds different
// key shares and never saw the permit, so it must not be able to answer.
func TestRequestBindsToTheSeatedEpoch(t *testing.T) {
owner, grantee := newTestKey(t), newTestKey(t)
vm, _, members := newDecryptVM(t, 3, 2, owner, grantee)
next, nextKeys := newCommittee(t, 3)
for _, k := range nextKeys {
vm.stateLock.Lock()
require.NoError(t, vm.ledger.Credit(k.addr, testFund))
require.NoError(t, vm.versdb.Commit())
vm.stateLock.Unlock()
}
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, 0)
acceptOne(t, vm, requestTx(t, grantee, testScheme, handle, permitID, 0, 1))
requestID := deriveRequestID(handle, grantee.addr, 1)
// Rotate the committee out from under the pending request.
acceptOne(t, vm, advanceTx(t, members[0], 1, next, 2, []byte("k"), 1))
acceptOne(t, vm, advanceTx(t, members[1], 1, next, 2, []byte("k"), 1))
require.Equal(t, uint64(1), vm.CurrentEpoch())
// The NEW committee cannot answer the OLD request.
_, err := vm.SubmitTx(fulfillTx(t, nextKeys[0], requestID, digestOf("r"), 1))
require.ErrorIs(t, err, ErrNotCommittee)
// The committee that was seated when it was asked still can.
result := digestOf("r")
acceptOne(t, vm, fulfillTx(t, members[0], requestID, result, 2))
acceptOne(t, vm, fulfillTx(t, members[1], requestID, result, 2))
rec, _ := vm.Decrypt(requestID)
require.Equal(t, fhe.RequestCompleted, rec.Status)
// A request made now belongs to the new epoch.
acceptOne(t, vm, requestTx(t, grantee, testScheme, handle, permitID, 0, 2))
fresh, _ := vm.Decrypt(deriveRequestID(handle, grantee.addr, 2))
require.Equal(t, uint64(1), fresh.Epoch)
}
// TestCommitteelessChainAnswersNothing proves a chain whose genesis seated
// nobody refuses every threshold decision rather than accepting one from
// anybody.
func TestCommitteelessChainAnswersNothing(t *testing.T) {
owner, grantee := newTestKey(t), newTestKey(t)
vm := newTestVM(t, fundAll(owner, grantee), nil, 0)
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, 0)
acceptOne(t, vm, requestTx(t, grantee, testScheme, handle, permitID, 0, 1))
requestID := deriveRequestID(handle, grantee.addr, 1)
// There is no epoch record to name a committee, so nothing can answer.
_, err := vm.SubmitTx(fulfillTx(t, owner, requestID, digestOf("r"), 3))
require.ErrorIs(t, err, ErrEpochNotFound)
// And nobody can seat themselves.
next, _ := newCommittee(t, 3)
_, err = vm.SubmitTx(advanceTx(t, owner, 1, next, 2, []byte("k"), 3))
require.ErrorIs(t, err, ErrNotCommittee)
}
// TestBuildBlockWithoutParent proves block building fails closed rather than
// dereferencing a missing tip.
func TestBuildBlockWithoutParent(t *testing.T) {
vm := &VM{}
vm.mempool = []*Transaction{sampleTx()}
_, err := vm.BuildBlock(context.Background())
require.ErrorIs(t, err, errNoParentBlock)
require.Len(t, vm.mempool, 1, "the transactions must go back to the mempool")
}
// TestBuildBlockRefusesWhenShuttingDown proves a stopping VM proposes nothing.
func TestBuildBlockRefusesWhenShuttingDown(t *testing.T) {
k := newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, fundAll(k), committee, 1)
_, err := vm.SubmitTx(registerTx(t, k, testScheme, digestOf("x"), 1))
require.NoError(t, err)
require.NoError(t, vm.Shutdown(context.Background()))
_, err = vm.BuildBlock(context.Background())
require.ErrorIs(t, err, errVMShutdown)
}
// TestBuildBlockWithEmptyMempool proves an idle VM proposes nothing.
func TestBuildBlockWithEmptyMempool(t *testing.T) {
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, nil, committee, 1)
_, err := vm.BuildBlock(context.Background())
require.ErrorIs(t, err, errNoPendingTxs)
}
// TestHeightIndexRejectsCorruptEntry proves the height index is validated when
// it is read, so a damaged entry surfaces as an error instead of a truncated id.
func TestHeightIndexRejectsCorruptEntry(t *testing.T) {
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, nil, committee, 1)
vm.stateLock.Lock()
require.NoError(t, vm.state.Put(heightKey(5), []byte{1, 2, 3}))
require.NoError(t, vm.versdb.Commit())
vm.stateLock.Unlock()
_, err := vm.GetBlockIDAtHeight(context.Background(), 5)
require.ErrorIs(t, err, ErrInvalidPayload)
}
// TestStateSurvivesReload proves the caches are a projection of the database
// and not the truth: rebuilding them from disk yields the same chain.
func TestStateSurvivesReload(t *testing.T) {
owner, grantee := newTestKey(t), newTestKey(t)
vm, _, members := newDecryptVM(t, 3, 2, owner, grantee)
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, 0)
acceptOne(t, vm, requestTx(t, grantee, testScheme, handle, permitID, 0, 1))
requestID := deriveRequestID(handle, grantee.addr, 1)
acceptOne(t, vm, fulfillTx(t, members[0], requestID, digestOf("r"), 1))
before, _ := vm.Decrypt(requestID)
height, epoch := vm.height, vm.CurrentEpoch()
require.NoError(t, vm.loadState())
require.Equal(t, height, vm.height)
require.Equal(t, epoch, vm.CurrentEpoch())
after, ok := vm.Decrypt(requestID)
require.True(t, ok)
require.Equal(t, before.Attestations, after.Attestations)
require.Equal(t, before.Status, after.Status)
_, ok = vm.Ciphertext(handle)
require.True(t, ok)
pm, ok := vm.Permit(permitID)
require.True(t, ok)
require.Equal(t, StatusActive, pm.Status)
ep, ok := vm.Epoch(0)
require.True(t, ok)
require.Len(t, ep.Committee, 3)
}
+114
View File
@@ -0,0 +1,114 @@
// 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)
// 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, 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
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"testing"
"github.com/stretchr/testify/require"
nodefee "github.com/luxfi/node/vms/types/fee"
)
// TestGas_PerSchemeDistinct proves the cost model is real: a TFHE boolean, a
// BFV vector and a CKKS vector are priced differently, and doubling the ring
// dimension raises the price.
func TestGas_PerSchemeDistinct(t *testing.T) {
feeFor := func(scheme string) uint64 {
f, err := FeeFor(&Transaction{Type: TxRequestDecrypt, Scheme: scheme})
require.NoError(t, err)
return f
}
require.Less(t, feeFor("tfhe-n10"), feeFor("bfv-n13"), "small LWE ciphertexts are cheapest")
require.Less(t, feeFor("bfv-n13"), feeFor("ckks-n13"), "at equal N, CKKS costs more than BFV")
require.Less(t, feeFor("bfv-n13"), feeFor("bfv-n14"), "doubling the ring dimension must cost more")
require.Less(t, feeFor("ckks-n14"), feeFor("ckks-n15"), "doubling the ring dimension must cost more")
require.NotEqual(t, feeFor("bfv-n13"), feeFor("bgv-n13"), "distinct schemes must price distinctly")
}
// TestGas_AllOperationsMeetFloor proves every scheduled operation settles at or
// above the node admission floor, so settlement and admission never drift.
func TestGas_AllOperationsMeetFloor(t *testing.T) {
for op := range opBaseGas {
if usesScheme(op) {
for scheme := range schemeGas {
f, err := FeeFor(&Transaction{Type: op, Scheme: scheme})
require.NoError(t, err)
require.GreaterOrEqualf(t, f, nodefee.MinTxFeeFloor,
"op %d scheme %s fee %d below floor %d", op, scheme, f, nodefee.MinTxFeeFloor)
}
continue
}
f, err := FeeFor(&Transaction{Type: op})
require.NoError(t, err)
require.GreaterOrEqualf(t, f, nodefee.MinTxFeeFloor,
"op %d fee %d below floor %d", op, f, nodefee.MinTxFeeFloor)
}
require.GreaterOrEqual(t, MinScheduledFee(), nodefee.MinTxFeeFloor,
"the cheapest scheduled fee must satisfy the admission floor")
}
// TestGas_UnknownSchemeRejected proves an unrecognised scheme is refused (fail
// closed), never priced at the bare base cost.
func TestGas_UnknownSchemeRejected(t *testing.T) {
_, err := GasFor(&Transaction{Type: TxRegisterCiphertext, Scheme: "paillier"})
require.ErrorIs(t, err, ErrUnknownScheme)
_, err = GasFor(&Transaction{Type: TxRequestDecrypt, Scheme: ""})
require.ErrorIs(t, err, ErrUnknownScheme)
require.False(t, SupportedScheme("paillier"))
require.True(t, SupportedScheme(testScheme))
}
// TestGas_UnknownOperationRejected proves an unpriced transaction type cannot
// slip through at zero cost.
func TestGas_UnknownOperationRejected(t *testing.T) {
_, err := GasFor(&Transaction{Type: 200, Scheme: testScheme})
require.Error(t, err)
}
// TestGas_RecordOpsAreSchemeIndependent proves the fixed-size record writes
// ignore the scheme field entirely.
func TestGas_RecordOpsAreSchemeIndependent(t *testing.T) {
for _, op := range []uint8{TxGrantPermit, TxRevokePermit, TxFulfillDecrypt, TxAdvanceEpoch} {
a, err := FeeFor(&Transaction{Type: op, Scheme: "ckks-n15"})
require.NoError(t, err)
b, err := FeeFor(&Transaction{Type: op})
require.NoError(t, err)
require.Equalf(t, a, b, "op %d must be scheme-independent", op)
}
}
// TestGas_EveryOperationIsPriced proves the schedule covers every transaction
// type the package defines — a new operation cannot ship unpriced.
func TestGas_EveryOperationIsPriced(t *testing.T) {
for _, op := range []uint8{
TxRegisterCiphertext, TxGrantPermit, TxRevokePermit,
TxRequestDecrypt, TxFulfillDecrypt, TxAdvanceEpoch,
} {
require.Containsf(t, opBaseGas, op, "transaction type %d has no base gas", op)
require.Containsf(t, opNames, op, "transaction type %d has no public name", op)
}
require.Len(t, opBaseGas, len(opNames), "every priced operation must have exactly one name")
}
+255
View File
@@ -0,0 +1,255 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// F-Chain's defining invariant: it coordinates confidential compute without
// ever holding the confidential part. No ciphertext body, no plaintext, no FHE
// secret key, no decryption share. These tests prove that STRUCTURALLY, two
// ways:
//
// 1. TestNoSecretFieldsInState walks the full type graph of everything F
// persists or holds and fails if any reachable field could carry one of
// those things — a private/secret key type, or a byte-bearing field named
// like a body, a plaintext, or a share.
//
// 2. TestNoSecretProducingCalls scans the package's own source and fails if it
// names any identifier by which F could generate a key, produce a share,
// combine shares, or decrypt. The same scan forbids the FHE runtime's
// Registry, whose writes are stamped with the wall clock and therefore
// cannot back a state root — the reason F owns its persistence.
// secretTokens are substrings that, on a byte-bearing field, would indicate F
// is holding something it must not. "share" is included but only flags
// BYTE-bearing fields, so an integer count named …Shares is not flagged.
var secretTokens = []string{
"private", "secret", "mnemonic", "seed", "share", "privkey",
"plaintext", "cleartext", "body", "blob",
}
// idSizes are the byte-array lengths F legitimately uses for identifiers: a
// selector, an address, and a hash. A byte array of any other length is treated
// as a potential opaque carrier and must not be named like a secret.
var idSizes = map[int]bool{4: true, 20: true, 32: true}
func isByteBearing(ft reflect.Type) bool {
switch ft.Kind() {
case reflect.String:
return true
case reflect.Slice:
e := ft.Elem()
if e.Kind() == reflect.Uint8 { // []byte
return true
}
if e.Kind() == reflect.Slice && e.Elem().Kind() == reflect.Uint8 { // [][]byte
return true
}
return false
case reflect.Array:
return ft.Elem().Kind() == reflect.Uint8 && !idSizes[ft.Len()]
}
return false
}
// walkType reports every violation reachable from typ. It returns them rather
// than failing, so the walker itself can be shown to reject a bad type.
func walkType(typ reflect.Type, path string, seen map[reflect.Type]bool) []string {
if typ == nil || seen[typ] {
return nil
}
seen[typ] = true
var bad []string
switch typ.Kind() {
case reflect.Ptr, reflect.Slice, reflect.Array:
bad = append(bad, walkType(typ.Elem(), path, seen)...)
case reflect.Map:
bad = append(bad, walkType(typ.Key(), path, seen)...)
bad = append(bad, walkType(typ.Elem(), path, seen)...)
case reflect.Struct:
for i := 0; i < typ.NumField(); i++ {
f := typ.Field(i)
fp := path + "." + f.Name
tn := f.Type.String()
if strings.Contains(tn, "PrivateKey") || strings.Contains(tn, "SecretKey") {
bad = append(bad, fmt.Sprintf("%s has secret-bearing type %s", fp, tn))
}
if isByteBearing(f.Type) {
lower := strings.ToLower(f.Name)
for _, tok := range secretTokens {
if strings.Contains(lower, tok) {
bad = append(bad, fmt.Sprintf("byte-bearing field %s (%s) is named like a secret (%q)", fp, tn, tok))
}
}
}
bad = append(bad, walkType(f.Type, fp, seen)...)
}
}
return bad
}
func TestNoSecretFieldsInState(t *testing.T) {
roots := []reflect.Type{
reflect.TypeOf(CiphertextRecord{}),
reflect.TypeOf(PermitRecord{}),
reflect.TypeOf(DecryptRecord{}),
reflect.TypeOf(EpochRecord{}),
reflect.TypeOf(Attestation{}),
reflect.TypeOf(RegisterPayload{}),
reflect.TypeOf(GrantPayload{}),
reflect.TypeOf(RequestPayload{}),
reflect.TypeOf(FulfillPayload{}),
reflect.TypeOf(AdvancePayload{}),
reflect.TypeOf(Transaction{}),
reflect.TypeOf(Block{}),
reflect.TypeOf(Genesis{}),
reflect.TypeOf(VM{}), // the VM itself must hold no secret field
}
seen := make(map[reflect.Type]bool)
var bad []string
for _, r := range roots {
bad = append(bad, walkType(r, r.Name(), seen)...)
}
require.Emptyf(t, bad, "F-Chain state can reach secret material:\n%s", strings.Join(bad, "\n"))
}
// TestWalkerRejectsSecretBearingType is the control for the test above: a walk
// that flags nothing proves nothing unless the walker can be shown to flag
// something. These two shapes are exactly what the invariant forbids.
func TestWalkerRejectsSecretBearingType(t *testing.T) {
type withNamedSecret struct {
Handle [32]byte
CiphertextBody []byte // a ciphertext body is the one thing F must never hold
}
type withShare struct {
Share []byte
}
for _, tc := range []struct {
name string
typ reflect.Type
}{
{"named body", reflect.TypeOf(withNamedSecret{})},
{"named share", reflect.TypeOf(withShare{})},
} {
t.Run(tc.name, func(t *testing.T) {
require.NotEmpty(t, walkType(tc.typ, tc.name, map[reflect.Type]bool{}),
"the walker must reject this shape, or it proves nothing about the real types")
})
}
}
func TestNoSecretProducingCalls(t *testing.T) {
// Forbidden IDENTIFIERS: any function or type by which F could generate a
// key, produce or combine shares, decrypt, or persist through the FHE
// runtime's wall-clock Registry. The check runs over the AST (identifiers
// only), so a doc comment that NAMES one of these to explain the invariant
// is not flagged — only real code is.
exact := map[string]string{
"GenerateKey": "key generation",
"NewSecretKey": "secret key construction",
"Decapsulate": "KEM decapsulation uses the private key",
"Encapsulate": "F performs no cryptographic compute",
"Sign": "signing uses a private key; F only verifies",
"Zeroize": "only needed if secret material were held",
"Reconstruct": "share reconstruction",
"CombineShares": "share combination",
"RecoverSecret": "secret recovery",
// The FHE runtime's secret-bearing entry points. F wraps the runtime's
// vocabulary, never its key machinery.
"SetSecretKey": "would put an FHE secret key in the VM",
"GenerateShare": "would make this node a share producer",
"ContributeShare": "would make this node a share consumer",
"InitiateDecryption": "decryption happens off-chain, on the committee",
"NewThresholdDecryptor": "decryption happens off-chain, on the committee",
"NewFHEDecryptionService": "decryption happens off-chain, on the committee",
"NewThresholdFHEIntegration": "F holds no evaluation session",
// The runtime's Registry stamps time.Now(), so its writes cannot back a
// state root: two validators replaying one block would diverge. F owns
// its persistence for exactly this reason, and must keep owning it.
"NewRegistry": "the runtime Registry is wall-clock stamped and cannot back a state root",
"NewInMemoryCiphertextStorage": "F stores no ciphertext body",
"NewRelayer": "relaying is an off-chain concern",
}
// markers: substrings catching any private/secret key type, plus plaintext.
// "PublicKey" and "VerifySignature" contain none of them, so the public
// path is unaffected.
markers := []string{"PrivateKey", "SecretKey", "Plaintext"}
fset := token.NewFileSet()
entries, err := os.ReadDir(".")
require.NoError(t, err)
scanned := 0
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
f, err := parser.ParseFile(fset, name, nil, parser.SkipObjectResolution)
require.NoError(t, err)
ast.Inspect(f, func(n ast.Node) bool {
id, ok := n.(*ast.Ident)
if !ok {
return true
}
if why, forbidden := exact[id.Name]; forbidden {
t.Errorf("%s references %q at %s — %s", name, id.Name, fset.Position(id.Pos()), why)
}
for _, m := range markers {
if strings.Contains(id.Name, m) {
t.Errorf("%s references %q (marker %q) at %s", name, id.Name, m, fset.Position(id.Pos()))
}
}
return true
})
scanned++
}
require.Greater(t, scanned, 5, "expected to scan the fhevm package sources")
}
// TestVMExposesNoSecretAccessor asserts the VM's public method set offers no
// way to ask it for something it must not have. Decrypt is present and named
// for what it returns — the RECORD of a decryption request — so the check is
// against the things a caller could mistake for the plaintext itself.
func TestVMExposesNoSecretAccessor(t *testing.T) {
bad := []string{"PrivateKey", "SecretKey", "Plaintext", "Share", "Seed", "Body", "Reconstruct"}
vt := reflect.TypeOf(&VM{})
for i := 0; i < vt.NumMethod(); i++ {
m := vt.Method(i).Name
for _, b := range bad {
require.Falsef(t, strings.Contains(m, b),
"VM exposes method %q which suggests access to material it does not hold", m)
}
}
}
// TestServiceExposesNoDecryptEndpoint asserts the RPC surface has no method
// that performs a decryption or hands back a plaintext. Decryption is a
// consensus transaction answered by the committee, never a synchronous call.
func TestServiceExposesNoDecryptEndpoint(t *testing.T) {
st := reflect.TypeOf(&Service{})
for i := 0; i < st.NumMethod(); i++ {
m := st.Method(i).Name
for _, b := range []string{"Plaintext", "Reencrypt", "Evaluate", "PrivateKey", "SecretKey"} {
require.Falsef(t, strings.Contains(m, b), "Service exposes %q", m)
}
}
// The one Decrypt-named method is a read of the request record.
_, ok := st.MethodByName("GetDecrypt")
require.True(t, ok)
}
+460
View File
@@ -0,0 +1,460 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"context"
"encoding/hex"
"net/http"
"strings"
"github.com/luxfi/chains/fee"
"github.com/luxfi/chains/mpcvm/fhe"
)
// Service is the F-Chain JSON-RPC surface. Mutating operations are submitted as
// CLIENT-SIGNED transactions and take effect only through fee-settled consensus
// blocks. Everything else is a read-only query of PUBLIC state. There is no
// endpoint that decrypts, none that returns a ciphertext body, and no "fee"
// integer in any request — a fee is never an unbacked number a caller writes
// down, it is gas metered and burned from the payer's on-chain balance inside
// consensus.
//
// Two of the methods report the FHE RUNTIME's configuration rather than F's
// ledger — the public parameters and the seated committee — and those answer in
// the runtime's own reply types (github.com/luxfi/chains/mpcvm/fhe), so a client
// that understands the runtime understands F. The rest describe F's ledger and
// answer in F's own views. That is the whole rule: runtime types for runtime
// facts, F's views for F's records.
type Service struct {
vm *VM
}
// ---- Mutating: submit a signed transaction ----
// SubmitTransactionArgs carries a hex-encoded, client-signed transaction
// (Transaction.Bytes()). The client builds and signs it offline with its own
// ML-DSA-65 key; F only verifies the signature and settles the fee.
type SubmitTransactionArgs struct {
Tx string `json:"tx"`
}
// SubmitTransactionReply returns the accepted transaction's ID. The fee is
// settled when the transaction's block is accepted, not here.
type SubmitTransactionReply struct {
TxID string `json:"txId"`
}
// SubmitTransaction parses, authenticates, admission-checks, and enqueues a
// signed transaction.
func (s *Service) SubmitTransaction(r *http.Request, args *SubmitTransactionArgs, reply *SubmitTransactionReply) error {
raw, err := hex.DecodeString(strings.TrimPrefix(args.Tx, "0x"))
if err != nil {
return err
}
tx, err := ParseTransaction(raw)
if err != nil {
return err
}
id, err := s.vm.SubmitTx(tx)
if err != nil {
return err
}
reply.TxID = id.String()
return nil
}
// ---- FHE runtime facts ----
// GetPublicParams returns the FHE parameters this network encrypts under,
// together with the seated epoch's threshold and network public key. The
// parameters come from the runtime's own threshold configuration, so a client
// encrypting for F and a node evaluating for F agree by construction.
func (s *Service) GetPublicParams(r *http.Request, args *fhe.GetPublicParamsArgs, reply *fhe.GetPublicParamsReply) error {
cfg := fhe.DefaultThresholdConfig()
epoch := s.vm.CurrentEpoch()
reply.Epoch = epoch
reply.LogN = cfg.CKKSParams.LogN()
reply.LogQP = int(cfg.CKKSParams.LogQ() + cfg.CKKSParams.LogP())
reply.LogScale = cfg.CKKSParams.LogDefaultScale()
reply.ChainID = s.vm.chainID.String()
if rec, ok := s.vm.Epoch(epoch); ok {
reply.Threshold = rec.Threshold
reply.PublicKey = hex.EncodeToString(rec.PublicKey)
}
return nil
}
// GetCommittee returns the threshold committee for an epoch (the seated one by
// default). These are the members whose attestations answer a decryption
// request and install the next epoch.
func (s *Service) GetCommittee(r *http.Request, args *fhe.GetCommitteeArgs, reply *fhe.GetCommitteeReply) error {
n := s.vm.CurrentEpoch()
if args.Epoch != nil {
n = *args.Epoch
}
rec, ok := s.vm.Epoch(n)
if !ok {
return ErrEpochNotFound
}
reply.Epoch = rec.Epoch
reply.Threshold = rec.Threshold
reply.Members = make([]fhe.CommitteeMemberInfo, len(rec.Committee))
for i, m := range rec.Committee {
reply.Members[i] = fhe.CommitteeMemberInfo{
NodeID: m.NodeID.String(),
PublicKey: hex.EncodeToString(m.PublicKey),
Weight: m.Weight,
Index: m.Index,
}
}
return nil
}
// ---- F's ledger ----
// CiphertextView is the PUBLIC view of a registered encrypted value. It carries
// the digest of the ciphertext body so a client can check a body it fetched
// from off-chain storage; it does not carry the body, because F never has it.
type CiphertextView struct {
Handle string `json:"handle"`
Owner string `json:"owner"`
Scheme string `json:"scheme"`
Digest string `json:"digest"`
Type uint8 `json:"type"`
Level int `json:"level"`
Epoch uint64 `json:"epoch"`
Size uint32 `json:"size"`
RegisteredAt int64 `json:"registeredAt"`
ChainID string `json:"chainId"`
}
func toCiphertextView(r *CiphertextRecord) CiphertextView {
return CiphertextView{
Handle: hex.EncodeToString(r.Handle[:]),
Owner: fee.Account(r.Owner).String(),
Scheme: r.Scheme,
Digest: hex.EncodeToString(r.Digest[:]),
Type: r.Type,
Level: r.Level,
Epoch: r.Epoch,
Size: r.Size,
RegisteredAt: r.RegisteredAt,
ChainID: r.ChainID.String(),
}
}
// GetCiphertextArgs selects a ciphertext by handle.
type GetCiphertextArgs struct {
Handle string `json:"handle"` // hex-encoded 32 bytes
}
// GetCiphertextReply returns the public ciphertext view.
type GetCiphertextReply struct {
Ciphertext CiphertextView `json:"ciphertext"`
}
// GetCiphertext returns a ciphertext record by handle.
func (s *Service) GetCiphertext(r *http.Request, args *GetCiphertextArgs, reply *GetCiphertextReply) error {
h, err := hash32(args.Handle)
if err != nil {
return err
}
rec, ok := s.vm.Ciphertext(h)
if !ok {
return ErrCiphertextNotFound
}
reply.Ciphertext = toCiphertextView(rec)
return nil
}
// ListCiphertextsArgs filters the ciphertext listing.
type ListCiphertextsArgs struct {
Owner string `json:"owner"`
Scheme string `json:"scheme"`
}
// ListCiphertextsReply returns matching ciphertext views.
type ListCiphertextsReply struct {
Ciphertexts []CiphertextView `json:"ciphertexts"`
Total int `json:"total"`
}
// ListCiphertexts lists registered ciphertexts, optionally filtered.
func (s *Service) ListCiphertexts(r *http.Request, args *ListCiphertextsArgs, reply *ListCiphertextsReply) error {
var owner fee.Account
if args.Owner != "" {
a, err := accountFromHex(args.Owner)
if err != nil {
return err
}
owner = a
}
for _, rec := range s.vm.Ciphertexts() {
if args.Owner != "" && rec.Owner != owner {
continue
}
if args.Scheme != "" && rec.Scheme != args.Scheme {
continue
}
reply.Ciphertexts = append(reply.Ciphertexts, toCiphertextView(rec))
}
reply.Total = len(reply.Ciphertexts)
return nil
}
// PermitView is the PUBLIC view of a capability grant.
type PermitView struct {
PermitID string `json:"permitId"`
Handle string `json:"handle"`
Grantor string `json:"grantor"`
Grantee string `json:"grantee"`
Operations uint32 `json:"operations"`
Expiry int64 `json:"expiry"`
Status string `json:"status"`
CreatedAt int64 `json:"createdAt"`
ChainID string `json:"chainId"`
}
// GetPermitArgs selects a permit by ID.
type GetPermitArgs struct {
PermitID string `json:"permitId"` // hex-encoded 32 bytes
}
// GetPermitReply returns the public permit view.
type GetPermitReply struct {
Permit PermitView `json:"permit"`
}
// GetPermit returns a permit record by ID.
func (s *Service) GetPermit(r *http.Request, args *GetPermitArgs, reply *GetPermitReply) error {
id, err := hash32(args.PermitID)
if err != nil {
return err
}
rec, ok := s.vm.Permit(id)
if !ok {
return ErrPermitNotFound
}
reply.Permit = PermitView{
PermitID: hex.EncodeToString(rec.PermitID[:]),
Handle: hex.EncodeToString(rec.Handle[:]),
Grantor: fee.Account(rec.Grantor).String(),
Grantee: fee.Account(rec.Grantee).String(),
Operations: rec.Operations,
Expiry: rec.Expiry,
Status: rec.Status,
CreatedAt: rec.CreatedAt,
ChainID: rec.ChainID.String(),
}
return nil
}
// AttestationView names one committee member and the value it attested.
type AttestationView struct {
Member string `json:"member"`
Value string `json:"value"`
}
// DecryptView is the PUBLIC view of a threshold-decryption request. It has no
// plaintext field: the plaintext goes to the requester's callback off-chain,
// and F records only the handle the committee agreed the decryption produced.
type DecryptView struct {
RequestID string `json:"requestId"`
Handle string `json:"handle"`
Requester string `json:"requester"`
PermitID string `json:"permitId"`
Callback string `json:"callback"`
Selector string `json:"selector"`
Epoch uint64 `json:"epoch"`
Status string `json:"status"`
ResultHandle string `json:"resultHandle,omitempty"`
Attestations []AttestationView `json:"attestations"`
Threshold int `json:"threshold"`
Expiry int64 `json:"expiry"`
CreatedAt int64 `json:"createdAt"`
CompletedAt int64 `json:"completedAt,omitempty"`
SourceChain string `json:"sourceChain"`
}
// GetDecryptArgs selects a decryption request by ID.
type GetDecryptArgs struct {
RequestID string `json:"requestId"` // hex-encoded 32 bytes
}
// GetDecryptReply returns the public request view.
type GetDecryptReply struct {
Request DecryptView `json:"request"`
}
// GetDecrypt returns a decryption request and the committee's answer so far.
func (s *Service) GetDecrypt(r *http.Request, args *GetDecryptArgs, reply *GetDecryptReply) error {
id, err := hash32(args.RequestID)
if err != nil {
return err
}
rec, ok := s.vm.Decrypt(id)
if !ok {
return ErrRequestNotFound
}
// A pending request past its expiry can no longer be answered — checkAuth
// refuses every attestation to it — so it is reported expired. The stored
// status stays Pending because nothing ever answered it; expiry is a fact
// about the clock, and this is the one place that reads the clock to say so.
status := rec.Status
if status == fhe.RequestPending && rec.Expiry != 0 && s.vm.clock.Time().Unix() > rec.Expiry {
status = fhe.RequestExpired
}
view := DecryptView{
RequestID: hex.EncodeToString(rec.RequestID[:]),
Handle: hex.EncodeToString(rec.CiphertextHandle[:]),
Requester: fee.Account(rec.Requester).String(),
PermitID: hex.EncodeToString(rec.PermitID[:]),
Callback: hex.EncodeToString(rec.Callback[:]),
Selector: hex.EncodeToString(rec.CallbackSelector[:]),
Epoch: rec.Epoch,
Status: status.String(),
Expiry: rec.Expiry,
CreatedAt: rec.CreatedAt,
CompletedAt: rec.CompletedAt,
SourceChain: rec.SourceChain.String(),
}
if rec.Status == fhe.RequestCompleted {
view.ResultHandle = hex.EncodeToString(rec.ResultHandle[:])
}
if ep, ok := s.vm.Epoch(rec.Epoch); ok {
view.Threshold = ep.Threshold
}
view.Attestations = make([]AttestationView, len(rec.Attestations))
for i, a := range rec.Attestations {
view.Attestations[i] = AttestationView{
Member: a.Member.String(),
Value: hex.EncodeToString(a.Value[:]),
}
}
reply.Request = view
return nil
}
// ---- Accounts ----
// BalanceArgs selects an account by hex address.
type BalanceArgs struct {
Address string `json:"address"`
}
// BalanceReply returns the account balance and total burned supply, both nLUX.
type BalanceReply struct {
BalanceNLUX uint64 `json:"balanceNLux"`
BurnedNLUX uint64 `json:"burnedNLux"`
}
// Balance returns an account's spendable balance and the chain's burned supply.
func (s *Service) Balance(r *http.Request, args *BalanceArgs, reply *BalanceReply) error {
acct, err := accountFromHex(args.Address)
if err != nil {
return err
}
bal, err := s.vm.Balance(acct)
if err != nil {
return err
}
burned, err := s.vm.Burned()
if err != nil {
return err
}
reply.BalanceNLUX = bal
reply.BurnedNLUX = burned
return nil
}
// ---- Diagnostics ----
// HealthArgs is empty.
type HealthArgs struct{}
// HealthReply reports VM health.
type HealthReply struct {
Healthy bool `json:"healthy"`
Details map[string]string `json:"details"`
}
// Health reports VM health.
func (s *Service) Health(r *http.Request, args *HealthArgs, reply *HealthReply) error {
res, err := s.vm.HealthCheck(context.Background())
if err != nil {
return err
}
reply.Healthy = res.Healthy
reply.Details = res.Details
return nil
}
// FeeScheduleArgs is empty.
type FeeScheduleArgs struct{}
// FeeScheduleEntry prices one (operation, scheme) pair.
type FeeScheduleEntry struct {
Operation string `json:"operation"`
Scheme string `json:"scheme"`
Gas uint64 `json:"gas"`
FeeNLUX uint64 `json:"feeNLux"`
}
// FeeScheduleReply returns the per-scheme gas/fee schedule and the price.
type FeeScheduleReply struct {
GasPrice uint64 `json:"gasPriceNLuxPerGas"`
Entries []FeeScheduleEntry `json:"entries"`
}
// opNames maps each transaction type to the name clients use for it. It is the
// one place an operation's public name is written down.
var opNames = map[uint8]string{
TxRegisterCiphertext: "registerCiphertext",
TxGrantPermit: "grantPermit",
TxRevokePermit: "revokePermit",
TxRequestDecrypt: "requestDecrypt",
TxFulfillDecrypt: "fulfillDecrypt",
TxAdvanceEpoch: "advanceEpoch",
}
// FeeSchedule returns the chain's per-operation, per-scheme fee schedule so
// clients can compute the exact burn before submitting a transaction.
func (s *Service) FeeSchedule(r *http.Request, args *FeeScheduleArgs, reply *FeeScheduleReply) error {
reply.GasPrice = uint64(GasPrice)
for op, name := range opNames {
if usesScheme(op) {
for scheme := range schemeGas {
g, _ := GasFor(&Transaction{Type: op, Scheme: scheme})
f, _ := fee.Cost(g, GasPrice)
reply.Entries = append(reply.Entries, FeeScheduleEntry{
Operation: name, Scheme: scheme, Gas: uint64(g), FeeNLUX: f,
})
}
continue
}
g, _ := GasFor(&Transaction{Type: op})
f, _ := fee.Cost(g, GasPrice)
reply.Entries = append(reply.Entries, FeeScheduleEntry{
Operation: name, Scheme: "", Gas: uint64(g), FeeNLUX: f,
})
}
return nil
}
// hash32 decodes a hex-encoded 32-byte identifier — a handle, a permit ID, a
// request ID. One decoder for all three, so none of them can drift into
// accepting a length the others reject.
func hash32(s string) ([32]byte, error) {
var out [32]byte
b, err := hex.DecodeString(strings.TrimPrefix(s, "0x"))
if err != nil {
return out, err
}
if len(b) != 32 {
return out, ErrInvalidPayload
}
copy(out[:], b)
return out, nil
}
+301
View File
@@ -0,0 +1,301 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"context"
"encoding/hex"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/chains/mpcvm/fhe"
)
// TestServicePublicParams proves the parameters F reports come from the FHE
// runtime's own threshold configuration — so a client encrypting for F and a
// node evaluating for F agree by construction — while the threshold and network
// key come from the seated epoch on chain.
func TestServicePublicParams(t *testing.T) {
committee, _ := newCommittee(t, 3)
vm := newTestVM(t, nil, committee, 2)
svc := &Service{vm: vm}
var reply fhe.GetPublicParamsReply
require.NoError(t, svc.GetPublicParams(nil, &fhe.GetPublicParamsArgs{}, &reply))
cfg := fhe.DefaultThresholdConfig()
require.Equal(t, cfg.CKKSParams.LogN(), reply.LogN)
require.Equal(t, int(cfg.CKKSParams.LogQ()+cfg.CKKSParams.LogP()), reply.LogQP)
require.Equal(t, cfg.CKKSParams.LogDefaultScale(), reply.LogScale)
require.Equal(t, uint64(0), reply.Epoch)
require.Equal(t, 2, reply.Threshold)
require.Equal(t, hex.EncodeToString([]byte("network-fhe-public-key")), reply.PublicKey)
require.Equal(t, vm.chainID.String(), reply.ChainID)
}
// TestServiceCommittee proves the committee is readable per epoch, and that an
// epoch nobody ever seated is an error rather than an empty answer.
func TestServiceCommittee(t *testing.T) {
committee, _ := newCommittee(t, 3)
vm := newTestVM(t, nil, committee, 2)
svc := &Service{vm: vm}
var reply fhe.GetCommitteeReply
require.NoError(t, svc.GetCommittee(nil, &fhe.GetCommitteeArgs{}, &reply))
require.Equal(t, uint64(0), reply.Epoch)
require.Equal(t, 2, reply.Threshold)
require.Len(t, reply.Members, 3)
for i, m := range reply.Members {
require.Equal(t, committee[i].NodeID.String(), m.NodeID)
require.Equal(t, hex.EncodeToString(committee[i].PublicKey), m.PublicKey)
require.Equal(t, i, m.Index)
}
future := uint64(9)
require.ErrorIs(t,
svc.GetCommittee(nil, &fhe.GetCommitteeArgs{Epoch: &future}, &fhe.GetCommitteeReply{}),
ErrEpochNotFound)
}
// TestServiceCiphertextViews proves a registered value reads back through the
// RPC surface with its digest — so a client can check a body it fetched from
// off-chain storage — and that the listing filters work.
func TestServiceCiphertextViews(t *testing.T) {
a, b := newTestKey(t), newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, fundAll(a, b), committee, 1)
svc := &Service{vm: vm}
digest := digestOf("value-one")
acceptOne(t, vm, registerTx(t, a, testScheme, digest, 1))
acceptOne(t, vm, registerTx(t, b, "bfv-n13", digestOf("value-two"), 1))
handle := deriveHandle(digest, testScheme)
var got GetCiphertextReply
require.NoError(t, svc.GetCiphertext(nil, &GetCiphertextArgs{Handle: hexOf(handle)}, &got))
require.Equal(t, hexOf(handle), got.Ciphertext.Handle)
require.Equal(t, hexOf(digest), got.Ciphertext.Digest)
require.Equal(t, a.addr.String(), got.Ciphertext.Owner)
require.Equal(t, testScheme, got.Ciphertext.Scheme)
require.Equal(t, uint32(4096), got.Ciphertext.Size)
require.Equal(t, vm.chainID.String(), got.Ciphertext.ChainID)
var all ListCiphertextsReply
require.NoError(t, svc.ListCiphertexts(nil, &ListCiphertextsArgs{}, &all))
require.Equal(t, 2, all.Total)
var byScheme ListCiphertextsReply
require.NoError(t, svc.ListCiphertexts(nil, &ListCiphertextsArgs{Scheme: "bfv-n13"}, &byScheme))
require.Equal(t, 1, byScheme.Total)
require.Equal(t, "bfv-n13", byScheme.Ciphertexts[0].Scheme)
var byOwner ListCiphertextsReply
require.NoError(t, svc.ListCiphertexts(nil, &ListCiphertextsArgs{Owner: a.hexAddr()}, &byOwner))
require.Equal(t, 1, byOwner.Total)
require.Equal(t, a.addr.String(), byOwner.Ciphertexts[0].Owner)
require.ErrorIs(t,
svc.GetCiphertext(nil, &GetCiphertextArgs{Handle: hexOf(digestOf("absent"))}, &GetCiphertextReply{}),
ErrCiphertextNotFound)
}
// TestServicePermitView proves a grant reads back with the capability bits and
// expiry it was created with, and that revocation is visible.
func TestServicePermitView(t *testing.T) {
owner, grantee := newTestKey(t), newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, fundAll(owner, grantee), committee, 1)
svc := &Service{vm: vm}
ops := fhe.PermitOpDecrypt | fhe.PermitOpCompute
handle, permitID := seedPermit(t, vm, owner, grantee, ops, 0)
var got GetPermitReply
require.NoError(t, svc.GetPermit(nil, &GetPermitArgs{PermitID: hexOf(permitID)}, &got))
require.Equal(t, hexOf(permitID), got.Permit.PermitID)
require.Equal(t, hexOf(handle), got.Permit.Handle)
require.Equal(t, owner.addr.String(), got.Permit.Grantor)
require.Equal(t, grantee.addr.String(), got.Permit.Grantee)
require.Equal(t, ops, got.Permit.Operations)
require.Equal(t, StatusActive, got.Permit.Status)
acceptOne(t, vm, revokeTx(t, owner, permitID, 3))
require.NoError(t, svc.GetPermit(nil, &GetPermitArgs{PermitID: hexOf(permitID)}, &got))
require.Equal(t, StatusRevoked, got.Permit.Status)
require.ErrorIs(t,
svc.GetPermit(nil, &GetPermitArgs{PermitID: hexOf(digestOf("absent"))}, &GetPermitReply{}),
ErrPermitNotFound)
}
// TestServiceDecryptView proves the request surface shows how far the committee
// has got — who attested what, against what threshold — and the final result
// handle once it completes.
func TestServiceDecryptView(t *testing.T) {
owner, grantee := newTestKey(t), newTestKey(t)
vm, _, members := newDecryptVM(t, 3, 2, owner, grantee)
svc := &Service{vm: vm}
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, 0)
acceptOne(t, vm, requestTx(t, grantee, testScheme, handle, permitID, 0, 1))
requestID := deriveRequestID(handle, grantee.addr, 1)
var got GetDecryptReply
require.NoError(t, svc.GetDecrypt(nil, &GetDecryptArgs{RequestID: hexOf(requestID)}, &got))
require.Equal(t, hexOf(requestID), got.Request.RequestID)
require.Equal(t, hexOf(handle), got.Request.Handle)
require.Equal(t, hexOf(permitID), got.Request.PermitID)
require.Equal(t, grantee.addr.String(), got.Request.Requester)
require.Equal(t, fhe.RequestPending.String(), got.Request.Status)
require.Equal(t, 2, got.Request.Threshold)
require.Empty(t, got.Request.Attestations)
require.Empty(t, got.Request.ResultHandle, "a pending request has no result")
require.Equal(t, "ca11000000000000000000000000000000000000", got.Request.Callback)
require.Equal(t, "01020304", got.Request.Selector)
result := digestOf("answer")
acceptOne(t, vm, fulfillTx(t, members[0], requestID, result, 1))
require.NoError(t, svc.GetDecrypt(nil, &GetDecryptArgs{RequestID: hexOf(requestID)}, &got))
require.Len(t, got.Request.Attestations, 1)
require.Equal(t, members[0].addr.String(), got.Request.Attestations[0].Member)
require.Equal(t, hexOf(result), got.Request.Attestations[0].Value)
require.Equal(t, fhe.RequestPending.String(), got.Request.Status)
acceptOne(t, vm, fulfillTx(t, members[1], requestID, result, 1))
require.NoError(t, svc.GetDecrypt(nil, &GetDecryptArgs{RequestID: hexOf(requestID)}, &got))
require.Equal(t, fhe.RequestCompleted.String(), got.Request.Status)
require.Equal(t, hexOf(result), got.Request.ResultHandle)
require.NotZero(t, got.Request.CompletedAt)
require.ErrorIs(t,
svc.GetDecrypt(nil, &GetDecryptArgs{RequestID: hexOf(digestOf("absent"))}, &GetDecryptReply{}),
ErrRequestNotFound)
}
// TestServiceSubmitTransaction proves the one mutating endpoint takes a
// client-signed transaction as hex, parses it canonically, and enqueues it —
// and that a transaction the VM would refuse is refused here too.
func TestServiceSubmitTransaction(t *testing.T) {
k := newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, fundAll(k), committee, 1)
svc := &Service{vm: vm}
tx := registerTx(t, k, testScheme, digestOf("submitted"), 1)
var reply SubmitTransactionReply
require.NoError(t, svc.SubmitTransaction(nil,
&SubmitTransactionArgs{Tx: hex.EncodeToString(tx.Bytes())}, &reply))
require.Equal(t, tx.ID().String(), reply.TxID)
// The 0x prefix is accepted too.
next := registerTx(t, k, testScheme, digestOf("prefixed"), 2)
require.NoError(t, svc.SubmitTransaction(nil,
&SubmitTransactionArgs{Tx: "0x" + hex.EncodeToString(next.Bytes())}, &reply))
acceptQueued(t, vm)
_, ok := vm.Ciphertext(tx.Subject)
require.True(t, ok)
// Not hex at all.
require.Error(t, svc.SubmitTransaction(nil, &SubmitTransactionArgs{Tx: "zzzz"}, &reply))
// Hex, but not a transaction.
require.Error(t, svc.SubmitTransaction(nil, &SubmitTransactionArgs{Tx: "deadbeef"}, &reply))
// A well-formed transaction the VM refuses (replayed nonce).
require.ErrorIs(t, svc.SubmitTransaction(nil,
&SubmitTransactionArgs{Tx: hex.EncodeToString(tx.Bytes())}, &reply), ErrBadNonce)
}
// TestServiceBalanceAndHealth proves the account and diagnostic surfaces report
// what consensus actually did.
func TestServiceBalanceAndHealth(t *testing.T) {
k := newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, fundAll(k), committee, 1)
svc := &Service{vm: vm}
tx := registerTx(t, k, testScheme, digestOf("charged"), 1)
spent, err := FeeFor(tx)
require.NoError(t, err)
acceptOne(t, vm, tx)
var bal BalanceReply
require.NoError(t, svc.Balance(nil, &BalanceArgs{Address: k.hexAddr()}, &bal))
require.Equal(t, testFund-spent, bal.BalanceNLUX)
require.Equal(t, spent, bal.BurnedNLUX)
require.Error(t, svc.Balance(nil, &BalanceArgs{Address: "nothex"}, &bal))
require.Error(t, svc.Balance(nil, &BalanceArgs{Address: "0011"}, &bal))
var h HealthReply
require.NoError(t, svc.Health(nil, &HealthArgs{}, &h))
require.True(t, h.Healthy)
require.Equal(t, "1", h.Details["ciphertexts"])
require.Equal(t, "1", h.Details["height"])
}
// TestServiceFeeSchedule proves clients can compute the exact burn before
// submitting: every operation is listed, scheme-priced ones once per scheme,
// and each entry's fee is gas times the price.
func TestServiceFeeSchedule(t *testing.T) {
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, nil, committee, 1)
svc := &Service{vm: vm}
var reply FeeScheduleReply
require.NoError(t, svc.FeeSchedule(nil, &FeeScheduleArgs{}, &reply))
require.Equal(t, uint64(GasPrice), reply.GasPrice)
seen := map[string]int{}
for _, e := range reply.Entries {
seen[e.Operation]++
require.Equal(t, e.Gas*uint64(GasPrice), e.FeeNLUX, "%s/%s", e.Operation, e.Scheme)
require.GreaterOrEqual(t, e.FeeNLUX, MinScheduledFee())
}
for op, name := range opNames {
want := 1
if usesScheme(op) {
want = len(schemeGas)
}
require.Equalf(t, want, seen[name], "operation %s listed %d times", name, seen[name])
}
}
// TestServiceRejectsMalformedIdentifiers proves the 32-byte identifier decoder
// fails closed on anything that is not exactly 32 bytes of hex, rather than
// padding or truncating into a valid-looking lookup.
func TestServiceRejectsMalformedIdentifiers(t *testing.T) {
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, nil, committee, 1)
svc := &Service{vm: vm}
for _, bad := range []string{"", "zz", "00", hex.EncodeToString(make([]byte, 31)), hex.EncodeToString(make([]byte, 33))} {
require.Errorf(t, svc.GetCiphertext(nil, &GetCiphertextArgs{Handle: bad}, &GetCiphertextReply{}), "handle %q", bad)
require.Errorf(t, svc.GetPermit(nil, &GetPermitArgs{PermitID: bad}, &GetPermitReply{}), "permit %q", bad)
require.Errorf(t, svc.GetDecrypt(nil, &GetDecryptArgs{RequestID: bad}, &GetDecryptReply{}), "request %q", bad)
}
}
// TestHTTPHandlerServesRPC proves the VM hands consensus a handler that
// actually routes to the registered service.
func TestHTTPHandlerServesRPC(t *testing.T) {
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, nil, committee, 1)
h, err := vm.NewHTTPHandler(context.Background())
require.NoError(t, err)
require.NotNil(t, h)
handlers, err := vm.CreateHandlers(context.Background())
require.NoError(t, err)
require.Contains(t, handlers, "/rpc")
static, err := vm.CreateStaticHandlers(context.Background())
require.NoError(t, err)
require.Nil(t, static, "F serves nothing that does not depend on chain state")
var _ http.Handler = h
}
+340
View File
@@ -0,0 +1,340 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/chains/fee"
"github.com/luxfi/chains/mpcvm/fhe"
)
// TestFeeSettledThroughConsensus is the headline proof that an F-Chain
// operation is paid for by BURNING real on-chain balance inside a consensus
// block — not by an unbacked integer a caller writes into a JSON request.
func TestFeeSettledThroughConsensus(t *testing.T) {
k := newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, map[string]uint64{k.hexAddr(): testFund}, committee, 1)
bal, err := vm.Balance(k.addr)
require.NoError(t, err)
require.Equal(t, testFund, bal, "genesis must fund the payer")
burned0, err := vm.Burned()
require.NoError(t, err)
tx := registerTx(t, k, testScheme, digestOf("treasury"), 1)
// The fee is computed from the per-scheme gas schedule, NOT supplied by the
// caller: register + ckks-n14 = (21000 + 60000) gas * 1000 nLUX/gas.
expectedFee, err := FeeFor(tx)
require.NoError(t, err)
require.Equal(t, uint64(81_000_000), expectedFee)
txID, err := vm.SubmitTx(tx)
require.NoError(t, err)
require.Equal(t, tx.ID(), txID)
blkIntf, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
blk := blkIntf.(*Block)
require.NoError(t, blk.Verify(context.Background()))
// Verify must NOT move funds (read-only affordability check).
balPre, err := vm.Balance(k.addr)
require.NoError(t, err)
require.Equal(t, testFund, balPre, "Verify must not debit")
require.NoError(t, blk.Accept(context.Background()))
// Accept settles: balance debited by EXACTLY the metered fee...
balPost, err := vm.Balance(k.addr)
require.NoError(t, err)
require.Equal(t, testFund-expectedFee, balPost, "payer must be debited the metered fee")
// ...and the same amount is BURNED (circulating supply reduced).
burned1, err := vm.Burned()
require.NoError(t, err)
require.Equal(t, burned0+expectedFee, burned1, "fee must be burned, not credited anywhere")
// The operation took effect THROUGH consensus (not a synchronous RPC).
rec, ok := vm.Ciphertext(tx.Subject)
require.True(t, ok, "the register effect must be applied in Accept")
require.Equal(t, k.addr, fee.Account(rec.Owner))
require.Equal(t, testScheme, rec.Scheme)
require.Equal(t, digestOf("treasury"), rec.Digest)
require.Equal(t, blk.timestamp.Unix(), rec.RegisteredAt,
"a record's timestamp comes from the accepting block, never a validator's clock")
// Block is the new tip; mempool drained.
la, err := vm.LastAccepted(context.Background())
require.NoError(t, err)
require.Equal(t, blk.ID(), la)
require.Empty(t, vm.mempool)
}
// TestUnfundedPayerCannotSettle proves the fee is balance-backed: a payer
// without funds cannot get an operation accepted. Admission rejects it, and
// even if forced into a block, Verify fails closed.
func TestUnfundedPayerCannotSettle(t *testing.T) {
k := newTestKey(t)
committee, _ := newCommittee(t, 1)
// Fund with far less than one operation's fee.
vm := newTestVM(t, map[string]uint64{k.hexAddr(): 1_000}, committee, 1)
tx := registerTx(t, k, testScheme, digestOf("x"), 1)
_, err := vm.SubmitTx(tx)
require.ErrorIs(t, err, fee.ErrInsufficientFunds, "admission must reject an unaffordable tx")
// Force it into a block anyway: consensus Verify must still refuse it.
blk := forceBlock(vm, tx)
require.ErrorIs(t, blk.Verify(context.Background()), fee.ErrInsufficientFunds)
// State untouched: no ciphertext, nothing burned.
_, ok := vm.Ciphertext(tx.Subject)
require.False(t, ok)
burned, _ := vm.Burned()
require.Zero(t, burned)
}
// TestCumulativeFeesWithinBlock proves affordability is checked against the
// payer's RUNNING debit inside the block, not its opening balance: two
// operations that each fit alone but do not fit together are refused.
func TestCumulativeFeesWithinBlock(t *testing.T) {
k := newTestKey(t)
committee, _ := newCommittee(t, 1)
one, err := FeeFor(&Transaction{Type: TxRegisterCiphertext, Scheme: testScheme})
require.NoError(t, err)
// Enough for one operation and one nLUX, never for two.
vm := newTestVM(t, map[string]uint64{k.hexAddr(): one + 1}, committee, 1)
a := registerTx(t, k, testScheme, digestOf("a"), 1)
b := registerTx(t, k, testScheme, digestOf("b"), 2)
_, err = vm.SubmitTx(a)
require.NoError(t, err)
_, err = vm.SubmitTx(b)
require.NoError(t, err, "each is affordable on its own at admission")
blk, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
require.ErrorIs(t, blk.(*Block).Verify(context.Background()), fee.ErrInsufficientFunds)
}
// TestGasLimitEnforced proves the payer's declared ceiling is real: an
// operation costing more gas than the payer allowed is refused rather than
// silently charged.
func TestGasLimitEnforced(t *testing.T) {
k := newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, map[string]uint64{k.hexAddr(): testFund}, committee, 1)
tx := registerTx(t, k, testScheme, digestOf("tight"), 1)
tx.GasLimit = 1 // far below the scheduled 81,000
k.sign(t, tx)
_, err := vm.SubmitTx(tx)
require.NoError(t, err, "admission prices the operation but does not meter it")
blk, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
require.ErrorIs(t, blk.(*Block).Verify(context.Background()), fee.ErrOutOfGas)
burned, _ := vm.Burned()
require.Zero(t, burned, "a refused block burns nothing")
}
// TestTamperedOrUnsignedTxRejected proves authorization integrity at the VM
// boundary: F authenticates the payer by PUBLIC-key signature, so a tampered,
// unsigned, or impersonating transaction cannot spend or act.
func TestTamperedOrUnsignedTxRejected(t *testing.T) {
k := newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, map[string]uint64{k.hexAddr(): testFund}, committee, 1)
tampered := registerTx(t, k, testScheme, digestOf("x"), 1)
tampered.Nonce = 2
_, err := vm.SubmitTx(tampered)
require.ErrorIs(t, err, ErrBadSignature)
unsigned := registerTx(t, k, testScheme, digestOf("y"), 1)
unsigned.Auth, unsigned.Sig = nil, nil
_, err = vm.SubmitTx(unsigned)
require.ErrorIs(t, err, ErrUnsignedTx)
other := newTestKey(t)
impersonating := registerTx(t, k, testScheme, digestOf("z"), 1)
other.sign(t, impersonating)
_, err = vm.SubmitTx(impersonating)
require.ErrorIs(t, err, ErrPayerMismatch)
burned, _ := vm.Burned()
require.Zero(t, burned)
}
// TestReplayRejected proves a captured signed transaction cannot be resubmitted
// to drain the payer through repeated fee burns: nonce enforcement rejects it
// at admission and again in consensus Verify, and no second burn occurs.
func TestReplayRejected(t *testing.T) {
k := newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, map[string]uint64{k.hexAddr(): testFund}, committee, 1)
tx := registerTx(t, k, testScheme, digestOf("replay"), 1)
acceptOne(t, vm, tx)
balAfter, _ := vm.Balance(k.addr)
// Resubmit the identical signed tx: rejected at admission (nonce consumed).
_, err := vm.SubmitTx(tx)
require.ErrorIs(t, err, ErrBadNonce)
// Forced into a block, consensus Verify rejects it too — no second burn.
require.ErrorIs(t, forceBlock(vm, tx).Verify(context.Background()), ErrBadNonce)
bal2, _ := vm.Balance(k.addr)
require.Equal(t, balAfter, bal2, "replay must not burn the payer again")
}
// TestNonceMustBeSequential proves a payer's transactions are ordered: a gap is
// refused, so a signed transaction cannot be held back and injected later out
// of order.
func TestNonceMustBeSequential(t *testing.T) {
k := newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, map[string]uint64{k.hexAddr(): testFund}, committee, 1)
// Nonce 2 without 1: admission accepts it (it is greater than the last
// used), but consensus requires exactly the next one.
skipped := registerTx(t, k, testScheme, digestOf("skip"), 2)
_, err := vm.SubmitTx(skipped)
require.NoError(t, err)
blk, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
require.ErrorIs(t, blk.(*Block).Verify(context.Background()), ErrBadNonce)
}
// TestDuplicateEffectRefused proves one payer cannot queue two transactions
// that claim the same effect. Without this guard both pass every individual
// check and only collide in Accept, aborting the block that carried them —
// which, since Reject requeues, would rebuild the same doomed pair forever.
func TestDuplicateEffectRefused(t *testing.T) {
k := newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, map[string]uint64{k.hexAddr(): testFund}, committee, 1)
first := registerTx(t, k, testScheme, digestOf("same"), 1)
second := registerTx(t, k, testScheme, digestOf("same"), 2) // same handle, next nonce
_, err := vm.SubmitTx(first)
require.NoError(t, err)
_, err = vm.SubmitTx(second)
require.ErrorIs(t, err, ErrDuplicateEffect, "admission must refuse the second claim")
require.Len(t, vm.mempool, 1)
// A peer could still propose a block containing both. Consensus refuses it.
require.ErrorIs(t, forceBlock(vm, first, second).Verify(context.Background()), ErrDuplicateEffect)
// The honest first transaction is unaffected.
acceptQueued(t, vm)
_, ok := vm.Ciphertext(first.Subject)
require.True(t, ok)
// And once it is committed, a later duplicate is refused by state.
third := registerTx(t, k, testScheme, digestOf("same"), 2)
_, err = vm.SubmitTx(third)
require.ErrorIs(t, err, ErrCiphertextExists)
}
// TestAcceptIsAtomic proves a block that fails partway through applies NOTHING
// and burns NOTHING. The pair here passes Verify honestly — at that moment the
// permit is still active — and only collides once the revocation lands, which
// is exactly the case a per-transaction check cannot see coming.
func TestAcceptIsAtomic(t *testing.T) {
owner := newTestKey(t)
grantee := newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, map[string]uint64{
owner.hexAddr(): testFund,
grantee.hexAddr(): testFund,
}, committee, 1)
handle, permitID := seedPermit(t, vm, owner, grantee, fhe.PermitOpDecrypt, 0)
burnedBefore, _ := vm.Burned()
ownerBefore, _ := vm.Balance(owner.addr)
granteeBefore, _ := vm.Balance(grantee.addr)
// Revocation first, then a request that the revocation invalidates. The
// owner has already spent nonces 1 and 2 seeding the permit.
revoke := revokeTx(t, owner, permitID, 3)
request := requestTx(t, grantee, testScheme, handle, permitID, 0, 1)
blk := forceBlock(vm, revoke, request)
require.NoError(t, blk.Verify(context.Background()),
"both are valid against committed state — the conflict is created by the block itself")
require.ErrorIs(t, blk.Accept(context.Background()), ErrPermitRevoked)
// Nothing applied: the permit is still active, no request exists.
pm, ok := vm.Permit(permitID)
require.True(t, ok)
require.Equal(t, StatusActive, pm.Status, "the revocation must be rolled back with the block")
_, ok = vm.Decrypt(deriveRequestID(handle, grantee.addr, 1))
require.False(t, ok)
// Nothing burned, no balance moved.
burnedAfter, _ := vm.Burned()
require.Equal(t, burnedBefore, burnedAfter, "an aborted block burns nothing")
ownerAfter, _ := vm.Balance(owner.addr)
granteeAfter, _ := vm.Balance(grantee.addr)
require.Equal(t, ownerBefore, ownerAfter)
require.Equal(t, granteeBefore, granteeAfter)
// The chain is still usable: the revocation alone succeeds.
acceptOne(t, vm, revokeTx(t, owner, permitID, 3))
pm, _ = vm.Permit(permitID)
require.Equal(t, StatusRevoked, pm.Status)
}
// forceBlock builds a block directly from the given transactions, bypassing the
// mempool — the way a peer's proposal arrives.
func forceBlock(vm *VM, txs ...*Transaction) *Block {
vm.stateLock.RLock()
parentID, height := vm.lastAccepted, vm.height
vm.stateLock.RUnlock()
blk := &Block{
parentID: parentID,
height: height + 1,
timestamp: time.Now(),
transactions: txs,
vm: vm,
}
blk.id = blk.computeID()
return blk
}
// seedPermit registers a ciphertext owned by owner and grants grantee a permit
// over it, returning the handle and permit id. It is the starting state every
// decryption test needs. It spends the owner's nonces 1 and 2, so the owner's
// next transaction carries nonce 3.
func seedPermit(t *testing.T, vm *VM, owner, grantee testKey, ops uint32, expiry int64) (handle, permitID [32]byte) {
t.Helper()
reg := registerTx(t, owner, testScheme, digestOf("seed-"+owner.hexAddr()), 1)
acceptOne(t, vm, reg)
handle = reg.Subject
grant := grantTx(t, owner, handle, grantee.addr, ops, expiry, 2)
acceptOne(t, vm, grant)
permitID = derivePermitID(handle, owner.addr, grantee.addr, ops, expiry, 2)
_, ok := vm.Permit(permitID)
require.True(t, ok, "the grant must create the permit its inputs derive")
return handle, permitID
}
+228
View File
@@ -0,0 +1,228 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"crypto/sha256"
"encoding/binary"
"sort"
"github.com/luxfi/chains/fee"
"github.com/luxfi/chains/mpcvm/fhe"
)
// CIPHERTEXT-BODY INVARIANT (the F-Chain's reason to exist).
//
// F-Chain is the COORDINATION plane for confidential compute. It stores the
// PUBLIC coordinates of an encrypted value — a 32-byte handle, the digest of
// the ciphertext body, who owns it, who may act on it, and which threshold
// decryptions were asked for and answered. It never stores a ciphertext body,
// never holds the FHE secret key, and never holds a decryption share: the
// bodies live in off-chain storage and the key shares live on the threshold
// committee, whose members are named here only by public node ID and public
// signing key.
//
// The invariant is structural, not a matter of discipline. The four record
// types below are everything F persists, and each of their fields is a public
// coordinate: hashes, addresses, bitmasks, sizes, epochs, timestamps. There is
// no field whose type can carry ciphertext, plaintext, or a share.
// ciphertext_test.go proves this by walking the types reflectively.
//
// The records embed the FHE runtime's own types (github.com/luxfi/chains/
// mpcvm/fhe) so the chain and the runtime speak one vocabulary. F owns the
// PERSISTENCE, because persistence in a chain is consensus: every field F
// writes is derived from the accepting block (its timestamp, its transactions)
// and never from a validator's wall clock, so two validators replaying the
// same block write the same bytes. The runtime's own Registry stamps
// time.Now() and is the right store for the off-chain daemon; it is the wrong
// store for a state root. See vm.go.
// Permit lifecycle states. A permit is the only revocable thing on F: a
// registration is a fact about a ciphertext that exists, and a fact does not
// stop being true, whereas the authority to act on it can be withdrawn at any
// time. Access is therefore controlled entirely by granting and revoking
// permits, and there is no second place to look.
const (
StatusActive = "active"
StatusRevoked = "revoked"
)
// CiphertextRecord is F's record of one registered encrypted value. The body it
// describes is off-chain; Digest binds this record to it, and Handle — derived
// from Digest and Scheme — is the name every other operation and every off-chain
// consumer uses to refer to it.
type CiphertextRecord struct {
fhe.CiphertextMeta // Handle, Owner, Type, Level, Epoch, RegisteredAt, Size, ChainID
Scheme string `json:"scheme"` // FHE scheme + ring dimension (drives gas)
Digest [32]byte `json:"digest"` // hash of the off-chain ciphertext body
}
// PermitRecord is a capability: the grantor lets the grantee perform a set of
// operations on one handle until Expiry. It is the only thing that authorizes a
// decryption request, so it is checked at admission, in consensus, and again at
// application.
type PermitRecord struct {
fhe.Permit // PermitID, Handle, Grantee, Grantor, Operations, Expiry, CreatedAt, Attestation, ChainID
Status string `json:"status"`
}
// DecryptRecord is a threshold-decryption request and the committee's answer to
// it. F does not decrypt: the committee combines its shares off-chain and each
// member ATTESTS the resulting public handle here. The request completes when
// Threshold distinct members attest the SAME result, which is why Attestations
// is a list of (member, result) pairs and not a single field — a lone member
// posting a wrong result buys nothing but its own burnt fee, and cannot stall
// the honest majority.
type DecryptRecord struct {
fhe.DecryptRequest // RequestID, CiphertextHandle, Requester, Callback, ..., Status, ResultHandle
PermitID [32]byte `json:"permitId"`
Attestations []Attestation `json:"attestations"`
}
// Attestation is one committee member's vote for one 32-byte value. It backs
// both of F's threshold decisions — which plaintext handle a decryption
// produced, and which committee the next epoch has — because both are the same
// question: did Threshold distinct members say the same thing?
type Attestation struct {
Member fee.Account `json:"member"`
Value [32]byte `json:"value"`
}
// EpochRecord is the committee that holds the FHE key shares for one epoch,
// together with the network public key they jointly generated. Epoch 0 comes
// from genesis; every later epoch is installed by TxAdvanceEpoch once Threshold
// members of the CURRENT committee attest the same successor.
type EpochRecord struct {
fhe.EpochInfo // Epoch, StartTime, EndTime, Committee, Threshold, PublicKey, Status
Attestations []Attestation `json:"attestations"` // approvals of the NEXT epoch, tallied here
}
// tally counts how many DISTINCT members attested value, which is the only
// question a threshold decision asks.
func tally(as []Attestation, value [32]byte) int {
seen := make(map[fee.Account]struct{}, len(as))
for _, a := range as {
if a.Value != value {
continue
}
seen[a.Member] = struct{}{}
}
return len(seen)
}
// attested reports whether member already voted, whatever it voted for. One
// member, one vote: a member cannot raise a value's count by repeating itself,
// and cannot hedge by attesting two different values.
func attested(as []Attestation, member fee.Account) bool {
for _, a := range as {
if a.Member == member {
return true
}
}
return false
}
// memberOf reports whether acct is a committee member of this epoch. A member's
// F-Chain account is derived from its PUBLIC signing key by exactly the
// derivation that authenticates a transaction payer (transaction.go
// addressOf), so committee membership and payer identity cannot disagree.
func (e *EpochRecord) memberOf(acct fee.Account) bool {
for _, m := range e.Committee {
if addressOf(m.PublicKey) == acct {
return true
}
}
return false
}
// committeeOrder is the canonical ordering of a committee: ascending node ID.
// A committee is hashed to decide an epoch advance, so two members proposing
// the same set must produce the same bytes — the order is part of the value.
func committeeOrder(c []fhe.CommitteeMember) bool {
return sort.SliceIsSorted(c, func(i, j int) bool {
return c[i].NodeID.Compare(c[j].NodeID) < 0
})
}
// committeeDigest is the value committee members attest when advancing an
// epoch. It hashes the SEMANTIC proposal — epoch, threshold, network public
// key, and the canonically ordered members — rather than the transaction's
// payload bytes, so two members whose clients encode the same proposal
// differently still vote for the same thing.
func committeeDigest(epoch uint64, threshold int, publicKey []byte, c []fhe.CommitteeMember) [32]byte {
h := sha256.New()
h.Write([]byte("fhevm/epoch/"))
var u8 [8]byte
binary.BigEndian.PutUint64(u8[:], epoch)
h.Write(u8[:])
binary.BigEndian.PutUint64(u8[:], uint64(threshold))
h.Write(u8[:])
writeLenPrefixed(h, publicKey)
binary.BigEndian.PutUint64(u8[:], uint64(len(c)))
h.Write(u8[:])
for _, m := range c {
id := m.NodeID
h.Write(id[:])
writeLenPrefixed(h, m.PublicKey)
binary.BigEndian.PutUint64(u8[:], m.Weight)
h.Write(u8[:])
binary.BigEndian.PutUint64(u8[:], uint64(m.Index))
h.Write(u8[:])
}
return [32]byte(h.Sum(nil))
}
// writeLenPrefixed writes len(b) then b, so concatenated fields cannot be
// re-split at a different boundary to forge a colliding digest.
func writeLenPrefixed(h interface{ Write([]byte) (int, error) }, b []byte) {
var u8 [8]byte
binary.BigEndian.PutUint64(u8[:], uint64(len(b)))
_, _ = h.Write(u8[:])
_, _ = h.Write(b)
}
// deriveHandle names a ciphertext by its CONTENT: the digest of the off-chain
// body under a given scheme. Two registrations of the same body under the same
// scheme therefore collide by construction and the second is refused, and a
// handle cannot be squatted by someone who does not have the body to hash.
func deriveHandle(digest [32]byte, scheme string) [32]byte {
h := sha256.New()
h.Write([]byte("fhevm/ct/"))
h.Write(digest[:])
writeLenPrefixed(h, []byte(scheme))
return [32]byte(h.Sum(nil))
}
// derivePermitID names a grant by everything that distinguishes it, including
// the grantor's nonce, so a grantor may re-grant the same capability later
// without colliding with the earlier permit.
func derivePermitID(handle [32]byte, grantor, grantee fee.Account, ops uint32, expiry int64, nonce uint64) [32]byte {
h := sha256.New()
h.Write([]byte("fhevm/permit/"))
h.Write(handle[:])
h.Write(grantor[:])
h.Write(grantee[:])
var u8 [8]byte
binary.BigEndian.PutUint64(u8[:], uint64(ops))
h.Write(u8[:])
binary.BigEndian.PutUint64(u8[:], uint64(expiry))
h.Write(u8[:])
binary.BigEndian.PutUint64(u8[:], nonce)
h.Write(u8[:])
return [32]byte(h.Sum(nil))
}
// deriveRequestID names a decryption request by handle, requester and the
// requester's nonce — all fields the payer signed — so the id is deterministic
// across validators and unique per request.
func deriveRequestID(handle [32]byte, requester fee.Account, nonce uint64) [32]byte {
h := sha256.New()
h.Write([]byte("fhevm/decrypt/"))
h.Write(handle[:])
h.Write(requester[:])
var u8 [8]byte
binary.BigEndian.PutUint64(u8[:], nonce)
h.Write(u8[:])
return [32]byte(h.Sum(nil))
}
+629
View File
@@ -0,0 +1,629 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"crypto/sha256"
"encoding/binary"
"encoding/json"
"fmt"
"github.com/luxfi/chains/fee"
"github.com/luxfi/chains/mpcvm/fhe"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/ids"
)
// Transaction types. Each is a MUTATING operation that may only take effect
// through a fee-settled consensus block — never through a synchronous RPC.
// Together they are the whole life of a confidential value on F: it is
// registered, capabilities over it are granted and revoked, its decryption is
// requested, and the committee answers; the committee that answers is itself
// rotated by the sixth.
const (
TxRegisterCiphertext uint8 = 1 // record a ciphertext's PUBLIC handle + metadata
TxGrantPermit uint8 = 2 // owner grants a capability over a handle
TxRevokePermit uint8 = 3 // owner withdraws a capability it granted
TxRequestDecrypt uint8 = 4 // permitted grantee asks the committee to decrypt
TxFulfillDecrypt uint8 = 5 // committee member attests the PUBLIC result
TxAdvanceEpoch uint8 = 6 // committee installs its successor
)
// payerAuthMode is the algorithm F uses to authenticate a transaction's payer
// and to identify a committee member. It is the platform service-identity
// scheme (ML-DSA-65). Authentication is a PUBLIC operation: F parses the
// payer's public key and verifies a signature; it never possesses a secret.
const payerAuthMode = mldsa.MLDSA65
// Transaction is an F-Chain consensus transaction. Its header is deterministic
// binary; Payload is an opaque, PUBLIC, op-specific JSON blob; Auth is the
// payer's ML-DSA-65 public key and Sig the payer's signature over the signing
// bytes. Nothing here is or can become ciphertext, plaintext, or a key share.
type Transaction struct {
Type uint8
Scheme string // FHE scheme + ring dimension (drives per-scheme gas); "" for scheme-independent ops
Payer fee.Account // fee payer + authorization subject (public address)
Subject [32]byte // the object this operation acts on or creates (see below)
GasLimit uint64 // payer-declared gas ceiling for this tx
Nonce uint64 // payer replay/uniqueness nonce
Payload []byte // op-specific PUBLIC encoding
Auth []byte // payer ML-DSA-65 PUBLIC key
Sig []byte // payer signature over SigningBytes()
id ids.ID // cached, computed from full Bytes()
}
// Subject names the object of the operation, and is ALWAYS checked against what
// the payload derives — so the signature covers the object, not just the
// arguments that happen to produce it:
//
// TxRegisterCiphertext the handle the payload's digest+scheme derive
// TxGrantPermit the handle being granted over
// TxRevokePermit the permit being withdrawn
// TxRequestDecrypt the handle whose decryption is asked for
// TxFulfillDecrypt the request being answered
// TxAdvanceEpoch the digest of the successor committee being approved
// Operation payloads. All fields are PUBLIC.
// RegisterPayload records an encrypted value's PUBLIC coordinates. Digest is
// the hash of the ciphertext BODY, which lives in off-chain storage — F stores
// the hash so anyone can check a fetched body against the chain, and never the
// body itself.
type RegisterPayload struct {
Digest [32]byte `json:"digest"`
Type uint8 `json:"type"` // FHE plaintext type tag (bool, uint8, …)
Level int `json:"level"` // remaining multiplicative level
Size uint32 `json:"size"` // ciphertext body size in bytes
}
// GrantPayload grants a capability over one handle to one grantee until Expiry.
// Operations is a bitmask of fhe.PermitOp* values.
type GrantPayload struct {
Grantee fee.Account `json:"grantee"`
Operations uint32 `json:"operations"`
Expiry int64 `json:"expiry"` // unix seconds; 0 = no expiry
}
// RevokePayload withdraws a permit.
type RevokePayload struct {
Reason string `json:"reason"`
}
// RequestPayload asks the committee to threshold-decrypt a handle under the
// authority of a permit the requester holds. Callback and Selector name where
// the answer should be delivered on the source chain.
type RequestPayload struct {
PermitID [32]byte `json:"permitId"`
Callback [20]byte `json:"callback"`
Selector [4]byte `json:"selector"`
Expiry int64 `json:"expiry"` // unix seconds; 0 = the chain's default window
}
// FulfillPayload is one committee member's attestation of the PUBLIC handle a
// threshold decryption produced. The plaintext itself is delivered off-chain to
// the callback; F records only which handle the committee agreed on.
type FulfillPayload struct {
Result [32]byte `json:"result"`
}
// AdvancePayload proposes the next epoch's committee and the network public key
// it jointly generated. Every approving member sends the identical proposal;
// the epoch installs when Threshold of the CURRENT committee have.
type AdvancePayload struct {
Epoch uint64 `json:"epoch"`
Committee []fhe.CommitteeMember `json:"committee"`
Threshold int `json:"threshold"`
PublicKey []byte `json:"publicKey"`
}
// DefaultRequestWindow is how long a decryption request stays answerable when
// the requester names no expiry. A request that no committee can still answer
// is dead weight in state, and an unbounded one would be answerable by a future
// committee that never saw the permit that authorized it.
const DefaultRequestWindow = int64(3600)
// ID returns the transaction's content hash (over the full Bytes).
func (tx *Transaction) ID() ids.ID {
if tx.id == ids.Empty {
tx.id = ids.ID(sha256.Sum256(tx.Bytes()))
}
return tx.id
}
// addressOf derives an account from an ML-DSA public key. F is internally
// consistent: it derives the same address it checks a payer against, and the
// same address it recognises a committee member by. This is a PUBLIC, one-way
// derivation (no secret involved).
func addressOf(pub []byte) fee.Account {
h := sha256.Sum256(pub)
var a fee.Account
copy(a[:], h[:ids.ShortIDLen])
return a
}
// SyntacticVerify checks the transaction is well-formed and priceable, without
// any state. It rejects unknown types, unpriceable schemes, undecodable or
// out-of-range payloads, and any Subject that disagrees with what the payload
// derives — all fail-closed.
func (tx *Transaction) SyntacticVerify() error {
switch tx.Type {
case TxRegisterCiphertext, TxGrantPermit, TxRevokePermit,
TxRequestDecrypt, TxFulfillDecrypt, TxAdvanceEpoch:
default:
return ErrInvalidTxType
}
// Pricing also validates scheme membership for scheme-bearing ops.
if _, err := GasFor(tx); err != nil {
return err
}
if tx.Nonce == 0 {
return fmt.Errorf("fhevm: %w: nonce starts at 1", ErrBadNonce)
}
switch tx.Type {
case TxRegisterCiphertext:
p, err := decode[RegisterPayload](tx.Payload, "register")
if err != nil {
return err
}
if p.Digest == ([32]byte{}) {
return fmt.Errorf("fhevm: %w: register: empty ciphertext digest", ErrInvalidPayload)
}
if p.Size == 0 {
return fmt.Errorf("fhevm: %w: register: zero-length ciphertext", ErrInvalidPayload)
}
if p.Level < 0 {
return fmt.Errorf("fhevm: %w: register: negative level", ErrInvalidPayload)
}
if tx.Subject != deriveHandle(p.Digest, tx.Scheme) {
return fmt.Errorf("fhevm: %w: handle does not match digest+scheme", ErrHandleMismatch)
}
case TxGrantPermit:
p, err := decode[GrantPayload](tx.Payload, "grant")
if err != nil {
return err
}
if p.Operations == 0 {
return fmt.Errorf("fhevm: %w: grant confers no operation", ErrInvalidPayload)
}
if p.Operations & ^permitOpMask != 0 {
return fmt.Errorf("fhevm: %w: unknown permit operation bits", ErrInvalidPayload)
}
if p.Expiry < 0 {
return fmt.Errorf("fhevm: %w: negative expiry", ErrInvalidPayload)
}
case TxRevokePermit:
if _, err := decode[RevokePayload](tx.Payload, "revoke"); err != nil {
return err
}
case TxRequestDecrypt:
p, err := decode[RequestPayload](tx.Payload, "request")
if err != nil {
return err
}
if p.PermitID == ([32]byte{}) {
return fmt.Errorf("fhevm: %w: request names no permit", ErrInvalidPayload)
}
if p.Expiry < 0 {
return fmt.Errorf("fhevm: %w: negative expiry", ErrInvalidPayload)
}
case TxFulfillDecrypt:
p, err := decode[FulfillPayload](tx.Payload, "fulfill")
if err != nil {
return err
}
if p.Result == ([32]byte{}) {
return fmt.Errorf("fhevm: %w: fulfill carries no result handle", ErrInvalidPayload)
}
case TxAdvanceEpoch:
p, err := decode[AdvancePayload](tx.Payload, "advance")
if err != nil {
return err
}
if err := ValidateCommittee(p.Committee, p.Threshold, p.PublicKey); err != nil {
return err
}
if tx.Subject != committeeDigest(p.Epoch, p.Threshold, p.PublicKey, p.Committee) {
return fmt.Errorf("fhevm: %w: subject does not match the proposed committee", ErrHandleMismatch)
}
}
return nil
}
// permitOpMask is every capability bit the FHE runtime defines. A grant that
// sets a bit outside it is refused rather than silently conferring nothing.
const permitOpMask = fhe.PermitOpDecrypt | fhe.PermitOpReencrypt | fhe.PermitOpCompute | fhe.PermitOpTransfer
// ValidateCommittee checks a committee is installable: non-empty, canonically
// ordered by node ID, free of duplicates, with a real threshold and with every
// member carrying a parseable ML-DSA-65 public key. The last check is what
// stops F being wedged by an epoch whose members can never sign an attestation
// — the committee that cannot speak can never be replaced either. Genesis and
// TxAdvanceEpoch both go through here, so the two can never disagree.
func ValidateCommittee(c []fhe.CommitteeMember, threshold int, publicKey []byte) error {
if len(c) == 0 {
return fmt.Errorf("fhevm: %w: empty committee", ErrInvalidCommittee)
}
if threshold <= 0 || threshold > len(c) {
return fmt.Errorf("fhevm: %w: threshold %d of %d", ErrInvalidThreshold, threshold, len(c))
}
if len(publicKey) == 0 {
return fmt.Errorf("fhevm: %w: no network public key", ErrInvalidCommittee)
}
if !committeeOrder(c) {
return fmt.Errorf("fhevm: %w: members not in canonical node-ID order", ErrInvalidCommittee)
}
for i, m := range c {
if i > 0 && c[i-1].NodeID == m.NodeID {
return fmt.Errorf("fhevm: %w: duplicate member %s", ErrInvalidCommittee, m.NodeID)
}
if _, err := mldsa.PublicKeyFromBytes(m.PublicKey, payerAuthMode); err != nil {
return fmt.Errorf("fhevm: %w: member %s key: %v", ErrInvalidCommittee, m.NodeID, err)
}
}
return nil
}
// decode unmarshals an op payload, naming the operation in the error.
func decode[T any](payload []byte, op string) (T, error) {
var v T
if err := json.Unmarshal(payload, &v); err != nil {
return v, fmt.Errorf("fhevm: %w: %s: %v", ErrInvalidPayload, op, err)
}
return v, nil
}
// authenticate verifies the payer authorized this transaction. PUBLIC ONLY:
// parse the payer's ML-DSA-65 public key, require it hashes to Payer, and
// verify the signature over SigningBytes. No secret material is touched.
func (tx *Transaction) authenticate() error {
if len(tx.Auth) == 0 || len(tx.Sig) == 0 {
return ErrUnsignedTx
}
if addressOf(tx.Auth) != tx.Payer {
return ErrPayerMismatch
}
pub, err := mldsa.PublicKeyFromBytes(tx.Auth, payerAuthMode)
if err != nil {
return fmt.Errorf("fhevm: payer public key: %w", err)
}
if !pub.VerifySignature(tx.SigningBytes(), tx.Sig) {
return ErrBadSignature
}
return nil
}
// effect names, in 32 bytes, the state entry this transaction writes — and,
// where an entry legitimately takes a write from each of several actors, which
// actor writes it. Two transactions sharing an effect cannot both take place:
// the second would find the ciphertext already registered, the permit already
// withdrawn, or the member already counted, and abort the block that carried
// them both.
//
// Nonces do not catch that on their own. A payer's nonces n and n+1 are both
// valid, so one payer can build two transactions that individually pass every
// check and together wedge a block — and since a rejected block requeues its
// contents, the same doomed pair would be rebuilt indefinitely. Naming the
// effect lets admission refuse the second before it is ever queued (vm.go
// SubmitTx) and lets consensus refuse a peer's block that contains both
// (block.go Verify). One function, both layers, no drift.
func (tx *Transaction) effect() [32]byte {
h := sha256.New()
h.Write([]byte{tx.Type})
h.Write(tx.Subject[:])
switch tx.Type {
case TxRegisterCiphertext, TxRevokePermit:
// The entry is named by Subject alone: one registration per handle, one
// withdrawal per permit, whoever asks for it.
case TxFulfillDecrypt, TxAdvanceEpoch:
// A threshold decision takes one write per member, so the effect is the
// member's VOTE rather than the value voted for. That is what stops a
// member spending twice on one decision, or hedging across two values.
h.Write(tx.Payer[:])
default:
// A grant and a request CREATE an entry whose id already carries the
// payer and its nonce, so no two of them collide — the same owner may
// grant twice over one handle, to different grantees, in one block.
// Qualifying by the same two fields keeps that true here.
h.Write(tx.Payer[:])
var u8 [8]byte
binary.BigEndian.PutUint64(u8[:], tx.Nonce)
h.Write(u8[:])
}
return [32]byte(h.Sum(nil))
}
// checkAuth is the single, read-only authorization predicate for a transaction:
// it decides whether tx may take effect against the CURRENT committed state at
// time now. It mutates nothing. It is the one place F's access model is
// enforced, called at three layers so unauthorized transactions are rejected at
// the earliest gate and never charged: admission (SubmitTx), consensus
// (Block.Verify), and — as defense in depth — application (Apply). The caller
// holds the appropriate stateLock.
func (tx *Transaction) checkAuth(vm *VM, now int64) error {
switch tx.Type {
case TxRegisterCiphertext:
if _, ok := vm.getCiphertext(tx.Subject); ok {
return ErrCiphertextExists
}
return nil
case TxGrantPermit:
ct, ok := vm.getCiphertext(tx.Subject)
if !ok {
return ErrCiphertextNotFound
}
// Only the owner may confer a capability over its ciphertext — and,
// because only the owner grants, only the owner revokes.
if ct.Owner != tx.Payer {
return ErrUnauthorized
}
return nil
case TxRevokePermit:
pm, ok := vm.getPermit(tx.Subject)
if !ok {
return ErrPermitNotFound
}
if pm.Status != StatusActive {
return ErrPermitRevoked
}
if pm.Grantor != tx.Payer {
return ErrUnauthorized
}
return nil
case TxRequestDecrypt:
p, err := decode[RequestPayload](tx.Payload, "request")
if err != nil {
return err
}
if _, ok := vm.getCiphertext(tx.Subject); !ok {
return ErrCiphertextNotFound
}
pm, ok := vm.getPermit(p.PermitID)
if !ok {
return ErrPermitNotFound
}
if pm.Status != StatusActive {
return ErrPermitRevoked
}
if pm.Handle != tx.Subject {
return fmt.Errorf("fhevm: %w: permit is for another handle", ErrPermitInvalid)
}
if pm.Grantee != tx.Payer {
return ErrUnauthorized
}
if pm.Expiry != 0 && now > pm.Expiry {
return ErrPermitExpired
}
if pm.Operations&fhe.PermitOpDecrypt == 0 {
return fmt.Errorf("fhevm: %w: permit does not confer decrypt", ErrPermitInvalid)
}
// The request's id carries the requester's nonce, and a nonce is used
// once, so a request cannot collide with an existing one. That is the
// whole uniqueness argument — there is no second check to keep in step
// with it.
return nil
case TxFulfillDecrypt:
req, ok := vm.getDecrypt(tx.Subject)
if !ok {
return ErrRequestNotFound
}
if req.Status != fhe.RequestPending {
return ErrRequestClosed
}
if req.Expiry != 0 && now > req.Expiry {
return ErrRequestExpired
}
// Only the committee of the epoch the request was made in may answer it:
// a later committee holds different shares and never saw the permit.
ep, ok := vm.getEpoch(req.Epoch)
if !ok {
return ErrEpochNotFound
}
if !ep.memberOf(tx.Payer) {
return ErrNotCommittee
}
if attested(req.Attestations, tx.Payer) {
return ErrAlreadyAttested
}
return nil
case TxAdvanceEpoch:
p, err := decode[AdvancePayload](tx.Payload, "advance")
if err != nil {
return err
}
cur := vm.currentEpoch()
if p.Epoch != cur.Epoch+1 {
return fmt.Errorf("fhevm: %w: proposed %d, next is %d", ErrEpochMismatch, p.Epoch, cur.Epoch+1)
}
// Only the sitting committee decides its successor.
if !cur.memberOf(tx.Payer) {
return ErrNotCommittee
}
if attested(cur.Attestations, tx.Payer) {
return ErrAlreadyAttested
}
return nil
default:
return ErrInvalidTxType
}
}
// Apply mutates VM state for an already-verified, already-paid transaction. It
// runs inside block.Accept, writing through the VM's versiondb so the effect
// commits atomically with the fee burn. now is the accepting block's unix time
// — every timestamp F stores comes from here, never from a validator's clock.
// It re-runs checkAuth (defense in depth) before mutating.
func (tx *Transaction) Apply(vm *VM, now int64) error {
if err := tx.checkAuth(vm, now); err != nil {
return err
}
switch tx.Type {
case TxRegisterCiphertext:
return tx.applyRegister(vm, now)
case TxGrantPermit:
return tx.applyGrant(vm, now)
case TxRevokePermit:
return tx.applyRevoke(vm)
case TxRequestDecrypt:
return tx.applyRequest(vm, now)
case TxFulfillDecrypt:
return tx.applyFulfill(vm, now)
case TxAdvanceEpoch:
return tx.applyAdvance(vm, now)
default:
return ErrInvalidTxType
}
}
func (tx *Transaction) applyRegister(vm *VM, now int64) error {
p, err := decode[RegisterPayload](tx.Payload, "register")
if err != nil {
return err
}
rec := &CiphertextRecord{
CiphertextMeta: fhe.CiphertextMeta{
Handle: tx.Subject,
Owner: tx.Payer,
Type: p.Type,
Level: p.Level,
Epoch: vm.currentEpoch().Epoch,
RegisteredAt: now,
Size: p.Size,
ChainID: vm.chainID,
},
Scheme: tx.Scheme,
Digest: p.Digest,
}
return vm.putCiphertext(rec)
}
func (tx *Transaction) applyGrant(vm *VM, now int64) error {
p, err := decode[GrantPayload](tx.Payload, "grant")
if err != nil {
return err
}
rec := &PermitRecord{
Permit: fhe.Permit{
PermitID: derivePermitID(tx.Subject, tx.Payer, p.Grantee, p.Operations, p.Expiry, tx.Nonce),
Handle: tx.Subject,
Grantee: p.Grantee,
Grantor: tx.Payer,
Operations: p.Operations,
Expiry: p.Expiry,
CreatedAt: now,
ChainID: vm.chainID,
},
Status: StatusActive,
}
return vm.putPermit(rec)
}
func (tx *Transaction) applyRevoke(vm *VM) error {
pm, ok := vm.getPermit(tx.Subject)
if !ok {
return ErrPermitNotFound
}
pm.Status = StatusRevoked
return vm.putPermit(pm)
}
func (tx *Transaction) applyRequest(vm *VM, now int64) error {
p, err := decode[RequestPayload](tx.Payload, "request")
if err != nil {
return err
}
expiry := p.Expiry
if expiry == 0 {
expiry = now + DefaultRequestWindow
}
rec := &DecryptRecord{
DecryptRequest: fhe.DecryptRequest{
RequestID: deriveRequestID(tx.Subject, tx.Payer, tx.Nonce),
CiphertextHandle: tx.Subject,
Requester: tx.Payer,
Callback: p.Callback,
CallbackSelector: p.Selector,
SourceChain: vm.chainID,
Epoch: vm.currentEpoch().Epoch,
Nonce: tx.Nonce,
Expiry: expiry,
Status: fhe.RequestPending,
CreatedAt: now,
},
PermitID: p.PermitID,
}
return vm.putDecrypt(rec)
}
func (tx *Transaction) applyFulfill(vm *VM, now int64) error {
p, err := decode[FulfillPayload](tx.Payload, "fulfill")
if err != nil {
return err
}
req, ok := vm.getDecrypt(tx.Subject)
if !ok {
return ErrRequestNotFound
}
ep, ok := vm.getEpoch(req.Epoch)
if !ok {
return ErrEpochNotFound
}
req.Attestations = append(req.Attestations, Attestation{Member: tx.Payer, Value: p.Result})
// The request completes the moment a threshold of DISTINCT members have
// named the same handle. A member that names a different one is counted
// against that value alone, so it delays nothing and pays for the privilege.
if tally(req.Attestations, p.Result) >= ep.Threshold {
req.Status = fhe.RequestCompleted
req.ResultHandle = p.Result
req.CompletedAt = now
}
return vm.putDecrypt(req)
}
func (tx *Transaction) applyAdvance(vm *VM, now int64) error {
p, err := decode[AdvancePayload](tx.Payload, "advance")
if err != nil {
return err
}
cur := vm.currentEpoch()
cur.Attestations = append(cur.Attestations, Attestation{Member: tx.Payer, Value: tx.Subject})
if tally(cur.Attestations, tx.Subject) < cur.Threshold {
// Not yet decided: record the vote against the sitting epoch and stop.
return vm.putEpoch(cur)
}
// Decided. The transaction that carries the deciding vote also carries the
// proposal itself, so no proposal body is ever stored while it is pending —
// the digest the members attested is the whole record of what they agreed.
cur.EndTime = now
cur.Status = fhe.EpochEnded
if err := vm.putEpoch(cur); err != nil {
return err
}
next := &EpochRecord{EpochInfo: fhe.EpochInfo{
Epoch: p.Epoch,
StartTime: now,
Committee: p.Committee,
Threshold: p.Threshold,
PublicKey: p.PublicKey,
Status: fhe.EpochActive,
}}
if err := vm.putEpoch(next); err != nil {
return err
}
return vm.setCurrentEpoch(p.Epoch)
}
+314
View File
@@ -0,0 +1,314 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/chains/mpcvm/fhe"
"github.com/luxfi/ids"
)
// TestSyntacticVerify_AcceptsWellFormed establishes the baseline every
// rejection test below is measured against.
func TestSyntacticVerify_AcceptsWellFormed(t *testing.T) {
k := newTestKey(t)
require.NoError(t, registerTx(t, k, testScheme, digestOf("ok"), 1).SyntacticVerify())
}
// TestSyntacticVerify_Rejects proves each structural rule actually refuses.
// Every case is a well-formed transaction with exactly one thing wrong, so a
// rule that stopped being enforced fails here instead of shipping.
func TestSyntacticVerify_Rejects(t *testing.T) {
k := newTestKey(t)
digest := digestOf("subject")
handle := deriveHandle(digest, testScheme)
for _, tc := range []struct {
name string
tx *Transaction
want error
}{
{
name: "unknown transaction type",
tx: &Transaction{Type: 99, Nonce: 1},
want: ErrInvalidTxType,
},
{
name: "unknown scheme",
tx: &Transaction{Type: TxRegisterCiphertext, Scheme: "paillier", Nonce: 1,
Payload: mustJSON(t, RegisterPayload{Digest: digest, Size: 1})},
want: ErrUnknownScheme,
},
{
name: "nonce zero",
tx: &Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 0,
Subject: handle, Payload: mustJSON(t, RegisterPayload{Digest: digest, Size: 1})},
want: ErrBadNonce,
},
{
name: "undecodable payload",
tx: &Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 1,
Subject: handle, Payload: []byte("not json")},
want: ErrInvalidPayload,
},
{
name: "register with no digest",
tx: &Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 1,
Payload: mustJSON(t, RegisterPayload{Size: 1})},
want: ErrInvalidPayload,
},
{
name: "register with zero size",
tx: &Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 1,
Subject: handle, Payload: mustJSON(t, RegisterPayload{Digest: digest})},
want: ErrInvalidPayload,
},
{
name: "register with negative level",
tx: &Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 1,
Subject: handle, Payload: mustJSON(t, RegisterPayload{Digest: digest, Size: 1, Level: -1})},
want: ErrInvalidPayload,
},
{
name: "register whose subject is not the derived handle",
tx: &Transaction{Type: TxRegisterCiphertext, Scheme: testScheme, Nonce: 1,
Subject: [32]byte{0xde, 0xad}, Payload: mustJSON(t, RegisterPayload{Digest: digest, Size: 1})},
want: ErrHandleMismatch,
},
{
name: "register under a different scheme than the handle names",
tx: &Transaction{Type: TxRegisterCiphertext, Scheme: "bfv-n13", Nonce: 1,
Subject: handle, Payload: mustJSON(t, RegisterPayload{Digest: digest, Size: 1})},
want: ErrHandleMismatch,
},
{
name: "grant conferring nothing",
tx: &Transaction{Type: TxGrantPermit, Nonce: 1, Subject: handle,
Payload: mustJSON(t, GrantPayload{Grantee: k.addr, Operations: 0})},
want: ErrInvalidPayload,
},
{
name: "grant with unknown operation bits",
tx: &Transaction{Type: TxGrantPermit, Nonce: 1, Subject: handle,
Payload: mustJSON(t, GrantPayload{Grantee: k.addr, Operations: 1 << 20})},
want: ErrInvalidPayload,
},
{
name: "request naming no permit",
tx: &Transaction{Type: TxRequestDecrypt, Scheme: testScheme, Nonce: 1, Subject: handle,
Payload: mustJSON(t, RequestPayload{})},
want: ErrInvalidPayload,
},
{
name: "fulfil carrying no result",
tx: &Transaction{Type: TxFulfillDecrypt, Nonce: 1, Subject: handle,
Payload: mustJSON(t, FulfillPayload{})},
want: ErrInvalidPayload,
},
} {
t.Run(tc.name, func(t *testing.T) {
require.ErrorIs(t, tc.tx.SyntacticVerify(), tc.want)
})
}
}
// TestSyntacticVerify_AdvanceRejectsUnusableCommittee proves an epoch proposal
// carries the whole committee check with it, so a committee that could never
// sign cannot be voted in.
func TestSyntacticVerify_AdvanceRejectsUnusableCommittee(t *testing.T) {
good, _ := newCommittee(t, 3)
pk := []byte("next-network-key")
advance := func(c []fhe.CommitteeMember, threshold int, key []byte) *Transaction {
return &Transaction{
Type: TxAdvanceEpoch, Nonce: 1,
Subject: committeeDigest(1, threshold, key, c),
Payload: mustJSON(t, AdvancePayload{Epoch: 1, Committee: c, Threshold: threshold, PublicKey: key}),
}
}
require.NoError(t, advance(good, 2, pk).SyntacticVerify())
require.ErrorIs(t, advance(nil, 1, pk).SyntacticVerify(), ErrInvalidCommittee)
require.ErrorIs(t, advance(good, 0, pk).SyntacticVerify(), ErrInvalidThreshold)
require.ErrorIs(t, advance(good, 4, pk).SyntacticVerify(), ErrInvalidThreshold)
require.ErrorIs(t, advance(good, 2, nil).SyntacticVerify(), ErrInvalidCommittee)
// Out of canonical order: two members proposing the same set would hash it
// differently and never agree, so the order is part of the value.
shuffled := append([]fhe.CommitteeMember(nil), good...)
shuffled[0], shuffled[2] = shuffled[2], shuffled[0]
require.ErrorIs(t, advance(shuffled, 2, pk).SyntacticVerify(), ErrInvalidCommittee)
// A member whose key cannot be parsed could never attest — installing it
// would wedge the chain, because a committee that cannot speak also cannot
// be replaced.
unusable := append([]fhe.CommitteeMember(nil), good...)
unusable[1].PublicKey = []byte("not-an-mldsa-65-key")
require.ErrorIs(t, advance(unusable, 2, pk).SyntacticVerify(), ErrInvalidCommittee)
// A proposal whose subject does not hash its own contents is refused, so
// the signature always covers the committee actually being voted for.
mismatched := advance(good, 2, pk)
mismatched.Subject = [32]byte{0xff}
require.ErrorIs(t, mismatched.SyntacticVerify(), ErrHandleMismatch)
}
// TestAuthenticate proves the payer authentication path: an unsigned, a
// tampered, and an impersonating transaction are each refused, and only a
// genuine signature by the claimed identity passes.
func TestAuthenticate(t *testing.T) {
k := newTestKey(t)
other := newTestKey(t)
require.NoError(t, registerTx(t, k, testScheme, digestOf("auth"), 1).authenticate())
unsigned := registerTx(t, k, testScheme, digestOf("auth"), 1)
unsigned.Auth, unsigned.Sig = nil, nil
require.ErrorIs(t, unsigned.authenticate(), ErrUnsignedTx)
tampered := registerTx(t, k, testScheme, digestOf("auth"), 1)
tampered.Nonce = 7
require.ErrorIs(t, tampered.authenticate(), ErrBadSignature)
// Signed by `other` but claiming k's address: the address is derived from
// the attached public key, so the two cannot be made to agree.
impersonating := registerTx(t, k, testScheme, digestOf("auth"), 1)
other.sign(t, impersonating)
require.ErrorIs(t, impersonating.authenticate(), ErrPayerMismatch)
// Auth bytes that are not a public key at all.
garbage := registerTx(t, k, testScheme, digestOf("auth"), 1)
garbage.Auth = []byte("garbage")
garbage.Payer = addressOf(garbage.Auth)
require.Error(t, garbage.authenticate())
}
// TestAddressDerivationIsStable proves a payer's address and a committee
// member's address come from the same one-way derivation, so membership and
// payer identity cannot disagree.
func TestAddressDerivationIsStable(t *testing.T) {
k := newTestKey(t)
require.Equal(t, k.addr, addressOf(k.pub))
members, keys := newCommittee(t, 3)
rec := &EpochRecord{EpochInfo: fhe.EpochInfo{Committee: members}}
for i, key := range keys {
require.Truef(t, rec.memberOf(key.addr), "member %d must be recognised by its payer address", i)
}
require.False(t, rec.memberOf(newTestKey(t).addr), "a stranger is not a member")
}
// TestIdentifiersAreDeterministic proves every id F derives comes from signed
// transaction fields alone, so two validators applying one block agree — and
// that changing any input changes the id.
func TestIdentifiersAreDeterministic(t *testing.T) {
d := digestOf("determinism")
a, b := newTestKey(t).addr, newTestKey(t).addr
require.Equal(t, deriveHandle(d, testScheme), deriveHandle(d, testScheme))
require.NotEqual(t, deriveHandle(d, testScheme), deriveHandle(d, "bfv-n13"))
require.NotEqual(t, deriveHandle(d, testScheme), deriveHandle(digestOf("other"), testScheme))
h := deriveHandle(d, testScheme)
require.Equal(t, derivePermitID(h, a, b, 1, 0, 1), derivePermitID(h, a, b, 1, 0, 1))
require.NotEqual(t, derivePermitID(h, a, b, 1, 0, 1), derivePermitID(h, a, b, 1, 0, 2),
"a grantor may re-grant the same capability without colliding")
require.NotEqual(t, derivePermitID(h, a, b, 1, 0, 1), derivePermitID(h, b, a, 1, 0, 1))
require.Equal(t, deriveRequestID(h, a, 3), deriveRequestID(h, a, 3))
require.NotEqual(t, deriveRequestID(h, a, 3), deriveRequestID(h, a, 4))
require.NotEqual(t, deriveRequestID(h, a, 3), deriveRequestID(h, b, 3))
}
// TestCommitteeDigestIsSemantic proves the value members attest describes the
// PROPOSAL, not the bytes one client happened to encode: it changes with every
// meaningful field and with the members themselves.
func TestCommitteeDigestIsSemantic(t *testing.T) {
c, _ := newCommittee(t, 3)
pk := []byte("network-key")
base := committeeDigest(1, 2, pk, c)
require.Equal(t, base, committeeDigest(1, 2, pk, c))
require.NotEqual(t, base, committeeDigest(2, 2, pk, c), "epoch must bind")
require.NotEqual(t, base, committeeDigest(1, 3, pk, c), "threshold must bind")
require.NotEqual(t, base, committeeDigest(1, 2, []byte("other"), c), "network key must bind")
fewer := c[:2]
require.NotEqual(t, base, committeeDigest(1, 2, pk, fewer), "membership must bind")
// Length-prefixing means a member's fields cannot be re-split across the
// boundary to forge a colliding digest.
shifted := append([]fhe.CommitteeMember(nil), c...)
shifted[0].PublicKey = append(append([]byte(nil), c[0].PublicKey...), c[1].PublicKey...)
require.NotEqual(t, base, committeeDigest(1, 2, pk, shifted))
}
// TestTallyCountsDistinctMembers proves a threshold decision counts members,
// not messages: one member repeating itself never raises a count, and votes for
// different values are counted apart.
func TestTallyCountsDistinctMembers(t *testing.T) {
a, b := newTestKey(t).addr, newTestKey(t).addr
x, y := [32]byte{1}, [32]byte{2}
as := []Attestation{{Member: a, Value: x}, {Member: a, Value: x}}
require.Equal(t, 1, tally(as, x), "a repeated member counts once")
as = append(as, Attestation{Member: b, Value: x})
require.Equal(t, 2, tally(as, x))
require.Zero(t, tally(as, y), "votes for another value do not count")
require.True(t, attested(as, a))
require.False(t, attested(as, newTestKey(t).addr))
}
// TestEffectDistinguishesOperations proves the in-flight uniqueness key names
// the right thing: two votes by one member on one decision collide, votes by
// different members do not, and two grants by one grantor do not.
func TestEffectDistinguishesOperations(t *testing.T) {
k := newTestKey(t)
other := newTestKey(t)
req := [32]byte{7}
// One member cannot have two attestations to one request in flight, even
// naming different results — the effect is the vote, not the value.
v1 := fulfillTx(t, k, req, [32]byte{1}, 1)
v2 := fulfillTx(t, k, req, [32]byte{2}, 2)
require.Equal(t, v1.effect(), v2.effect())
// A different member voting on the same request is a different effect.
v3 := fulfillTx(t, other, req, [32]byte{1}, 1)
require.NotEqual(t, v1.effect(), v3.effect())
// Registering two different ciphertexts are different effects; registering
// the same one twice is the same effect.
r1 := registerTx(t, k, testScheme, digestOf("one"), 1)
r2 := registerTx(t, k, testScheme, digestOf("two"), 2)
r1again := registerTx(t, other, testScheme, digestOf("one"), 1)
require.NotEqual(t, r1.effect(), r2.effect())
require.Equal(t, r1.effect(), r1again.effect(), "the handle is the effect, whoever claims it")
// Two grants over the same handle by the same grantor are distinct — the
// nonce distinguishes the permits they create.
handle := deriveHandle(digestOf("g"), testScheme)
g1 := grantTx(t, k, handle, other.addr, fhe.PermitOpDecrypt, 0, 1)
g2 := grantTx(t, k, handle, other.addr, fhe.PermitOpDecrypt, 0, 2)
require.NotEqual(t, g1.effect(), g2.effect())
}
// TestTransactionIDIsContentHash proves the id is the hash of the canonical
// wire, so it changes with any field and cannot be chosen by the sender.
func TestTransactionIDIsContentHash(t *testing.T) {
k := newTestKey(t)
tx := registerTx(t, k, testScheme, digestOf("id"), 1)
first := tx.ID()
require.NotEqual(t, ids.Empty, first)
require.Equal(t, first, tx.ID(), "ID is stable once computed")
other := registerTx(t, k, testScheme, digestOf("id"), 2)
require.NotEqual(t, first, other.ID())
}
+899
View File
@@ -0,0 +1,899 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package fhevm implements the F-Chain: the coordination plane for
// confidential compute (LP-8200, LP-167). F records the PUBLIC coordinates of
// encrypted values — a handle, the digest of the off-chain ciphertext body, its
// owner, the capabilities granted over it, and the threshold decryptions asked
// for and answered — and it records nothing else. It holds no ciphertext body,
// no FHE secret key, and no decryption share; see state.go for the
// structurally-enforced invariant.
//
// The FHE runtime itself is github.com/luxfi/chains/mpcvm/fhe, and F reuses it
// rather than restating it: F's records embed the runtime's types, and F's
// public parameters come from the runtime's threshold configuration. What F
// does NOT reuse is the runtime's Registry, and the reason is consensus. The
// Registry stamps records with time.Now(), which 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 and diverge. So F owns its
// persistence, and every timestamp it writes comes from the accepting block.
//
// Mutating operations take effect only through fee-settled consensus blocks
// (block.go), priced by a per-scheme gas schedule (gas.go) and burned from the
// payer's on-chain balance via the native fee settlement primitive
// (github.com/luxfi/chains/fee).
package fhevm
import (
"context"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/rpc/v2"
grjson "github.com/gorilla/rpc/v2/json"
"github.com/luxfi/chains/fee"
"github.com/luxfi/chains/mpcvm/fhe"
"github.com/luxfi/database"
"github.com/luxfi/database/versiondb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
nodefee "github.com/luxfi/node/vms/types/fee"
"github.com/luxfi/timer/mockable"
vmcore "github.com/luxfi/vm"
"github.com/luxfi/vm/chain"
)
const (
// Version of the F-Chain VM.
Version = "1.0.0"
// VMName is the human-readable name of the F-Chain VM.
VMName = "fhevm"
// Database namespaces. Records are JSON; balances live under the fee
// ledger's own namespace (github.com/luxfi/chains/fee).
CiphertextPrefix = "ct:"
PermitPrefix = "pm:"
DecryptPrefix = "dr:"
EpochPrefix = "ep:"
BlockPrefix = "block:"
)
var (
lastAcceptedKey = []byte("fhevm/last-accepted")
genesisMarker = []byte("fhevm/genesis-applied")
currentEpochKey = []byte("fhevm/current-epoch")
noncePrefix = []byte("nonce:")
heightPrefix = []byte("height:")
)
// Verify VM implements the consensus ChainVM interface.
var _ chain.ChainVM = (*VM)(nil)
// Sentinel errors. Every one denies an operation — the package fails secure.
var (
errVMShutdown = errors.New("fhevm: shutting down")
errNoPendingTxs = errors.New("fhevm: no pending transactions")
errNoParentBlock = errors.New("fhevm: no parent block")
ErrInvalidTxType = errors.New("fhevm: invalid transaction type")
ErrInvalidPayload = errors.New("fhevm: invalid transaction payload")
ErrUnknownScheme = errors.New("fhevm: unsupported FHE scheme")
ErrInvalidThreshold = errors.New("fhevm: invalid threshold (need 0 < t <= n)")
ErrInvalidCommittee = errors.New("fhevm: invalid committee")
ErrHandleMismatch = errors.New("fhevm: subject does not match its payload")
ErrUnsignedTx = errors.New("fhevm: transaction missing payer auth/signature")
ErrPayerMismatch = errors.New("fhevm: payer does not match auth public key")
ErrBadSignature = errors.New("fhevm: invalid payer signature")
ErrCiphertextExists = errors.New("fhevm: ciphertext already registered")
ErrCiphertextNotFound = errors.New("fhevm: ciphertext not found")
ErrPermitNotFound = errors.New("fhevm: permit not found")
ErrPermitRevoked = errors.New("fhevm: permit is revoked")
ErrPermitExpired = errors.New("fhevm: permit expired")
ErrPermitInvalid = errors.New("fhevm: permit does not authorize this operation")
ErrRequestNotFound = errors.New("fhevm: decrypt request not found")
ErrRequestClosed = errors.New("fhevm: decrypt request already answered")
ErrRequestExpired = errors.New("fhevm: decrypt request expired")
ErrEpochNotFound = errors.New("fhevm: epoch not found")
ErrEpochMismatch = errors.New("fhevm: epoch is not the next one")
ErrNotCommittee = errors.New("fhevm: payer is not a committee member")
ErrAlreadyAttested = errors.New("fhevm: member already attested")
ErrUnauthorized = errors.New("fhevm: payer not authorized for operation")
// ErrBadNonce rejects a replayed or out-of-order transaction. A payer's
// transactions MUST carry strictly increasing nonces starting at 1; this is
// what stops a captured signed transaction from being resubmitted to drain
// the payer's balance through repeated fee burns.
ErrBadNonce = errors.New("fhevm: bad or replayed nonce")
// ErrDuplicateEffect rejects a second transaction that would bring about an
// effect another transaction in flight already claims. See
// Transaction.effect.
ErrDuplicateEffect = errors.New("fhevm: effect already claimed by a pending transaction")
)
// Config is the F-Chain VM's chain configuration, parsed from the node's
// per-chain config bytes. It carries only what cannot be derived: the network
// this chain belongs to.
type Config struct {
NetworkID uint32 `json:"networkId"`
}
// VM implements the F-Chain Virtual Machine.
//
// Every field below is either runtime plumbing or a cache of PUBLIC records
// (CiphertextRecord / PermitRecord / DecryptRecord / EpochRecord). There is
// deliberately no ciphertext store, no key-share store, and no FHE evaluation
// session: F coordinates confidential compute, it does not perform it.
type VM struct {
cancel context.CancelFunc
log log.Logger
versdb *versiondb.Database
state database.Database // == versdb; buffered writes commit per block
work vmcore.Latch
networkID uint32
chainID ids.ID
clock mockable.Clock
// PUBLIC state caches (authoritative copy lives in the DB).
stateLock sync.RWMutex
ciphertexts map[[32]byte]*CiphertextRecord
permits map[[32]byte]*PermitRecord
decrypts map[[32]byte]*DecryptRecord
epochs map[uint64]*EpochRecord
epoch uint64 // current epoch number
// Native fee balance ledger (debit + burn), backed by the VM's versiondb so
// settlement commits atomically with the operations it pays for.
ledger *fee.Ledger
// Admission policy (node/vms/types/fee). Orthogonal to settlement: this is
// the boot-time floor declaration Manager validates; the per-op burn is done
// through `ledger`.
feePolicy nodefee.Policy
// Consensus mempool + block bookkeeping. `pending` holds the effect of every
// queued transaction so admission can refuse a second claim on it.
mempoolLock sync.Mutex
mempool []*Transaction
pending map[[32]byte]struct{}
pendingBlocks map[ids.ID]*Block
lastAccepted ids.ID
lastBlock *Block
height uint64
rpcServer *rpc.Server
shutdownLock sync.RWMutex
shuttingDown bool
}
// Genesis is the F-Chain genesis: a funding allocation (address hex -> nLUX)
// and the epoch-0 threshold committee. There is no ciphertext in genesis —
// ciphertexts are registered through consensus transactions — so genesis
// carries nothing encrypted.
type Genesis struct {
Version int `json:"version"`
Message string `json:"message"`
Timestamp int64 `json:"timestamp"`
Alloc map[string]uint64 `json:"alloc"`
Committee []fhe.CommitteeMember `json:"committee"`
Threshold int `json:"threshold"`
PublicKey []byte `json:"publicKey"`
}
// Initialize wires the VM: database, ledger, fee policy, caches, genesis
// seeding, and the JSON-RPC service.
func (vm *VM) Initialize(ctx context.Context, init vmcore.Init) error {
_, vm.cancel = context.WithCancel(ctx)
vm.versdb = versiondb.New(init.DB)
vm.state = vm.versdb
if init.Runtime != nil {
if logger, ok := init.Runtime.Log.(log.Logger); ok {
vm.log = logger
}
}
if vm.log == nil {
if init.Log != nil {
vm.log = init.Log
} else {
vm.log = log.NewNoOpLogger()
}
}
var cfg Config
if len(init.Config) > 0 {
if err := json.Unmarshal(init.Config, &cfg); err != nil {
return fmt.Errorf("fhevm: parse config: %w", err)
}
}
vm.stateLock.Lock()
vm.ciphertexts = make(map[[32]byte]*CiphertextRecord)
vm.permits = make(map[[32]byte]*PermitRecord)
vm.decrypts = make(map[[32]byte]*DecryptRecord)
vm.epochs = make(map[uint64]*EpochRecord)
vm.stateLock.Unlock()
vm.pendingBlocks = make(map[ids.ID]*Block)
vm.pending = make(map[[32]byte]struct{})
if init.Runtime != nil {
vm.networkID = init.Runtime.NetworkID
vm.chainID = init.Runtime.ChainID
}
if cfg.NetworkID != 0 {
vm.networkID = cfg.NetworkID
}
vm.ledger = fee.NewLedger(vm.versdb)
vm.feePolicy = newFeePolicy(vm.networkID)
if err := nodefee.Validate(vm.feePolicy); err != nil {
return fmt.Errorf("fhevm: fee policy: %w", err)
}
genesis := &Genesis{}
if len(init.Genesis) > 0 {
if err := json.Unmarshal(init.Genesis, genesis); err != nil {
return fmt.Errorf("fhevm: parse genesis: %w", err)
}
}
// Genesis block at height 0.
genesisBlock := &Block{
id: ids.Empty,
parentID: ids.Empty,
height: 0,
timestamp: time.Unix(genesis.Timestamp, 0),
vm: vm,
}
genesisBlock.id = genesisBlock.computeID()
vm.lastAccepted = genesisBlock.id
vm.lastBlock = genesisBlock
if err := vm.seedGenesis(genesis, genesisBlock.id); err != nil {
return fmt.Errorf("fhevm: seed genesis: %w", err)
}
if err := vm.loadState(); err != nil {
return fmt.Errorf("fhevm: load state: %w", err)
}
if err := vm.initHTTP(); err != nil {
return fmt.Errorf("fhevm: init http: %w", err)
}
vm.log.Info("F-Chain initialized",
log.String("version", Version),
log.Uint32("networkID", vm.networkID),
log.Uint64("height", vm.height),
log.Uint64("epoch", vm.epoch),
)
return nil
}
// seedGenesis credits the funding allocation and installs the epoch-0
// committee, once (idempotent via a marker key). It is the only trusted state
// mutation; all later mutations go through blocks.
func (vm *VM) seedGenesis(g *Genesis, genesisBlockID ids.ID) error {
applied, err := vm.versdb.Has(genesisMarker)
if err != nil {
return err
}
if applied {
return nil
}
for addrHex, amount := range g.Alloc {
acct, err := accountFromHex(addrHex)
if err != nil {
return fmt.Errorf("alloc %q: %w", addrHex, err)
}
if err := vm.ledger.Credit(acct, amount); err != nil {
return fmt.Errorf("alloc %q: %w", addrHex, err)
}
}
// A committee in genesis goes through exactly the check TxAdvanceEpoch
// applies, so a chain cannot be born with a committee that consensus would
// refuse to install later.
if len(g.Committee) > 0 {
if err := ValidateCommittee(g.Committee, g.Threshold, g.PublicKey); err != nil {
return err
}
rec := &EpochRecord{EpochInfo: fhe.EpochInfo{
Epoch: 0,
StartTime: g.Timestamp,
Committee: g.Committee,
Threshold: g.Threshold,
PublicKey: g.PublicKey,
Status: fhe.EpochActive,
}}
if err := vm.writeEpoch(rec); err != nil {
return err
}
if err := vm.state.Put(currentEpochKey, encodeU64(0)); err != nil {
return err
}
}
if err := vm.state.Put(heightKey(0), genesisBlockID[:]); err != nil {
return err
}
if err := vm.versdb.Put(genesisMarker, []byte{1}); err != nil {
return err
}
return vm.versdb.Commit()
}
// loadState rebuilds the PUBLIC caches and lastAccepted pointer from the DB.
func (vm *VM) loadState() error {
vm.stateLock.Lock()
defer vm.stateLock.Unlock()
return vm.loadStateLocked()
}
// loadStateLocked is loadState's body; callers must hold stateLock. The block
// Accept error path uses it to reload caches after a versiondb Abort while it
// still holds the lock.
func (vm *VM) loadStateLocked() error {
vm.ciphertexts = make(map[[32]byte]*CiphertextRecord)
vm.permits = make(map[[32]byte]*PermitRecord)
vm.decrypts = make(map[[32]byte]*DecryptRecord)
vm.epochs = make(map[uint64]*EpochRecord)
vm.epoch = 0
if err := loadInto(vm, CiphertextPrefix, vm.ciphertexts, func(r *CiphertextRecord) [32]byte { return r.Handle }); err != nil {
return err
}
if err := loadInto(vm, PermitPrefix, vm.permits, func(r *PermitRecord) [32]byte { return r.PermitID }); err != nil {
return err
}
if err := loadInto(vm, DecryptPrefix, vm.decrypts, func(r *DecryptRecord) [32]byte { return r.RequestID }); err != nil {
return err
}
if err := loadInto(vm, EpochPrefix, vm.epochs, func(r *EpochRecord) uint64 { return r.Epoch }); err != nil {
return err
}
if b, err := vm.state.Get(currentEpochKey); err == nil && len(b) == 8 {
vm.epoch = binary.BigEndian.Uint64(b)
}
if b, err := vm.state.Get(lastAcceptedKey); err == nil && len(b) == 32 {
copy(vm.lastAccepted[:], b)
if blk, err := vm.getBlockLocked(vm.lastAccepted); err == nil {
vm.lastBlock = blk
vm.height = blk.height
}
}
return nil
}
// loadInto reads every JSON record under prefix into dst, keyed by key(record).
// One iteration shape serves all four record types, so a new record kind cannot
// be loaded a subtly different way from the others.
func loadInto[K comparable, R any](vm *VM, prefix string, dst map[K]*R, key func(*R) K) error {
it := vm.state.NewIteratorWithPrefix([]byte(prefix))
defer it.Release()
for it.Next() {
var rec R
if err := json.Unmarshal(it.Value(), &rec); err != nil {
vm.log.Warn("fhevm: skip corrupt record",
log.String("prefix", prefix),
log.String("error", err.Error()))
continue
}
r := rec
dst[key(&r)] = &r
}
return it.Error()
}
// ---- PUBLIC state accessors (used by tx checkAuth/Apply, under stateLock) ----
func (vm *VM) getCiphertext(handle [32]byte) (*CiphertextRecord, bool) {
r, ok := vm.ciphertexts[handle]
return r, ok
}
func (vm *VM) putCiphertext(rec *CiphertextRecord) error {
if err := writeRecord(vm, CiphertextPrefix, rec.Handle, rec); err != nil {
return err
}
vm.ciphertexts[rec.Handle] = rec
return nil
}
func (vm *VM) getPermit(id [32]byte) (*PermitRecord, bool) {
r, ok := vm.permits[id]
return r, ok
}
func (vm *VM) putPermit(rec *PermitRecord) error {
if err := writeRecord(vm, PermitPrefix, rec.PermitID, rec); err != nil {
return err
}
vm.permits[rec.PermitID] = rec
return nil
}
func (vm *VM) getDecrypt(id [32]byte) (*DecryptRecord, bool) {
r, ok := vm.decrypts[id]
return r, ok
}
func (vm *VM) putDecrypt(rec *DecryptRecord) error {
if err := writeRecord(vm, DecryptPrefix, rec.RequestID, rec); err != nil {
return err
}
vm.decrypts[rec.RequestID] = rec
return nil
}
func (vm *VM) getEpoch(n uint64) (*EpochRecord, bool) {
r, ok := vm.epochs[n]
return r, ok
}
func (vm *VM) putEpoch(rec *EpochRecord) error {
if err := vm.writeEpoch(rec); err != nil {
return err
}
vm.epochs[rec.Epoch] = rec
return nil
}
func (vm *VM) writeEpoch(rec *EpochRecord) error {
data, err := json.Marshal(rec)
if err != nil {
return err
}
return vm.state.Put(append([]byte(EpochPrefix), encodeU64(rec.Epoch)...), data)
}
// currentEpoch returns the sitting epoch. A chain whose genesis declared no
// committee has an epoch 0 with no members, which authorizes nobody — so
// fulfilment and epoch advance are refused until a committee exists, rather
// than accepted from anyone. Caller holds stateLock.
func (vm *VM) currentEpoch() *EpochRecord {
if r, ok := vm.epochs[vm.epoch]; ok {
return r
}
return &EpochRecord{EpochInfo: fhe.EpochInfo{Epoch: vm.epoch, Status: fhe.EpochActive}}
}
func (vm *VM) setCurrentEpoch(n uint64) error {
if err := vm.state.Put(currentEpochKey, encodeU64(n)); err != nil {
return err
}
vm.epoch = n
return nil
}
func writeRecord[R any](vm *VM, prefix string, id [32]byte, rec *R) error {
data, err := json.Marshal(rec)
if err != nil {
return err
}
return vm.state.Put(append([]byte(prefix), id[:]...), data)
}
// nonceOf returns the payer's last-used nonce (0 if the account has never
// transacted). The next valid nonce is nonceOf(payer)+1. Caller holds a lock.
func (vm *VM) nonceOf(payer fee.Account) uint64 {
key := append(append([]byte{}, noncePrefix...), payer[:]...)
b, err := vm.state.Get(key)
if err != nil || len(b) != 8 {
return 0
}
return binary.BigEndian.Uint64(b)
}
// setNonce records the payer's last-used nonce (writes to the versiondb, so it
// commits atomically with the block). Caller holds stateLock.
func (vm *VM) setNonce(payer fee.Account, n uint64) error {
key := append(append([]byte{}, noncePrefix...), payer[:]...)
return vm.state.Put(key, encodeU64(n))
}
func encodeU64(v uint64) []byte {
var u [8]byte
binary.BigEndian.PutUint64(u[:], v)
return u[:]
}
func heightKey(h uint64) []byte {
return append(append([]byte{}, heightPrefix...), encodeU64(h)...)
}
// ---- Read-only public queries (RPC) ----
// Ciphertext returns a copy of a ciphertext record.
func (vm *VM) Ciphertext(handle [32]byte) (*CiphertextRecord, bool) {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
r, ok := vm.ciphertexts[handle]
if !ok {
return nil, false
}
c := *r
return &c, true
}
// Ciphertexts returns copies of all ciphertext records.
func (vm *VM) Ciphertexts() []*CiphertextRecord {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
out := make([]*CiphertextRecord, 0, len(vm.ciphertexts))
for _, r := range vm.ciphertexts {
c := *r
out = append(out, &c)
}
return out
}
// Permit returns a copy of a permit record.
func (vm *VM) Permit(id [32]byte) (*PermitRecord, bool) {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
r, ok := vm.permits[id]
if !ok {
return nil, false
}
c := *r
return &c, true
}
// Decrypt returns a copy of a decryption request record.
func (vm *VM) Decrypt(id [32]byte) (*DecryptRecord, bool) {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
r, ok := vm.decrypts[id]
if !ok {
return nil, false
}
c := *r
c.Attestations = append([]Attestation(nil), r.Attestations...)
return &c, true
}
// Epoch returns a copy of an epoch record.
func (vm *VM) Epoch(n uint64) (*EpochRecord, bool) {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
r, ok := vm.epochs[n]
if !ok {
return nil, false
}
c := *r
c.Committee = append([]fhe.CommitteeMember(nil), r.Committee...)
c.Attestations = append([]Attestation(nil), r.Attestations...)
return &c, true
}
// CurrentEpoch returns the sitting epoch number.
func (vm *VM) CurrentEpoch() uint64 {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
return vm.epoch
}
// Balance returns an account's spendable nLUX.
func (vm *VM) Balance(acct fee.Account) (uint64, error) {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
return vm.ledger.Balance(acct)
}
// Burned returns cumulative burned supply in nLUX.
func (vm *VM) Burned() (uint64, error) {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
return vm.ledger.Burned()
}
// ---- Mempool / consensus driver ----
// SubmitTx validates, authenticates, and admission-checks a transaction, then
// enqueues it and signals the engine to build a block. The fee is SETTLED
// later, in block Accept — never here. Returns the transaction ID.
func (vm *VM) SubmitTx(tx *Transaction) (ids.ID, error) {
if err := tx.SyntacticVerify(); err != nil {
return ids.Empty, err
}
if err := tx.authenticate(); err != nil {
return ids.Empty, err
}
feeAmt, err := FeeFor(tx)
if err != nil {
return ids.Empty, err
}
vm.stateLock.RLock()
if tx.Nonce <= vm.nonceOf(tx.Payer) {
vm.stateLock.RUnlock()
return ids.Empty, ErrBadNonce
}
if err := tx.checkAuth(vm, vm.clock.Time().Unix()); err != nil {
vm.stateLock.RUnlock()
return ids.Empty, err
}
err = fee.CanPay(vm.ledger, tx.Payer, feeAmt)
vm.stateLock.RUnlock()
if err != nil {
return ids.Empty, err
}
// Refuse a second claim on an effect already queued. Without this a payer
// could enqueue two transactions that each pass every check alone and
// together abort the block that carries them.
eff := tx.effect()
vm.mempoolLock.Lock()
if _, dup := vm.pending[eff]; dup {
vm.mempoolLock.Unlock()
return ids.Empty, ErrDuplicateEffect
}
vm.pending[eff] = struct{}{}
vm.mempool = append(vm.mempool, tx)
vm.mempoolLock.Unlock()
vm.work.Signal()
return tx.ID(), nil
}
// WaitForEvent blocks until there are pending transactions or the VM stops.
func (vm *VM) WaitForEvent(ctx context.Context) (vmcore.Message, error) {
return vm.work.WaitForEvent(ctx)
}
// BuildBlock drains the mempool into a new block extending the last accepted
// block. The block is not yet verified or accepted — settlement happens in
// Verify/Accept.
func (vm *VM) BuildBlock(ctx context.Context) (chain.Block, error) {
vm.shutdownLock.RLock()
down := vm.shuttingDown
vm.shutdownLock.RUnlock()
if down {
return nil, errVMShutdown
}
vm.mempoolLock.Lock()
txs := vm.mempool
vm.mempool = nil
vm.mempoolLock.Unlock()
if len(txs) == 0 {
return nil, errNoPendingTxs
}
vm.stateLock.RLock()
parent := vm.lastBlock
parentID := vm.lastAccepted
vm.stateLock.RUnlock()
if parent == nil {
vm.requeue(txs)
return nil, errNoParentBlock
}
blk := &Block{
parentID: parentID,
height: parent.height + 1,
timestamp: vm.clock.Time(),
transactions: txs,
vm: vm,
}
blk.id = blk.computeID()
vm.shutdownLock.Lock()
vm.pendingBlocks[blk.id] = blk
vm.shutdownLock.Unlock()
return blk, nil
}
// requeue returns transactions to the front of the mempool (on build/reject).
// Their effects stay claimed — they are still in flight.
func (vm *VM) requeue(txs []*Transaction) {
if len(txs) == 0 {
return
}
vm.mempoolLock.Lock()
vm.mempool = append(txs, vm.mempool...)
vm.mempoolLock.Unlock()
vm.work.Signal()
}
// dropFromMempool removes accepted transactions from the mempool by ID and
// releases the effects they claimed — they have taken place, so any later
// transaction claiming the same effect is now refused by checkAuth against
// committed state instead.
func (vm *VM) dropFromMempool(txs []*Transaction) {
if len(txs) == 0 {
return
}
accepted := make(map[ids.ID]struct{}, len(txs))
for _, tx := range txs {
accepted[tx.ID()] = struct{}{}
}
vm.mempoolLock.Lock()
kept := vm.mempool[:0]
for _, tx := range vm.mempool {
if _, ok := accepted[tx.ID()]; !ok {
kept = append(kept, tx)
}
}
vm.mempool = kept
for _, tx := range txs {
delete(vm.pending, tx.effect())
}
vm.mempoolLock.Unlock()
}
// ---- Block storage ----
// ParseBlock decodes a block from bytes.
func (vm *VM) ParseBlock(ctx context.Context, blockBytes []byte) (chain.Block, error) {
return parseBlock(vm, blockBytes)
}
// GetBlock returns a block by ID.
func (vm *VM) GetBlock(ctx context.Context, blockID ids.ID) (chain.Block, error) {
vm.shutdownLock.RLock()
defer vm.shutdownLock.RUnlock()
return vm.getBlockLocked(blockID)
}
func (vm *VM) getBlockLocked(blockID ids.ID) (*Block, error) {
if vm.pendingBlocks != nil {
if blk, ok := vm.pendingBlocks[blockID]; ok {
return blk, nil
}
}
if vm.lastBlock != nil && vm.lastBlock.id == blockID {
return vm.lastBlock, nil
}
b, err := vm.state.Get(append([]byte(BlockPrefix), blockID[:]...))
if err != nil {
return nil, fmt.Errorf("fhevm: block %s: %w", blockID, err)
}
return parseBlock(vm, b)
}
// ---- ChainVM lifecycle / misc ----
func (vm *VM) SetState(ctx context.Context, state uint32) error { return nil }
func (vm *VM) SetPreference(ctx context.Context, id ids.ID) error { return nil }
func (vm *VM) LastAccepted(ctx context.Context) (ids.ID, error) {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
return vm.lastAccepted, nil
}
// GetBlockIDAtHeight answers from the height index Accept writes in the same
// commit as the block itself, so the index cannot name a block the chain did
// not accept.
func (vm *VM) GetBlockIDAtHeight(ctx context.Context, height uint64) (ids.ID, error) {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
b, err := vm.state.Get(heightKey(height))
if err != nil {
return ids.Empty, fmt.Errorf("fhevm: height %d: %w", height, err)
}
if len(b) != 32 {
return ids.Empty, fmt.Errorf("fhevm: height %d: %w: %d-byte index entry", height, ErrInvalidPayload, len(b))
}
return ids.ID(b), nil
}
func (vm *VM) NewHTTPHandler(ctx context.Context) (http.Handler, error) {
handlers, err := vm.CreateHandlers(ctx)
if err != nil {
return nil, err
}
mux := http.NewServeMux()
for path, h := range handlers {
if path == "" {
path = "/"
}
mux.Handle(path, h)
}
return mux, nil
}
func (vm *VM) CreateHandlers(ctx context.Context) (map[string]http.Handler, error) {
return map[string]http.Handler{"/rpc": vm.rpcServer}, nil
}
func (vm *VM) CreateStaticHandlers(ctx context.Context) (map[string]http.Handler, error) {
return nil, nil
}
func (vm *VM) Version(ctx context.Context) (string, error) { return Version, nil }
func (vm *VM) Connected(ctx context.Context, nodeID ids.NodeID, ver *chain.VersionInfo) error {
return nil
}
func (vm *VM) Disconnected(ctx context.Context, nodeID ids.NodeID) error { return nil }
func (vm *VM) HealthCheck(ctx context.Context) (chain.HealthResult, error) {
vm.shutdownLock.RLock()
down := vm.shuttingDown
vm.shutdownLock.RUnlock()
vm.stateLock.RLock()
cts := len(vm.ciphertexts)
permits := len(vm.permits)
decrypts := len(vm.decrypts)
epoch := vm.currentEpoch()
committee := len(epoch.Committee)
threshold := epoch.Threshold
vm.stateLock.RUnlock()
burned, _ := vm.Burned()
return chain.HealthResult{
// A committee is what makes F answerable: with none seated, decryptions
// can be requested but never fulfilled, which is a degraded chain and is
// reported as such rather than as healthy.
Healthy: !down && committee > 0,
Details: map[string]string{
"version": Version,
"ciphertexts": fmt.Sprintf("%d", cts),
"permits": fmt.Sprintf("%d", permits),
"decrypts": fmt.Sprintf("%d", decrypts),
"epoch": fmt.Sprintf("%d", epoch.Epoch),
"committee": fmt.Sprintf("%d", committee),
"threshold": fmt.Sprintf("%d", threshold),
"height": fmt.Sprintf("%d", vm.height),
"burnedNLUX": fmt.Sprintf("%d", burned),
},
}, nil
}
// Shutdown stops the VM. There is no secret material to zero — by construction
// the VM never held any.
func (vm *VM) Shutdown(ctx context.Context) error {
vm.shutdownLock.Lock()
vm.shuttingDown = true
vm.shutdownLock.Unlock()
if vm.cancel != nil {
vm.cancel()
}
if vm.versdb != nil {
if err := vm.versdb.Close(); err != nil {
vm.log.Error("fhevm: close db", log.String("error", err.Error()))
}
}
vm.log.Info("F-Chain shut down")
return nil
}
func (vm *VM) initHTTP() error {
vm.rpcServer = rpc.NewServer()
vm.rpcServer.RegisterCodec(grjson.NewCodec(), "application/json")
vm.rpcServer.RegisterCodec(grjson.NewCodec(), "application/json;charset=UTF-8")
return vm.rpcServer.RegisterService(&Service{vm: vm}, "fchain")
}
// accountFromHex parses a 20-byte hex address into a fee.Account.
func accountFromHex(s string) (fee.Account, error) {
var a fee.Account
b, err := hex.DecodeString(strings.TrimPrefix(s, "0x"))
if err != nil {
return a, err
}
if len(b) != ids.ShortIDLen {
return a, fmt.Errorf("address must be %d bytes, got %d", ids.ShortIDLen, len(b))
}
copy(a[:], b)
return a, nil
}
+402
View File
@@ -0,0 +1,402 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"context"
"crypto"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"sort"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/chains/fee"
"github.com/luxfi/chains/mpcvm/fhe"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
vmcore "github.com/luxfi/vm"
)
// ---- shared test helpers ----
// testGas is a gas ceiling above every scheduled operation, so a test that is
// not about metering never trips the limit.
const testGas = uint64(500_000)
// testFund is enough for many operations at the scheduled prices.
const testFund = uint64(10_000_000_000)
// testScheme is the scheme most tests price against.
const testScheme = "ckks-n14"
// testKey is an external identity: a payer, a grantee, or a committee member.
// The ML-DSA-65 private key lives ONLY in the test, exercising the public-key
// authentication path on the VM side.
type testKey struct {
priv *mldsa.PrivateKey
pub []byte
addr fee.Account
}
func newTestKey(t *testing.T) testKey {
t.Helper()
priv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(t, err)
pub := priv.PublicKey.Bytes()
return testKey{priv: priv, pub: pub, addr: addressOf(pub)}
}
func (k testKey) hexAddr() string { return hex.EncodeToString(k.addr[:]) }
// sign attaches the payer's public key and a valid signature over the tx's
// signing bytes, then clears the cached id so ID() recomputes.
func (k testKey) sign(t *testing.T, tx *Transaction) *Transaction {
t.Helper()
tx.Auth = k.pub
sig, err := k.priv.Sign(rand.Reader, tx.SigningBytes(), crypto.Hash(0))
require.NoError(t, err)
tx.Sig = sig
tx.id = ids.Empty
return tx
}
// newCommittee builds n committee members in canonical node-ID order, each
// carrying a real ML-DSA-65 public key, and returns the members alongside the
// keys that can sign for them (index-aligned).
func newCommittee(t *testing.T, n int) ([]fhe.CommitteeMember, []testKey) {
t.Helper()
type pair struct {
m fhe.CommitteeMember
k testKey
}
pairs := make([]pair, n)
for i := range pairs {
k := newTestKey(t)
pairs[i] = pair{
m: fhe.CommitteeMember{NodeID: ids.GenerateTestNodeID(), PublicKey: k.pub, Weight: 1},
k: k,
}
}
sort.Slice(pairs, func(i, j int) bool {
return pairs[i].m.NodeID.Compare(pairs[j].m.NodeID) < 0
})
members := make([]fhe.CommitteeMember, n)
keys := make([]testKey, n)
for i, p := range pairs {
p.m.Index = i
members[i] = p.m
keys[i] = p.k
}
return members, keys
}
// newTestVM initializes an in-memory F-Chain seeded with the given funding
// allocation (hex address -> nLUX) and epoch-0 committee.
func newTestVM(t *testing.T, alloc map[string]uint64, committee []fhe.CommitteeMember, threshold int) *VM {
t.Helper()
logger := log.NewNoOpLogger()
g := Genesis{
Version: 1,
Timestamp: time.Now().Unix(),
Alloc: alloc,
Committee: committee,
Threshold: threshold,
}
if len(committee) > 0 {
g.PublicKey = []byte("network-fhe-public-key")
}
gb, err := json.Marshal(g)
require.NoError(t, err)
rt := &runtime.Runtime{ChainID: ids.GenerateTestID(), NetworkID: 96369, Log: logger}
vm := &VM{}
require.NoError(t, vm.Initialize(context.Background(), vmcore.Init{
Runtime: rt,
DB: memdb.New(),
ToEngine: make(chan vmcore.Message, 8),
Log: logger,
Genesis: gb,
}))
t.Cleanup(func() { _ = vm.Shutdown(context.Background()) })
return vm
}
// ---- transaction builders ----
func digestOf(s string) [32]byte { return sha256.Sum256([]byte(s)) }
// hexOf renders a 32-byte identifier the way the RPC surface takes it.
func hexOf(v [32]byte) string { return hex.EncodeToString(v[:]) }
func registerTx(t *testing.T, k testKey, scheme string, digest [32]byte, nonce uint64) *Transaction {
t.Helper()
return k.sign(t, &Transaction{
Type: TxRegisterCiphertext,
Scheme: scheme,
Payer: k.addr,
Subject: deriveHandle(digest, scheme),
GasLimit: testGas,
Nonce: nonce,
Payload: mustJSON(t, RegisterPayload{
Digest: digest, Type: 4, Level: 3, Size: 4096,
}),
})
}
func grantTx(t *testing.T, owner testKey, handle [32]byte, grantee fee.Account, ops uint32, expiry int64, nonce uint64) *Transaction {
t.Helper()
return owner.sign(t, &Transaction{
Type: TxGrantPermit,
Payer: owner.addr,
Subject: handle,
GasLimit: testGas,
Nonce: nonce,
Payload: mustJSON(t, GrantPayload{Grantee: grantee, Operations: ops, Expiry: expiry}),
})
}
func revokeTx(t *testing.T, k testKey, permitID [32]byte, nonce uint64) *Transaction {
t.Helper()
return k.sign(t, &Transaction{
Type: TxRevokePermit,
Payer: k.addr,
Subject: permitID,
GasLimit: testGas,
Nonce: nonce,
Payload: mustJSON(t, RevokePayload{Reason: "no longer sanctioned"}),
})
}
func requestTx(t *testing.T, k testKey, scheme string, handle, permitID [32]byte, expiry int64, nonce uint64) *Transaction {
t.Helper()
return k.sign(t, &Transaction{
Type: TxRequestDecrypt,
Scheme: scheme,
Payer: k.addr,
Subject: handle,
GasLimit: testGas,
Nonce: nonce,
Payload: mustJSON(t, RequestPayload{
PermitID: permitID,
Callback: [20]byte{0xca, 0x11},
Selector: [4]byte{1, 2, 3, 4},
Expiry: expiry,
}),
})
}
func fulfillTx(t *testing.T, k testKey, requestID, result [32]byte, nonce uint64) *Transaction {
t.Helper()
return k.sign(t, &Transaction{
Type: TxFulfillDecrypt,
Payer: k.addr,
Subject: requestID,
GasLimit: testGas,
Nonce: nonce,
Payload: mustJSON(t, FulfillPayload{Result: result}),
})
}
func advanceTx(t *testing.T, k testKey, epoch uint64, committee []fhe.CommitteeMember, threshold int, pk []byte, nonce uint64) *Transaction {
t.Helper()
return k.sign(t, &Transaction{
Type: TxAdvanceEpoch,
Payer: k.addr,
Subject: committeeDigest(epoch, threshold, pk, committee),
GasLimit: testGas,
Nonce: nonce,
Payload: mustJSON(t, AdvancePayload{
Epoch: epoch, Committee: committee, Threshold: threshold, PublicKey: pk,
}),
})
}
// acceptOne submits, builds, verifies, and accepts a single-tx block.
func acceptOne(t *testing.T, vm *VM, tx *Transaction) {
t.Helper()
_, err := vm.SubmitTx(tx)
require.NoError(t, err)
acceptQueued(t, vm)
}
// acceptQueued builds, verifies, and accepts whatever is queued.
func acceptQueued(t *testing.T, vm *VM) *Block {
t.Helper()
blkIntf, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
blk := blkIntf.(*Block)
require.NoError(t, blk.Verify(context.Background()))
require.NoError(t, blk.Accept(context.Background()))
return blk
}
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
require.NoError(t, err)
return b
}
// timeAt is a fixed block timestamp, so a test that serializes one gets the
// same bytes on every run.
func timeAt(sec int64) time.Time { return time.Unix(sec, 0) }
// ---- VM lifecycle ----
func TestVMInitialize(t *testing.T) {
committee, _ := newCommittee(t, 3)
vm := newTestVM(t, nil, committee, 2)
v, err := vm.Version(context.Background())
require.NoError(t, err)
require.Equal(t, Version, v)
h, err := vm.HealthCheck(context.Background())
require.NoError(t, err)
require.True(t, h.Healthy)
require.Equal(t, "3", h.Details["committee"])
require.Equal(t, "2", h.Details["threshold"])
require.Equal(t, "0", h.Details["epoch"])
ep, ok := vm.Epoch(0)
require.True(t, ok, "genesis must seat epoch 0")
require.Len(t, ep.Committee, 3)
require.Equal(t, uint64(0), vm.CurrentEpoch())
}
// TestVMWithoutCommitteeIsUnhealthy proves a chain with nobody seated reports
// itself degraded rather than healthy: decryptions could be requested but never
// answered.
func TestVMWithoutCommitteeIsUnhealthy(t *testing.T) {
vm := newTestVM(t, nil, nil, 0)
h, err := vm.HealthCheck(context.Background())
require.NoError(t, err)
require.False(t, h.Healthy)
require.Equal(t, "0", h.Details["committee"])
}
// TestGenesisRejectsUnusableCommittee proves genesis goes through the same
// committee check as TxAdvanceEpoch, so a chain cannot be born holding a
// committee consensus would refuse to install.
func TestGenesisRejectsUnusableCommittee(t *testing.T) {
logger := log.NewNoOpLogger()
bad := []fhe.CommitteeMember{{NodeID: ids.GenerateTestNodeID(), PublicKey: []byte("not-an-mldsa-key")}}
gb, err := json.Marshal(Genesis{
Version: 1, Timestamp: time.Now().Unix(),
Committee: bad, Threshold: 1, PublicKey: []byte("pk"),
})
require.NoError(t, err)
vm := &VM{}
err = vm.Initialize(context.Background(), vmcore.Init{
Runtime: &runtime.Runtime{ChainID: ids.GenerateTestID(), NetworkID: 96369, Log: logger},
DB: memdb.New(),
ToEngine: make(chan vmcore.Message, 8),
Log: logger,
Genesis: gb,
})
require.ErrorIs(t, err, ErrInvalidCommittee)
}
// TestHeightIndex proves GetBlockIDAtHeight answers from an index written in
// the same commit as the block, and refuses a height the chain never reached.
func TestHeightIndex(t *testing.T) {
k := newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, map[string]uint64{k.hexAddr(): testFund}, committee, 1)
genesisID, err := vm.GetBlockIDAtHeight(context.Background(), 0)
require.NoError(t, err)
require.Equal(t, vm.lastAccepted, genesisID, "height 0 must name the genesis block")
acceptOne(t, vm, registerTx(t, k, testScheme, digestOf("a"), 1))
id1, err := vm.GetBlockIDAtHeight(context.Background(), 1)
require.NoError(t, err)
require.Equal(t, vm.lastAccepted, id1)
acceptOne(t, vm, registerTx(t, k, testScheme, digestOf("b"), 2))
id2, err := vm.GetBlockIDAtHeight(context.Background(), 2)
require.NoError(t, err)
require.Equal(t, vm.lastAccepted, id2)
require.NotEqual(t, id1, id2)
// A height the chain never reached is an error, not a zero id.
_, err = vm.GetBlockIDAtHeight(context.Background(), 99)
require.Error(t, err)
}
// TestBlockRejectRequeues proves a rejected block returns its transactions to
// the mempool and leaves state untouched.
func TestBlockRejectRequeues(t *testing.T) {
k := newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, map[string]uint64{k.hexAddr(): testFund}, committee, 1)
tx := registerTx(t, k, testScheme, digestOf("rejected"), 1)
_, err := vm.SubmitTx(tx)
require.NoError(t, err)
blkIntf, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
blk := blkIntf.(*Block)
require.Empty(t, vm.mempool, "building drains the mempool")
require.NoError(t, blk.Reject(context.Background()))
require.Len(t, vm.mempool, 1, "reject must requeue")
_, ok := vm.Ciphertext(tx.Subject)
require.False(t, ok, "a rejected block must apply nothing")
burned, _ := vm.Burned()
require.Zero(t, burned, "a rejected block must burn nothing")
// The requeued transaction still works in a later block.
acceptQueued(t, vm)
_, ok = vm.Ciphertext(tx.Subject)
require.True(t, ok)
}
// TestBlockStatus proves a block reports processing until it is accepted.
func TestBlockStatus(t *testing.T) {
k := newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, map[string]uint64{k.hexAddr(): testFund}, committee, 1)
_, err := vm.SubmitTx(registerTx(t, k, testScheme, digestOf("s"), 1))
require.NoError(t, err)
blkIntf, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
blk := blkIntf.(*Block)
require.Equal(t, uint8(0), blk.Status())
require.NoError(t, blk.Verify(context.Background()))
require.NoError(t, blk.Accept(context.Background()))
require.Equal(t, uint8(1), blk.Status())
}
// TestParseBlockRoundTrip proves an accepted block re-parses from its stored
// bytes with the same id and the same transactions.
func TestParseBlockRoundTrip(t *testing.T) {
k := newTestKey(t)
committee, _ := newCommittee(t, 1)
vm := newTestVM(t, map[string]uint64{k.hexAddr(): testFund}, committee, 1)
tx := registerTx(t, k, testScheme, digestOf("rt"), 1)
acceptOne(t, vm, tx)
blk := vm.lastBlock
parsed, err := vm.ParseBlock(context.Background(), blk.Bytes())
require.NoError(t, err)
require.Equal(t, blk.ID(), parsed.ID())
require.Equal(t, blk.Height(), parsed.Height())
require.Equal(t, blk.ParentID(), parsed.ParentID())
got, err := vm.GetBlock(context.Background(), blk.ID())
require.NoError(t, err)
require.Equal(t, blk.ID(), got.ID())
}
+64
View File
@@ -0,0 +1,64 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"testing"
"github.com/luxfi/constants"
"github.com/luxfi/ids"
)
// canonicalVMIDCB58 is F-Chain's vmID in the encoding the node's plugin
// registry uses. The plugin binary MUST be installed under exactly this
// filename; the registry resolves a CreateChainTx's vmID to an implementation
// by looking for a file with this name in the plugin directory.
//
// It is written out literally rather than computed so that a change to the
// vmID — for any reason, including an "obviously equivalent" refactor of the
// constant — fails this test instead of silently producing a chain that no
// node can start. A vmID is an immutable one-way door: it is baked into the
// genesis CreateChainTx and stored by the P-Chain forever.
const canonicalVMIDCB58 = "n6sSsSfbpQBrU9sY4R29U6z8VrmnTo2CntW6da4rRS7qmnGdv"
func TestVMID_IsCanonicalAndStable(t *testing.T) {
if VMID != constants.FHEVMID {
t.Fatalf("VMID = %s, want constants.FHEVMID = %s", VMID, constants.FHEVMID)
}
if got := VMID.String(); got != canonicalVMIDCB58 {
t.Fatalf("VMID CB58 = %q, want %q\n"+
"A vmID cannot change after a chain is created with it. If this is an\n"+
"intentional pre-launch change, every declaration must move together:\n"+
" luxfi/constants vm_ids.go FHEVMID\n"+
" node/node vms.go OptionalVMs\n"+
" node/Dockerfile plugin build -o, verify list, runtime COPY\n"+
" chains/fhevm factory.go, cmd/plugin/main.go, this test",
got, canonicalVMIDCB58)
}
if VMID == ids.Empty {
t.Fatal("VMID is the empty id")
}
// M-Chain and F-Chain were split out of the retired thresholdvm package
// (LP-7050). They are separate chains and must never share an id.
if VMID == constants.MPCVMID {
t.Fatal("F-Chain's vmID collides with M-Chain's")
}
}
// The bytes themselves, spelled out: an ASCII name left-padded into 32 bytes.
func TestVMID_Bytes(t *testing.T) {
want := ids.ID{'f', 'h', 'e', 'v', 'm'}
if VMID != want {
t.Fatalf("VMID bytes = %v, want %v", VMID[:8], want[:8])
}
}
// TestVMID_NameResolves proves luxfi/constants maps this id back to the plugin
// name the node scans --plugin-dir for. If the two ever disagree, luxd looks
// for a binary nobody builds and the chain silently never starts.
func TestVMID_NameResolves(t *testing.T) {
if got := constants.VMName(VMID); got != VMName {
t.Fatalf("constants.VMName(VMID) = %q, want %q", got, VMName)
}
}
+266
View File
@@ -0,0 +1,266 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"fmt"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/zap"
)
// Native-ZAP struct-is-wire for F-Chain (fhevm). No hand-rolled big-endian, no
// cursor codec — the Transaction and the Block each own their marshal/parse
// over zap objects at FIXED field offsets. The on-wire format is exactly these
// offsets (canonical: parse rejects trailing bytes).
//
// SECURITY — the signing/authentication architecture:
//
// - SigningBytes() is the deterministic preimage the payer SIGNS. It is one
// zap object binding every semantically-meaningful field (Type, Payer,
// Subject, GasLimit, Nonce, Scheme, Payload) and EXCLUDING Auth/Sig.
// Because Payer is bound here and authenticate() requires Payer ==
// addressOf(Auth), an attacker cannot swap in a different public key. And
// because Subject is bound here — and SyntacticVerify requires it to equal
// what the payload derives — the signature covers the OBJECT acted on, not
// merely the arguments that produce it.
//
// - Bytes() is SigningBytes() ‖ sigObject, where sigObject carries Auth+Sig.
// The signed bytes are therefore a genuine byte-prefix of the full wire (the
// node's proposervm signed-block idiom). ID() = sha256(Bytes()).
//
// - authenticate() re-derives SigningBytes() from the parsed fields and
// verifies the signature against it; a deterministic zap marshal guarantees
// the re-derived preimage equals the signed prefix.
// ---- Transaction signing object (the SIGNED preimage; excludes Auth/Sig) ----
//
// Type u8 @ 0
// Payer 20B @ 1
// Subject 32B @ 21
// GasLimit u64 @ 53
// Nonce u64 @ 61
// Scheme bytes @ 69
// Payload bytes @ 77
const (
txType = 0
txPayer = 1
txSubject = 21
txGas = 53
txNonce = 61
txScheme = 69
txPayld = 77
txSize = 85
)
// ---- Transaction sig object (appended after the signing object) ----
//
// Auth bytes @ 0
// Sig bytes @ 8
const (
sgAuth = 0
sgSig = 8
sgSize = 16
)
// SigningBytes is the deterministic encoding the payer signs. It binds every
// semantically meaningful field — including Payer and Subject — but excludes
// Auth and Sig.
func (tx *Transaction) SigningBytes() []byte {
b := zap.NewBuilder(zap.HeaderSize + txSize + len(tx.Scheme) + len(tx.Payload) + 64)
ob := b.StartObject(txSize)
ob.SetUint8(txType, tx.Type)
ob.SetBytesFixed(txPayer, tx.Payer[:])
ob.SetBytesFixed(txSubject, tx.Subject[:])
ob.SetUint64(txGas, tx.GasLimit)
ob.SetUint64(txNonce, tx.Nonce)
ob.SetBytes(txScheme, []byte(tx.Scheme))
ob.SetBytes(txPayld, tx.Payload)
ob.FinishAsRoot()
return b.Finish()
}
// Bytes is the full wire encoding: the signing object followed by an appended
// zap object carrying Auth and Sig. The signing prefix is byte-identical to
// SigningBytes(), so the payer's signature covers exactly Bytes()[:zapLen].
func (tx *Transaction) Bytes() []byte {
signing := tx.SigningBytes()
sb := zap.NewBuilder(zap.HeaderSize + sgSize + len(tx.Auth) + len(tx.Sig) + 32)
so := sb.StartObject(sgSize)
so.SetBytes(sgAuth, tx.Auth)
so.SetBytes(sgSig, tx.Sig)
so.FinishAsRoot()
sig := sb.Finish()
out := make([]byte, 0, len(signing)+len(sig))
out = append(out, signing...)
out = append(out, sig...)
return out
}
// ParseTransaction decodes a transaction from its wire encoding: the leading
// signing object, then the appended Auth/Sig object. Canonical: rejects
// trailing bytes.
func ParseTransaction(data []byte) (*Transaction, error) {
n, err := zapLen(data)
if err != nil {
return nil, err
}
sm, err := zap.Parse(data[:n])
if err != nil {
return nil, err
}
gm, err := zap.Parse(data[n:])
if err != nil {
return nil, err
}
if n+gm.Size() != len(data) {
return nil, fmt.Errorf("fhevm: %w: trailing bytes", ErrInvalidPayload)
}
so := sm.Root()
sig := gm.Root()
tx := &Transaction{
Type: so.Uint8(txType),
Scheme: string(so.Bytes(txScheme)),
GasLimit: so.Uint64(txGas),
Nonce: so.Uint64(txNonce),
Payload: appendBytes(so.Bytes(txPayld)),
Auth: appendBytes(sig.Bytes(sgAuth)),
Sig: appendBytes(sig.Bytes(sgSig)),
}
copy(tx.Payer[:], so.BytesFixedSlice(txPayer, ids.ShortIDLen))
copy(tx.Subject[:], so.BytesFixedSlice(txSubject, 32))
// Canonical wire: zap follows the root offset and ignores unreferenced
// padding inside a message's declared size, so distinct byte-strings can
// decode to identical fields. Bind the id to the RE-SERIALIZED (canonical)
// form and reject any input that is not already canonical — exactly one
// byte-string authenticates per logical tx (no id-malleability, fail-closed).
canonical := tx.Bytes()
if !bytes.Equal(data, canonical) {
return nil, fmt.Errorf("fhevm: %w: non-canonical tx encoding", ErrInvalidPayload)
}
tx.id = ids.ID(sha256.Sum256(canonical))
return tx, nil
}
// ---- Block ----
//
// ParentID 32B @ 0
// Height u64 @ 32
// Timestamp i64 @ 40 (Unix seconds — F-Chain's block-time resolution)
// TxLens list @ 48 (u32 per Transaction wire length)
// TxBlob bytes @ 56 (concatenated Transaction wire objects)
const (
blkParent = 0
blkHeight = 32
blkTime = 40
blkTxLens = 48
blkTxBlob = 56
blkSize = 64
)
// Bytes serializes the block (parent, height, timestamp, transactions).
func (b *Block) Bytes() []byte {
txLens := make([]uint32, len(b.transactions))
var txBlob []byte
for i, tx := range b.transactions {
txb := tx.Bytes()
txLens[i] = uint32(len(txb))
txBlob = append(txBlob, txb...)
}
bld := zap.NewBuilder(zap.HeaderSize + blkSize + len(txBlob) + 4*len(txLens) + 128)
txLensOff := writeU32List(bld, txLens)
ob := bld.StartObject(blkSize)
ob.SetBytesFixed(blkParent, b.parentID[:])
ob.SetUint64(blkHeight, b.height)
ob.SetInt64(blkTime, b.timestamp.Unix())
ob.SetList(blkTxLens, txLensOff, len(txLens))
ob.SetBytes(blkTxBlob, txBlob)
ob.FinishAsRoot()
return bld.Finish()
}
func parseBlock(vm *VM, data []byte) (*Block, error) {
msg, err := zap.Parse(data)
if err != nil {
return nil, err
}
if msg.Size() != len(data) {
return nil, fmt.Errorf("fhevm: %w: block trailing bytes", ErrInvalidPayload)
}
o := msg.Root()
b := &Block{vm: vm}
copy(b.parentID[:], o.BytesFixedSlice(blkParent, 32))
b.height = o.Uint64(blkHeight)
b.timestamp = time.Unix(o.Int64(blkTime), 0)
lens := readU32List(o, blkTxLens)
blob := o.Bytes(blkTxBlob)
b.transactions = make([]*Transaction, 0, len(lens))
pos := 0
for _, l := range lens {
if pos+int(l) > len(blob) {
return nil, fmt.Errorf("fhevm: %w: tx blob out of bounds", ErrInvalidPayload)
}
tx, err := ParseTransaction(blob[pos : pos+int(l)])
if err != nil {
return nil, err
}
b.transactions = append(b.transactions, tx)
pos += int(l)
}
b.id = b.computeID()
return b, nil
}
// ---- shared helpers ----
// zapLen returns the total length of the leading self-delimiting zap message in
// b (its header size field @[12:16]) — the split point between the signing
// prefix and the appended Auth/Sig object.
func zapLen(b []byte) (int, error) {
if len(b) < zap.HeaderSize {
return 0, fmt.Errorf("fhevm: %w: short buffer", ErrInvalidPayload)
}
n := int(binary.LittleEndian.Uint32(b[12:16]))
if n < zap.HeaderSize || n > len(b) {
return 0, fmt.Errorf("fhevm: %w: bad zap length", ErrInvalidPayload)
}
return n, nil
}
func writeU32List(b *zap.Builder, xs []uint32) int {
lb := b.StartList(4)
for _, x := range xs {
lb.AddUint32(x)
}
off, _ := lb.Finish()
return off
}
func readU32List(o zap.Object, ptrOff int) []uint32 {
l := o.ListStride(ptrOff, 4)
n := l.Len()
out := make([]uint32, n)
for i := 0; i < n; i++ {
out[i] = l.Uint32(i)
}
return out
}
func appendBytes(b []byte) []byte {
if len(b) == 0 {
return nil
}
return append([]byte(nil), b...)
}
+208
View File
@@ -0,0 +1,208 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"crypto/sha256"
"encoding/binary"
"testing"
"github.com/luxfi/ids"
"github.com/luxfi/zap"
)
// sampleTx is a fully-populated transaction whose every field is non-empty, so
// the canonical round-trip exercises every offset.
func sampleTx() *Transaction {
tx := &Transaction{
Type: TxRegisterCiphertext,
Scheme: "ckks-n14",
Subject: [32]byte{9, 8, 7, 6, 5, 4, 3, 2, 1},
GasLimit: 81000,
Nonce: 42,
Payload: []byte("register-ciphertext-payload"),
Auth: []byte("payer-public-key-bytes"),
Sig: []byte("payer-signature-bytes"),
}
copy(tx.Payer[:], []byte("payer-address-20byte"))
return tx
}
// TestWireCanonicalRoundTrip proves Bytes()→ParseTransaction is a faithful,
// idempotent round-trip and that the id is the content hash of the canonical
// wire (id == sha256(Bytes())).
func TestWireCanonicalRoundTrip(t *testing.T) {
tx := sampleTx()
data := tx.Bytes()
parsed, err := ParseTransaction(data)
if err != nil {
t.Fatalf("ParseTransaction(canonical) failed: %v", err)
}
if got := parsed.Bytes(); string(got) != string(data) {
t.Fatalf("re-serialization not canonical: parsed.Bytes() != input\n in =%x\n out=%x", data, got)
}
if want := ids.ID(sha256.Sum256(data)); parsed.ID() != want {
t.Fatalf("id != sha256(Bytes()): got %s want %s", parsed.ID(), want)
}
// Field fidelity across the seam.
if parsed.Type != tx.Type || parsed.Scheme != tx.Scheme ||
parsed.GasLimit != tx.GasLimit || parsed.Nonce != tx.Nonce ||
parsed.Payer != tx.Payer || parsed.Subject != tx.Subject ||
string(parsed.Payload) != string(tx.Payload) ||
string(parsed.Auth) != string(tx.Auth) || string(parsed.Sig) != string(tx.Sig) {
t.Fatalf("field mismatch after round-trip:\n in =%+v\n out=%+v", tx, parsed)
}
}
// TestWireSigningBytesArePrefix proves the payer's signature covers a genuine
// byte-prefix of the full wire, so what is authenticated and what is
// transmitted cannot come apart.
func TestWireSigningBytesArePrefix(t *testing.T) {
tx := sampleTx()
signing := tx.SigningBytes()
full := tx.Bytes()
if len(signing) > len(full) || string(full[:len(signing)]) != string(signing) {
t.Fatal("SigningBytes() is not a prefix of Bytes()")
}
n, err := zapLen(full)
if err != nil {
t.Fatalf("zapLen: %v", err)
}
if n != len(signing) {
t.Fatalf("zapLen = %d, want the signing length %d", n, len(signing))
}
}
// TestWireSigningBytesBindEveryField proves the signed preimage changes when
// ANY semantically-meaningful field changes — including Subject, which names
// the object the operation acts on. A field the signature did not cover could
// be swapped in flight.
func TestWireSigningBytesBindEveryField(t *testing.T) {
base := string(sampleTx().SigningBytes())
for name, mutate := range map[string]func(*Transaction){
"Type": func(tx *Transaction) { tx.Type = TxGrantPermit },
"Scheme": func(tx *Transaction) { tx.Scheme = "bfv-n13" },
"Payer": func(tx *Transaction) { tx.Payer[0] ^= 0xff },
"Subject": func(tx *Transaction) { tx.Subject[0] ^= 0xff },
"GasLimit": func(tx *Transaction) { tx.GasLimit++ },
"Nonce": func(tx *Transaction) { tx.Nonce++ },
"Payload": func(tx *Transaction) { tx.Payload = append(tx.Payload, 'x') },
} {
tx := sampleTx()
mutate(tx)
if string(tx.SigningBytes()) == base {
t.Fatalf("changing %s left the signed preimage unchanged", name)
}
}
// Auth and Sig are deliberately EXCLUDED from the preimage: the signature
// cannot cover itself.
tx := sampleTx()
tx.Auth = []byte("a-different-public-key")
tx.Sig = []byte("a-different-signature")
if string(tx.SigningBytes()) != base {
t.Fatal("Auth/Sig must not appear in the signed preimage")
}
}
// TestWireRejectsNonCanonical proves the canonical gate: zap follows the root
// offset and ignores unreferenced padding inside a message's declared size, so
// a twin buffer can decode to identical fields yet hash differently. Parse must
// reject any input that is not already byte-equal to its re-serialized form —
// exactly one byte-string authenticates per logical tx (no id-malleability).
func TestWireRejectsNonCanonical(t *testing.T) {
tx := sampleTx()
data := tx.Bytes()
// Split the two concatenated zap messages (signing ‖ sig).
n, err := zapLen(data)
if err != nil {
t.Fatalf("zapLen: %v", err)
}
// Craft a non-canonical twin: append 8 unreferenced bytes to the trailing
// (sig) message and bump its declared size field so a naive
// n+gm.Size()==len(data) trailing-bytes check still passes. The root offset
// does not reference the padding, so the fields decode identically.
sig := append([]byte(nil), data[n:]...)
sig = append(sig, make([]byte, 8)...)
binary.LittleEndian.PutUint32(sig[12:16], uint32(len(sig)))
twin := append(append([]byte(nil), data[:n]...), sig...)
if string(twin) == string(data) {
t.Fatal("twin construction failed to differ from canonical")
}
if got := int(binary.LittleEndian.Uint32(twin[n+12 : n+16])); got != len(sig) {
t.Fatalf("twin size field not bumped: got %d want %d", got, len(sig))
}
if _, err := ParseTransaction(twin); err == nil {
t.Fatal("ParseTransaction accepted a non-canonical (padded) twin — id-malleability not closed")
}
}
// TestWireParseRejectsTruncated confirms the parser fails closed on a short
// buffer rather than reading past the end.
func TestWireParseRejectsTruncated(t *testing.T) {
data := sampleTx().Bytes()
for _, cut := range []int{0, 4, zap.HeaderSize, len(data) - 1} {
if _, err := ParseTransaction(data[:cut]); err == nil {
t.Fatalf("ParseTransaction accepted truncated buffer of len %d", cut)
}
}
}
// TestWireParseRejectsTrailing confirms bytes appended after a complete
// transaction are refused rather than ignored.
func TestWireParseRejectsTrailing(t *testing.T) {
data := append(sampleTx().Bytes(), 0xde, 0xad)
if _, err := ParseTransaction(data); err == nil {
t.Fatal("ParseTransaction accepted trailing bytes")
}
}
// TestBlockWireRoundTrip proves a block serializes and re-parses with its
// transactions intact and its id unchanged.
func TestBlockWireRoundTrip(t *testing.T) {
a := sampleTx()
b := sampleTx()
b.Nonce = 43
b.Payload = []byte("second")
blk := &Block{
parentID: ids.ID{1, 2, 3},
height: 7,
timestamp: timeAt(1_700_000_000),
transactions: []*Transaction{a, b},
}
blk.id = blk.computeID()
parsed, err := parseBlock(nil, blk.Bytes())
if err != nil {
t.Fatalf("parseBlock: %v", err)
}
if parsed.id != blk.id {
t.Fatalf("block id changed across the wire: %s != %s", parsed.id, blk.id)
}
if parsed.height != blk.height || parsed.parentID != blk.parentID ||
!parsed.timestamp.Equal(blk.timestamp) {
t.Fatalf("header mismatch:\n in =%+v\n out=%+v", blk, parsed)
}
if len(parsed.transactions) != 2 {
t.Fatalf("got %d transactions, want 2", len(parsed.transactions))
}
for i, tx := range parsed.transactions {
if tx.ID() != blk.transactions[i].ID() {
t.Fatalf("transaction %d id changed across the wire", i)
}
}
}
// TestBlockWireRejectsTrailing confirms a block with appended bytes is refused.
func TestBlockWireRejectsTrailing(t *testing.T) {
blk := &Block{parentID: ids.ID{1}, height: 1, timestamp: timeAt(1)}
if _, err := parseBlock(nil, append(blk.Bytes(), 0xff)); err == nil {
t.Fatal("parseBlock accepted trailing bytes")
}
}