Files
zeekay 87cb72352a schain: an authorization is spent once, and a block commits what it contains
An AllocateTx's nonce was covered by the owner's signature and compared to
nothing. Every gate it passed the first time passed again for the identical
bytes: the epoch was still current, the fingerprint still matched, the signer was
still the range owner, the ML-DSA signature still verified. Anyone who saw an
owner's allocate could resubmit it and consume the range's id space again, for
free, as often as they liked. A range now carries the nonce of the allocation
that last moved it, in the same key as its cursor, and takes only a strictly
greater one. The state root covers it, so a chain cannot disagree about what has
been spent and share a root.

Accept committed whatever was staged. Every apply starts by dropping the version
layer, so the layer belongs to whichever block was processed LAST — verify a
sibling between a block's Verify and its Accept and the commit carried the
sibling's writes under this block's id. Accept re-applies its own transactions
from committed state, commits, and only then advances the chain in memory, under
the lock it was mutating without.

Block position is now checked before block content: the parent must be a block
this node holds, the height must be parent+1, and the timestamp must not rewind
or run past a skew ceiling floored at the parent. None of it was checked, so a
block could claim any height — and the height it claimed became the chain's
height and part of every state root computed after it.

A proposer now leaves out what it cannot build rather than failing the whole
build: one unauthorized allocate anyone could submit used to drain the mempool
and destroy every manifest queued behind it. A verifier still refuses the whole
block, which is the half that carries the safety property.

Also: the height lookup reads an index instead of scanning the block map and
returning whichever entry map iteration reached first; abandoned blocks are
released; a rejected block leaves the index; boot no longer panics on a nil
logger when the manager supplies no runtime; and the empty-set check in the owner
gate is gone — pinning.Owner already answers that question, and answers the wider
one the length check missed.

Coverage 69.9% -> 97.9%, with 32 guards mutation-checked. The stale premise in
SECURITY_review.md is recorded at its head: it reviewed a checkout that did not
contain this VM.
2026-08-28 09:00:18 -07:00

