Files
zeekay e5fbbc5201 fhevm: a block extends the tip, a signature names one chain, and a failed read is not an answer
Four defects, each confirmed by executing it before it was fixed and by
mutation after.

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

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

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

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

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

fhevm 89.7% -> 98.9%. The twelve statements left are named unreachable in
the report, not padded. The harness now holds constant what production
holds constant: one chain id and one genesis across every node of a chain,
rather than a fresh id per node.
2026-08-28 11:55:53 -07:00

743 lines
27 KiB
Go

// 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"
"encoding/json"
"fmt"
"github.com/luxfi/chains/fee"
"github.com/luxfi/chains/mpcvm/fhe"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
// 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"`
}
// Size bounds. Every one of these is a byte channel onto a chain that keeps
// what it is given, so each is bounded before anything is decoded and priced
// once it is.
const (
// MaxPayload bounds an operation's encoding. It is set by the largest
// legitimate payload — a MaxCommittee-member epoch proposal, whose members
// each carry an ML-DSA-65 public key — with room to spare.
MaxPayload = 128 * 1024
// MaxScheme bounds the scheme name. Scheme names are short labels
// ("ckks-n14"); anything longer is a channel, not a name.
MaxScheme = 32
// MaxCommittee bounds a threshold committee. It bounds the epoch payload,
// and with it the public-key parsing an UNAUTHENTICATED transaction can
// demand before its own signature is checked.
MaxCommittee = 32
// MaxCiphertextSize bounds the off-chain body a registration may describe.
// The body is not stored here, but a size nobody could ever serve describes
// nothing.
MaxCiphertextSize = 1 << 30
)
// 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
}
// Bounds FIRST, before anything is decoded or parsed, so the work an
// unauthenticated transaction can demand is bounded by its own size.
if len(tx.Payload) > MaxPayload {
return fmt.Errorf("fhevm: %w: payload %d exceeds %d bytes", ErrInvalidPayload, len(tx.Payload), MaxPayload)
}
if len(tx.Scheme) > MaxScheme {
return fmt.Errorf("fhevm: %w: scheme %d exceeds %d bytes", ErrInvalidPayload, len(tx.Scheme), MaxScheme)
}
// Auth and Sig are fixed-width by algorithm. Pinning them here keeps them
// from becoming a third byte channel that the base cost would carry for free.
if len(tx.Auth) != 0 && len(tx.Auth) != mldsa.GetPublicKeySize(payerAuthMode) {
return fmt.Errorf("fhevm: %w: auth is %d bytes, not %d", ErrInvalidPayload, len(tx.Auth), mldsa.GetPublicKeySize(payerAuthMode))
}
if len(tx.Sig) != 0 && len(tx.Sig) != mldsa.GetSignatureSize(payerAuthMode) {
return fmt.Errorf("fhevm: %w: signature is %d bytes, not %d", ErrInvalidPayload, len(tx.Sig), mldsa.GetSignatureSize(payerAuthMode))
}
// 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 || p.Size > MaxCiphertextSize {
return fmt.Errorf("fhevm: %w: register: ciphertext size %d", ErrInvalidPayload, p.Size)
}
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 len(c) > MaxCommittee {
return fmt.Errorf("fhevm: %w: %d members exceeds %d", ErrInvalidCommittee, len(c), MaxCommittee)
}
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)
}
// A seat is only a vote if a distinct account can cast it. Membership is
// tested by addressOf(PublicKey) — the same derivation that authenticates a
// payer — so that is what must be unique. Deduplicating NodeID instead let n
// seats share one key: the committee passed every check, reported itself
// fully seated, and could never reach its own threshold, for a decryption or
// for rotating itself out.
voters := make(map[fee.Account]struct{}, len(c))
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)
}
acct := addressOf(m.PublicKey)
if _, dup := voters[acct]; dup {
return fmt.Errorf("fhevm: %w: member %s shares a voting identity with another seat",
ErrInvalidCommittee, m.NodeID)
}
voters[acct] = struct{}{}
}
return nil
}
// decode unmarshals an op payload, naming the operation in the error. It
// accepts EXACTLY the schema: a member the schema does not describe, or bytes
// after the value, is refused rather than ignored.
//
// encoding/json ignores what it does not recognise, which on a chain that
// persists the transaction verbatim is a byte channel — a ciphertext body in a
// "body" member decoded fine, cost nothing extra, and came back out of the
// block store. There is one encoding of each payload and this is it.
func decode[T any](payload []byte, op string) (T, error) {
var v T
dec := json.NewDecoder(bytes.NewReader(payload))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, fmt.Errorf("fhevm: %w: %s: %v", ErrInvalidPayload, op, err)
}
if dec.More() {
return v, fmt.Errorf("fhevm: %w: %s: trailing content", ErrInvalidPayload, op)
}
return v, nil
}
// authenticate verifies the payer authorized this transaction ON THIS CHAIN.
// PUBLIC ONLY: parse the payer's ML-DSA-65 public key, require it hashes to
// Payer, and verify the signature over SigningBytes(chain). No secret material
// is touched.
//
// chain is the verifying node's own chain id, never one the transaction
// carries, so a transaction signed for another F-Chain fails here rather than
// spending a balance the payer holds on every chain at the same address.
func (tx *Transaction) authenticate(chain ids.ID) 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(chain), 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 spend a block on work only one of them can do. 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
// (batch.admit). One function, both layers, no drift.
func (tx *Transaction) effect() [32]byte {
h := sha256.New()
h.Write([]byte{tx.Type})
switch tx.Type {
case TxAdvanceEpoch:
// The DECISION, not the proposal. Exactly one epoch is ever open —
// checkAuth refuses any target but the sitting epoch's successor — so a
// member's vote is named by the member alone. Naming it by Subject
// instead made two votes for two different committees two different
// effects: both passed admission, both passed Verify against committed
// state, and Accept applied the first and refused the second. A block
// every validator certifies and no validator can apply halts the chain
// at that height, which is the whole thing this function exists to stop.
h.Write(tx.Payer[:])
case TxRegisterCiphertext, TxRevokePermit:
// The entry is named by Subject alone: one registration per handle, one
// withdrawal per permit, whoever asks for it.
h.Write(tx.Subject[:])
case TxFulfillDecrypt:
// One write per member per request. The voted value lives in the
// payload, so the effect is the member's vote on THIS request rather
// than the result it names.
h.Write(tx.Subject[:])
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.Subject[:])
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
}
// The permit that authorized the ask must still authorize it. Revocation
// is a withdrawal of consent and reaches a request already in flight —
// otherwise an owner who revoked would watch the committee answer anyway
// and deliver the plaintext to the callback.
//
// Expiry is deliberately NOT re-checked. It bounds when the grantee may
// ASK; the grantee asked in time, and the committee answering afterwards
// is not the grantee acting. Re-checking it would make any permit
// shorter than a decryption round useless.
pm, ok := vm.getPermit(req.PermitID)
if !ok {
return ErrPermitNotFound
}
if pm.Status != StatusActive {
return ErrPermitRevoked
}
// 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
}
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
}
return nil
default:
return ErrInvalidTxType
}
}
// Apply is where authorization is DECIDED and where state changes. It runs
// inside block.Accept, writing through the VM's versiondb so an 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 reports whether the transaction took effect. A transaction that fails
// checkAuth REVERTS: applied is false, err is nil, state is untouched, and the
// caller still burns the fee and consumes the nonce. This is the only place the
// verdict is reached, so every validator reaches it from the same committed
// state in the same order. Deciding it earlier, in Verify, would let a block
// pass consensus on state that an earlier transaction in the same block then
// changes — and a block every validator certifies and no validator can apply
// halts the chain.
//
// A non-nil err means the write itself failed, which no validator can proceed
// past; block.Accept rolls the whole block back.
func (tx *Transaction) Apply(vm *VM, now int64) (bool, error) {
if err := tx.checkAuth(vm, now); err != nil {
vm.log.Info("F-Chain transaction reverted",
log.String("txID", tx.ID().String()),
log.String("reason", err.Error()),
)
return false, nil
}
var err error
switch tx.Type {
case TxRegisterCiphertext:
err = tx.applyRegister(vm, now)
case TxGrantPermit:
err = tx.applyGrant(vm, now)
case TxRevokePermit:
err = tx.applyRevoke(vm)
case TxRequestDecrypt:
err = tx.applyRequest(vm, now)
case TxFulfillDecrypt:
err = tx.applyFulfill(vm, now)
case TxAdvanceEpoch:
err = tx.applyAdvance(vm, now)
default:
// Unreachable: SyntacticVerify refuses an unknown type before a
// transaction can reach a block. Treated as a refusal, not a halt.
return false, nil
}
return err == nil, err
}
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 = vote(req.Attestations, tx.Payer, 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 = vote(cur.Attestations, tx.Payer, 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)
}