mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
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.
260 lines
8.9 KiB
Go
260 lines
8.9 KiB
Go
// 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
|
||
}
|