260 lines
8.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package schain
import (
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/vm/chain"
)
// Ensure Block implements chain.Block.
var _ chain.Block = (*Block)(nil)
// errStateRootMismatch is returned by Verify when the proposer's claimed manifest
// state root does not match the root recomputed from the applied transactions —
// the multi-validator divergence the state root exists to catch.
var errStateRootMismatch = errors.New("manifest state root mismatch")
// Block is an S-Chain block. It wraps the deterministic ProcessBlock result and
// implements chain.Block. It carries only the storage VM's needs: a header and
// the serialized PutManifest transactions — no cross-chain carried fills (the
// dexvm wire-format complication a storage VM does not have).
type Block struct {
vm *ChainVM
id ids.ID
parentID ids.ID
height uint64
timestamp time.Time
// stateRoot is the proposer's claimed manifest state root for this block — the
// commitment over the manifest keyspace after this block's writes, folded with
// the block's consensus binding (see VM.computeStateRoot). It travels in the
// block bytes (so the block id commits to it — a peer cannot swap the claimed
// root while keeping the same id) and Verify recomputes it independently and
// rejects a block whose claimed root does not match the computed one.
stateRoot ids.ID
// txs are the serialized transactions (each a txs.PutManifestTx or
// txs.AllocateTx wire image).
txs [][]byte
// blockCtx carries the deterministic consensus inputs the AllocateTx owner
// gate needs (validator set frozen at the epoch + proposer NodeID + epoch).
// It is NOT part of the block bytes: it is reconstructed deterministically on
// every node from the consensus runtime (the frozen P-Chain set at the block's
// pChainHeight + the block's proposer), so two honest nodes resolve the SAME
// owner. For a block with no AllocateTx it is the empty context (ignored).
//
// WIRE SEAM: in Stage 1 this is set by BuildBlock (proposer-side) and must be
// reconstructed on the verifying side by the chains-manager / engine wrapper
// when it parses a block, from the consensus block's pChainHeight + proposer.
// See BlockContext for the exact production sources. parseBlock leaves it empty
// (a parsed block carrying an AllocateTx therefore fails closed until the
// verifying wrapper populates it — the safe default).
blockCtx BlockContext
status Status
}
// Status represents block status.
type Status uint8
const (
StatusUnknown Status = iota
StatusProcessing
StatusAccepted
StatusRejected
)
func (b *Block) ID() ids.ID { return b.id }
func (b *Block) Parent() ids.ID { return b.parentID }
func (b *Block) ParentID() ids.ID { return b.parentID }
func (b *Block) Height() uint64 { return b.height }
func (b *Block) Timestamp() time.Time { return b.timestamp }
func (b *Block) Status() uint8 { return uint8(b.status) }
// Bytes serializes the block. Wire format:
//
// height[8] | timestamp[8] | parentID[32] | stateRoot[32] |
// txCount[4] | txCount × ( txLen[4] | txBytes )
//
// The txCount prefix makes the transactions self-delimiting. stateRoot is the
// proposer's claimed manifest state root, carried in the header so the block id
// (sha256 of these bytes) commits to it — a peer cannot swap the claimed root or
// a manifest while keeping the same id, and Verify rejects a claimed root that
// does not match the root recomputed from the applied txs.
func (b *Block) Bytes() []byte {
size := 8 + 8 + 32 + 32 + 4
for _, tx := range b.txs {
size += 4 + len(tx)
}
data := make([]byte, size)
offset := 0
binary.BigEndian.PutUint64(data[offset:], b.height)
offset += 8
binary.BigEndian.PutUint64(data[offset:], uint64(b.timestamp.UnixNano()))
offset += 8
copy(data[offset:], b.parentID[:])
offset += 32
copy(data[offset:], b.stateRoot[:])
offset += 32
binary.BigEndian.PutUint32(data[offset:], uint32(len(b.txs)))
offset += 4
for _, tx := range b.txs {
binary.BigEndian.PutUint32(data[offset:], uint32(len(tx)))
offset += 4
copy(data[offset:], tx)
offset += len(tx)
}
return data
}
// Verify processes the block deterministically against the version layer, then
// enforces the manifest STATE ROOT: it recomputes the root from the txs it just
// applied and rejects the block if that computed root does not match the root the
// proposer claimed in the header. This is the multi-validator safety gate — a
// proposer (or a corrupted replica) whose post-apply manifest state diverges from
// what an honest validator computes cannot get its block accepted, because the
// claimed root (committed by the block id) and the recomputed root disagree.
// Verify performs NO external I/O on any node (mirror of dexvm/block.go:141); the
// manifest writes land in the in-memory version layer and become durable only at
// Accept.
func (b *Block) Verify(ctx context.Context) error {
if _, err := b.apply(ctx); err != nil {
return err
}
b.status = StatusProcessing
return nil
}
// apply is what Verify and Accept both do: check the block's POSITION, replay
// its transactions over COMMITTED state, and check the root it claims against
// the root that produced. Accept runs it again rather than trusting the version
// layer Verify left, because that layer belongs to whichever block was processed
// most recently — verify a sibling between a block's Verify and its Accept and
// the commit would carry the sibling's writes under this block's id.
func (b *Block) apply(ctx context.Context) (*BlockResult, error) {
if err := b.vm.checkPosition(b); err != nil {
return nil, err
}
result, err := b.vm.inner.ProcessBlock(ctx, b.height, b.timestamp, b.txs, b.blockCtx)
if err != nil {
return nil, err
}
if result.StateRoot != b.stateRoot {
// Drop this block's staged writes; the version layer must not retain a
// rejected block's mutations for the next apply.
b.vm.inner.abort()
return nil, fmt.Errorf("%w: claimed %s, computed %s", errStateRootMismatch, b.stateRoot, result.StateRoot)
}
return result, nil
}
// Accept commits the block's state batch in ONE atomic write (the single commit
// point — dexvm/block.go:159) and only then advances the chain in memory. The
// order matters: a tip that moved ahead of its commit is a chain serving reads
// out of writes that never landed. After this returns, the block's manifests are
// durable and GetManifest observes them.
func (b *Block) Accept(ctx context.Context) error {
if _, err := b.apply(ctx); err != nil {
return err
}
if err := b.vm.inner.commit(); err != nil {
return err
}
b.vm.lock.Lock()
defer b.vm.lock.Unlock()
b.status = StatusAccepted
b.vm.lastAcceptedID = b.id
b.vm.lastAcceptedHeight = b.height
b.vm.blocks[b.id] = b
b.vm.heights[b.height] = b.id
// A preferred tip at or behind the accepted one can only produce blocks that
// will never be accepted — and, since the proposer stamps an allocate's nonce
// from the block height, blocks whose allocations look like replays of each
// other. The engine normally advances the preference; when it has not, the
// block just accepted is the only sensible tip to build on.
if pref, ok := b.vm.blocks[b.vm.preferredID]; !ok || pref.height <= b.height {
b.vm.preferredID = b.id
}
b.vm.prune(b.height)
return nil
}
// Reject discards the block's staged version-layer changes (dexvm/block.go:175)
// and releases it from the block index.
func (b *Block) Reject(ctx context.Context) error {
b.vm.inner.abort()
b.vm.lock.Lock()
defer b.vm.lock.Unlock()
b.status = StatusRejected
delete(b.vm.blocks, b.id)
return nil
}
// parseBlock deserializes a block from bytes (the inverse of Block.Bytes). Every
// length is bounds-checked so a malformed block is rejected as errInvalidBlock
// rather than panicking or over-allocating.
func parseBlock(vm *ChainVM, data []byte) (*Block, error) {
if len(data) < 8+8+32+32+4 {
return nil, errInvalidBlock
}
b := &Block{vm: vm, status: StatusUnknown}
offset := 0
b.height = binary.BigEndian.Uint64(data[offset:])
offset += 8
ts := binary.BigEndian.Uint64(data[offset:])
b.timestamp = time.Unix(0, int64(ts))
offset += 8
copy(b.parentID[:], data[offset:offset+32])
offset += 32
copy(b.stateRoot[:], data[offset:offset+32])
offset += 32
txCount := binary.BigEndian.Uint32(data[offset:])
offset += 4
for i := uint32(0); i < txCount; i++ {
if offset+4 > len(data) {
return nil, errInvalidBlock
}
txLen := binary.BigEndian.Uint32(data[offset:])
offset += 4
if offset+int(txLen) > len(data) {
return nil, errInvalidBlock
}
tx := make([]byte, txLen)
copy(tx, data[offset:offset+int(txLen)])
b.txs = append(b.txs, tx)
offset += int(txLen)
}
if offset != len(data) {
return nil, errInvalidBlock
}
hash := sha256.Sum256(data)
copy(b.id[:], hash[:])
return b, nil
}