schain: Stage 1 — leaderless pinned-writer allocator (AllocateTx + owner gate)

Replaces raft's serialization role for volume/fileId allocation with
owner-gated VM state riding native Lux consensus. The HRW owner of a range
is the only emitter of allocate mutations, enforced at block Verify — no
leader election.

- txs: AllocateTx{Range,Count} mirrors PutManifestTx codec (type byte + JSON);
  Verify rejects empty range / zero count. Owner gate is block-level, not in
  the tx (a tx cannot see the block's validator set).
- state: per-range counter alloc/<range> (GetAlloc/SetAlloc), folded into the
  SHAKE256 state Root() alongside manifests via rootPrefixes; domain bumped to
  SCHAIN_STATE_ROOT_V2 so the root covers allocator divergence.
- vm: applyAllocate increments alloc/<range> by Count, reserving [base,base+Count)
  — pure function of committed state, deterministic, version-layer only, no I/O.
  Owner gate: pinning.IsOwner(range, proposer, members) in ProcessBlock; a
  non-owner (or no-validator-set) allocate fails the whole block. ProcessBlock
  now Aborts staged state first so read-modify-write applies over COMMITTED
  state (idempotent across BuildBlock+Verify re-application).
- BlockContext{Members,Proposer,Epoch} threads the validator set + proposer as
  explicit deterministic inputs (no network I/O in Verify). BlockContextBuilder
  is the seam to the real consensus runtime (master-cutover stage); empty
  context = M0/no-allocate path, AllocateTx fails closed.

Tests (green, no go.sum bypass):
- owner allocates: contiguous + monotonic across blocks ([0,8) then [8,13)).
- non-owner rejected: proposer-side (BuildBlock) and verifier-side (Verify) →
  errNonOwnerAllocate; counter untouched; nothing leaks to durable base.
- empty validator set: fails closed (errNoValidatorSet).
- parallel distinct ranges: independent owners allocate independently.
- same range: serializes through its one owner.
- state root covers alloc: counter change moves Root(); Verify rejects a
  tampered claimed root (errStateRootMismatch).
This commit is contained in:
zeekay
2026-06-25 15:15:37 -07:00
parent 6b9ddc1f79
commit d8d5b7e07b
8 changed files with 1075 additions and 50 deletions
+425
View File
@@ -0,0 +1,425 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// allocate_test.go — the Stage 1 end-to-end proof for the leaderless
// pinned-writer ALLOCATOR. It proves the property that replaces raft's
// single-serialized-writer role WITHOUT a leader election: the HRW owner of a
// range is the only validator whose AllocateTx commits, the counter is monotonic
// + contiguous across blocks, disjoint ranges allocate independently through
// their respective owners, and the same range serializes through its one owner —
// all gated deterministically at block Verify with NO network I/O.
//
// owner allocates → AllocateTx from the range owner commits; GetAlloc
// reflects it after Accept; [base, base+Count) is
// contiguous + monotonic across blocks.
// non-owner REJECTED → an AllocateTx for range R from a non-owner proposer
// fails Verify (the pinned-writer safety property —
// same-range double-write impossible).
// parallel distinct → allocations to different ranges by their respective
// owners proceed independently.
// state root covers it → committing an allocation changes the block's state
// root, and Verify rejects a block whose claimed root
// does not match.
package schain
import (
"context"
"errors"
"testing"
"github.com/luxfi/ids"
"github.com/luxfi/chains/schain/pinning"
"github.com/luxfi/chains/schain/txs"
)
// validatorMembers builds a deterministic, equal-weight validator set of n nodes.
// NodeID i has byte[0]=i+1 (matching pinning_test.node), so tests can name an
// owner by recomputing pinning.Owner over the same set.
func validatorMembers(n int) []pinning.Member {
ms := make([]pinning.Member, n)
for i := range ms {
var id ids.NodeID
id[0] = byte(i + 1)
ms[i] = pinning.Member{NodeID: id, Weight: 10}
}
return ms
}
// withValidatorSet installs a BlockContextBuilder that pins the given validator
// set at every height with `proposer` as the block proposer — the Stage 1 stand-in
// for the consensus-runtime seam. Every height resolves to the SAME deterministic
// (set, proposer, epoch), so the owner verdict is reproducible.
func withValidatorSet(cvm *ChainVM, members []pinning.Member, proposer ids.NodeID) {
cvm.SetBlockContextBuilder(func(_ context.Context, height uint64) (BlockContext, error) {
return BlockContext{Members: members, Proposer: proposer, Epoch: height}, nil
})
}
// ownerOf returns the HRW owner of range R under members — the only proposer
// whose AllocateTx for R may commit.
func ownerOf(t *testing.T, rng string, members []pinning.Member) ids.NodeID {
t.Helper()
owner, ok := pinning.Owner([]byte(rng), members)
if !ok {
t.Fatalf("no owner for range %q", rng)
}
return owner
}
// allocate drives one AllocateTx through the full Build -> Verify -> Accept cycle
// and returns the [base, base+Count) range it reserved (read from committed state
// before/after). A nil error means the block committed; a non-nil error is the
// Verify rejection (the owner gate firing).
func allocate(t *testing.T, cvm *ChainVM, rng string, count uint32) (base, next uint64, err error) {
t.Helper()
ctx := context.Background()
base, gerr := cvm.inner.state.GetAlloc(rng)
if gerr != nil {
t.Fatalf("GetAlloc(pre): %v", gerr)
}
tx := txs.NewAllocateTx(rng, count)
if serr := cvm.SubmitTx(tx.Bytes()); serr != nil {
t.Fatalf("SubmitTx: %v", serr)
}
blk, berr := cvm.BuildBlock(ctx)
if berr != nil {
// The owner gate also fires on the PROPOSER side: a non-owner cannot even
// build a block carrying an AllocateTx it has no right to emit. Unwrap the
// BuildBlock wrapper so callers match on the underlying gate error. The
// mempool is drained on a failed build, so the rejected tx does not leak
// into a later block.
return base, base, unwrapBuild(berr)
}
if verr := blk.Verify(ctx); verr != nil {
return base, base, verr // owner gate (or other) rejection — block not accepted
}
if aerr := blk.Accept(ctx); aerr != nil {
t.Fatalf("Accept: %v", aerr)
}
next, gerr = cvm.inner.state.GetAlloc(rng)
if gerr != nil {
t.Fatalf("GetAlloc(post): %v", gerr)
}
return base, next, nil
}
// unwrapBuild strips BuildBlock's wrapper so a caller can match on the underlying
// owner-gate error (errNonOwnerAllocate / errNoValidatorSet), which fires
// proposer-side inside BuildBlock's ProcessBlock for a block the proposer has no
// right to build.
func unwrapBuild(err error) error {
switch {
case errors.Is(err, errNonOwnerAllocate):
return errNonOwnerAllocate
case errors.Is(err, errNoValidatorSet):
return errNoValidatorSet
default:
return err
}
}
// TestVerifierRejectsNonOwnerBlock is the verifier-side safety proof: a MALICIOUS
// proposer (the range owner) builds a valid AllocateTx block, but an HONEST
// verifier — which reconstructs the SAME frozen validator set but resolves the
// block's proposer as a NON-owner — rejects it at Verify with errNonOwnerAllocate.
// This is the property peers rely on: a node cannot get a non-owner allocation
// accepted, because every honest verifier recomputes ownership and refuses.
func TestVerifierRejectsNonOwnerBlock(t *testing.T) {
ctx := context.Background()
members := validatorMembers(5)
const rng = "bucket-A"
owner := ownerOf(t, rng, members)
var nonOwner ids.NodeID
for _, m := range members {
if m.NodeID != owner {
nonOwner = m.NodeID
break
}
}
// Proposer (owner) builds a legitimate block.
proposer, _ := newTestVM(t)
withValidatorSet(proposer, members, owner)
tx := txs.NewAllocateTx(rng, 4)
if err := proposer.SubmitTx(tx.Bytes()); err != nil {
t.Fatalf("SubmitTx: %v", err)
}
blk, err := proposer.BuildBlock(ctx)
if err != nil {
t.Fatalf("owner BuildBlock: %v", err)
}
blkBytes := blk.Bytes()
// Honest verifier reconstructs the same set but, on its view, the block's
// proposer resolves to a NON-owner (e.g. the verifier learns the true proposer
// identity from the consensus header and it is not the range owner). It must
// reject the block.
verifier, _ := newTestVM(t)
withValidatorSet(verifier, members, nonOwner)
parsed, err := verifier.ParseBlock(ctx, blkBytes)
if err != nil {
t.Fatalf("verifier ParseBlock: %v", err)
}
if err := parsed.Verify(ctx); !errors.Is(err, errNonOwnerAllocate) {
t.Fatalf("verifier accepted non-owner block: err = %v, want errNonOwnerAllocate", err)
}
// Nothing leaked into the verifier's committed state.
if n, _ := verifier.inner.state.GetAlloc(rng); n != 0 {
t.Fatalf("rejected block leaked allocation on verifier: counter = %d, want 0", n)
}
}
// TestOwnerAllocatesContiguousMonotonic proves: an AllocateTx from the range
// OWNER commits; the reserved id range is [base, base+Count); and across multiple
// blocks the counter is contiguous + strictly monotonic (block N's next == block
// N+1's base). This is the allocator behaviour raft gave via its single leader —
// now from the HRW owner with no election.
func TestOwnerAllocatesContiguousMonotonic(t *testing.T) {
cvm, _ := newTestVM(t)
members := validatorMembers(5)
const rng = "bucket-A"
owner := ownerOf(t, rng, members)
withValidatorSet(cvm, members, owner)
// Block 1: reserve 8 ids from a fresh counter → [0, 8).
base, next, err := allocate(t, cvm, rng, 8)
if err != nil {
t.Fatalf("owner allocate rejected: %v", err)
}
if base != 0 || next != 8 {
t.Fatalf("first allocation = [%d,%d), want [0,8)", base, next)
}
// Block 2: reserve 5 more → [8, 13). Contiguous with block 1, monotonic.
base2, next2, err := allocate(t, cvm, rng, 5)
if err != nil {
t.Fatalf("second owner allocate rejected: %v", err)
}
if base2 != next {
t.Fatalf("allocation not contiguous: block2 base %d != block1 next %d", base2, next)
}
if base2 != 8 || next2 != 13 {
t.Fatalf("second allocation = [%d,%d), want [8,13)", base2, next2)
}
// Committed counter reflects the total.
if n, _ := cvm.inner.state.GetAlloc(rng); n != 13 {
t.Fatalf("committed counter = %d, want 13", n)
}
}
// TestNonOwnerAllocateRejected is the pinned-writer SAFETY proof: an AllocateTx
// for range R proposed by a node that is NOT Owner(R) is rejected at Verify
// (errNonOwnerAllocate), its staged write never reaches the durable base, and the
// counter stays untouched. This is why the same range can never have two writers
// — and therefore the same id can never be allocated twice.
func TestNonOwnerAllocateRejected(t *testing.T) {
cvm, _ := newTestVM(t)
members := validatorMembers(5)
const rng = "bucket-A"
owner := ownerOf(t, rng, members)
// Pick a proposer that is NOT the owner.
var nonOwner ids.NodeID
for _, m := range members {
if m.NodeID != owner {
nonOwner = m.NodeID
break
}
}
if nonOwner == owner || nonOwner == (ids.NodeID{}) {
t.Fatal("could not find a non-owner validator")
}
withValidatorSet(cvm, members, nonOwner)
_, _, err := allocate(t, cvm, rng, 4)
if err == nil {
t.Fatal("non-owner AllocateTx was accepted — pinned-writer safety violated")
}
if !errors.Is(err, errNonOwnerAllocate) {
t.Fatalf("Verify error = %v, want errNonOwnerAllocate", err)
}
// The counter must be untouched (no leaked allocation).
if n, _ := cvm.inner.state.GetAlloc(rng); n != 0 {
t.Fatalf("rejected allocate moved the counter to %d, want 0", n)
}
}
// TestEmptyValidatorSetFailsClosed proves the gate fails CLOSED: with no validator
// set, no AllocateTx may commit (ownership cannot be proven, so we never default
// to "the proposer is the owner"). Matches pinning.TestEmptySet's invariant at the
// VM layer.
func TestEmptyValidatorSetFailsClosed(t *testing.T) {
cvm, _ := newTestVM(t)
// No BlockContextBuilder installed → empty context → no members.
_, _, err := allocate(t, cvm, "any-range", 1)
if err == nil {
t.Fatal("allocate committed with no validator set — gate did not fail closed")
}
if !errors.Is(err, errNoValidatorSet) {
t.Fatalf("error = %v, want errNoValidatorSet", err)
}
}
// TestParallelDistinctRangesIndependentOwners proves disjoint ranges allocate
// independently through their RESPECTIVE owners: we find two ranges with
// DIFFERENT owners, and each owner's AllocateTx for its own range commits while
// neither touches the other's counter. This is the win over raft — no single
// leader serializes A and B; their owners decide independently.
func TestParallelDistinctRangesIndependentOwners(t *testing.T) {
members := validatorMembers(5)
// Find two ranges owned by different validators.
rngA, rngB := "", ""
ownerA, ownerB := ids.NodeID{}, ids.NodeID{}
for i := 0; i < 200 && (rngA == "" || rngB == ""); i++ {
r := "range-" + string(rune('a'+i%26)) + string(rune('0'+i/26))
o := ownerOf(t, r, members)
if rngA == "" {
rngA, ownerA = r, o
continue
}
if o != ownerA {
rngB, ownerB = r, o
}
}
if rngB == "" {
t.Fatal("could not find two ranges with distinct owners (expected ~80% chance per pair)")
}
// Owner A allocates in range A on its own VM view.
cvmA, _ := newTestVM(t)
withValidatorSet(cvmA, members, ownerA)
baseA, nextA, err := allocate(t, cvmA, rngA, 3)
if err != nil {
t.Fatalf("owner A allocate in range A rejected: %v", err)
}
if baseA != 0 || nextA != 3 {
t.Fatalf("range A allocation = [%d,%d), want [0,3)", baseA, nextA)
}
// Owner A is NOT the owner of range B → its allocate for B is rejected.
if _, _, err := allocate(t, cvmA, rngB, 3); !errors.Is(err, errNonOwnerAllocate) {
t.Fatalf("owner A allocate in range B: err = %v, want errNonOwnerAllocate", err)
}
// Range B's counter on this view is untouched.
if n, _ := cvmA.inner.state.GetAlloc(rngB); n != 0 {
t.Fatalf("range B counter moved on owner A's view = %d, want 0", n)
}
// Owner B allocates in range B on its own VM view — independently, no leader.
cvmB, _ := newTestVM(t)
withValidatorSet(cvmB, members, ownerB)
baseB, nextB, err := allocate(t, cvmB, rngB, 7)
if err != nil {
t.Fatalf("owner B allocate in range B rejected: %v", err)
}
if baseB != 0 || nextB != 7 {
t.Fatalf("range B allocation = [%d,%d), want [0,7)", baseB, nextB)
}
}
// TestSameRangeSerializesThroughOneOwner proves the same range serializes through
// its ONE owner: only the owner's AllocateTxs advance the counter, contiguously,
// and a non-owner's attempt is rejected — so even back-to-back allocations to the
// same range yield a single, non-overlapping id sequence (no double-allocation).
func TestSameRangeSerializesThroughOneOwner(t *testing.T) {
cvm, _ := newTestVM(t)
members := validatorMembers(5)
const rng = "hot-bucket"
owner := ownerOf(t, rng, members)
withValidatorSet(cvm, members, owner)
// Owner reserves 10, then 10 more: [0,10) then [10,20) — serialized, contiguous.
if base, next, err := allocate(t, cvm, rng, 10); err != nil || base != 0 || next != 10 {
t.Fatalf("alloc 1 = [%d,%d) err=%v, want [0,10) nil", base, next, err)
}
if base, next, err := allocate(t, cvm, rng, 10); err != nil || base != 10 || next != 20 {
t.Fatalf("alloc 2 = [%d,%d) err=%v, want [10,20) nil", base, next, err)
}
// A non-owner cannot interleave a write into the same range.
var nonOwner ids.NodeID
for _, m := range members {
if m.NodeID != owner {
nonOwner = m.NodeID
break
}
}
withValidatorSet(cvm, members, nonOwner)
if _, _, err := allocate(t, cvm, rng, 5); !errors.Is(err, errNonOwnerAllocate) {
t.Fatalf("non-owner interleave: err = %v, want errNonOwnerAllocate", err)
}
// Counter unchanged by the rejected non-owner write.
if n, _ := cvm.inner.state.GetAlloc(rng); n != 20 {
t.Fatalf("counter after rejected non-owner write = %d, want 20", n)
}
}
// TestStateRootCoversAllocThroughVerify proves the block state root covers the
// allocator end-to-end: an honestly-built AllocateTx block verifies (its claimed
// root matches the recomputed root that now includes the advanced counter), and a
// block whose claimed root is tampered is rejected by Verify with
// errStateRootMismatch — so an allocator divergence cannot get a block accepted.
func TestStateRootCoversAllocThroughVerify(t *testing.T) {
ctx := context.Background()
members := validatorMembers(5)
const rng = "rooted-range"
owner := ownerOf(t, rng, members)
// (1) Honest allocate block verifies, and its root differs from the empty root
// (proving the advanced counter is folded into the committed root).
good, _ := newTestVM(t)
withValidatorSet(good, members, owner)
emptyRoot, err := good.inner.state.Root()
if err != nil {
t.Fatalf("empty Root: %v", err)
}
tx := txs.NewAllocateTx(rng, 4)
if err := good.SubmitTx(tx.Bytes()); err != nil {
t.Fatalf("SubmitTx: %v", err)
}
blk, err := good.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
if err := blk.Verify(ctx); err != nil {
t.Fatalf("honest allocate block rejected: %v", err)
}
if afterRoot, _ := good.inner.state.Root(); afterRoot == emptyRoot {
t.Fatal("allocate did not change the manifest+alloc state root")
}
if err := blk.Accept(ctx); err != nil {
t.Fatalf("Accept: %v", err)
}
// (2) A block with a TAMPERED claimed root over the same allocate tx is
// rejected with errStateRootMismatch (and its staged write is dropped).
bad, _ := newTestVM(t)
withValidatorSet(bad, members, owner)
tx2 := txs.NewAllocateTx(rng, 4)
if err := bad.SubmitTx(tx2.Bytes()); err != nil {
t.Fatalf("SubmitTx: %v", err)
}
badBlk, err := bad.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
concrete, ok := badBlk.(*Block)
if !ok {
t.Fatalf("block type = %T, want *Block", badBlk)
}
concrete.stateRoot = ids.ID{0xDE, 0xAD, 0xBE, 0xEF}
hash := sha256Bytes(concrete.Bytes())
copy(concrete.id[:], hash[:])
if err := concrete.Verify(ctx); !errors.Is(err, errStateRootMismatch) {
t.Fatalf("Verify error = %v, want errStateRootMismatch", err)
}
if n, _ := bad.inner.state.GetAlloc(rng); n != 0 {
t.Fatalf("rejected (bad-root) block leaked an allocation: counter = %d, want 0", n)
}
}
+18 -2
View File
@@ -43,9 +43,25 @@ type Block struct {
// rejects a block whose claimed root does not match the computed one.
stateRoot ids.ID
// txs are the serialized transactions (each a txs.PutManifestTx wire image).
// 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
// result is populated after Verify.
result *BlockResult
@@ -122,7 +138,7 @@ func (b *Block) Bytes() []byte {
// manifest writes land in the in-memory version layer and become durable only at
// Accept.
func (b *Block) Verify(ctx context.Context) error {
result, err := b.vm.inner.ProcessBlock(ctx, b.height, b.timestamp, b.txs)
result, err := b.vm.inner.ProcessBlock(ctx, b.height, b.timestamp, b.txs, b.blockCtx)
if err != nil {
return err
}
+72 -6
View File
@@ -14,9 +14,9 @@ import (
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/version"
vmcore "github.com/luxfi/vm"
"github.com/luxfi/vm/chain"
"github.com/luxfi/version"
)
var (
@@ -53,6 +53,31 @@ type ChainVM struct {
toEngine chan<- vmcore.Message
initialized bool
// blockCtxBuilder resolves the deterministic BlockContext (validator set +
// proposer + epoch) for a block at the given height. It is the SEAM to the
// real consensus runtime: Stage 1 lets the test/harness inject a deterministic
// resolver; the master-cutover stage wires it to
// runtime.ValidatorState.GetValidatorSet(ctx, pChainHeight, netID) + the
// block's proposer (see BlockContext doc). When nil, blocks build with the
// empty context — the M0/no-allocate path, where any AllocateTx fails closed.
blockCtxBuilder BlockContextBuilder
}
// BlockContextBuilder resolves the deterministic consensus inputs (validator set,
// proposer, epoch) the AllocateTx owner gate needs for a block at the given
// height. It MUST be a pure local computation (no network I/O) so block Verify
// stays deterministic. height is the S-Chain block height; the implementation
// maps it to the epoch's pChainHeight and the block's proposer.
type BlockContextBuilder func(ctx context.Context, height uint64) (BlockContext, error)
// SetBlockContextBuilder installs the resolver that supplies the AllocateTx owner
// gate its validator set + proposer identity. Stage 1 wire point; production
// installs the real consensus-runtime-backed resolver here.
func (cvm *ChainVM) SetBlockContextBuilder(b BlockContextBuilder) {
cvm.lock.Lock()
defer cvm.lock.Unlock()
cvm.blockCtxBuilder = b
}
// NewChainVM constructs a ChainVM wrapping a fresh inner storage VM.
@@ -150,16 +175,33 @@ func (cvm *ChainVM) BuildBlock(ctx context.Context) (chain.Block, error) {
txs := cvm.pendingTxs
// Resolve the deterministic BlockContext (validator set + proposer + epoch)
// the AllocateTx owner gate needs. The proposer pins against the SAME frozen
// set every verifier will reconstruct, so the owner verdict is identical
// network-wide. With no builder installed the context is empty (M0 path); a
// block carrying an AllocateTx then fails closed in ProcessBlock.
blockCtx, err := cvm.resolveBlockContext(ctx, newHeight)
if err != nil {
return nil, fmt.Errorf("schain: resolve block context: %w", err)
}
// Proposer build: apply the drained txs to the version layer to obtain the
// post-apply manifest STATE ROOT, then carry that root in the block header so
// every validator can recompute and check it (mirror of dexvm BuildBlock ->
// post-apply STATE ROOT, then carry that root in the block header so every
// validator can recompute and check it (mirror of dexvm BuildBlock ->
// BuildBlockResult, chainvm.go:258). The staged writes remain in the version
// layer; the proposer's own Block.Verify re-applies the identical txs (same
// keys/values — idempotent over the versiondb) and a Rejected block's Abort
// drops them. The SAME timestamp is carried in the bytes and fed to every
// validator's ProcessBlock, so the root is reproducible network-wide.
result, err := cvm.inner.ProcessBlock(ctx, newHeight, newTimestamp, txs)
// drops them. The SAME timestamp + block context are fed to every validator's
// ProcessBlock, so the root and the owner verdict are reproducible network-wide.
result, err := cvm.inner.ProcessBlock(ctx, newHeight, newTimestamp, txs, blockCtx)
if err != nil {
// A block-level reject (a non-owner AllocateTx, or one with no validator
// set) means this proposer must NOT build this block. Drain the mempool so
// the offending tx is dropped rather than retried into the next block — a
// non-owner can never validly emit it, so retaining it would wedge block
// production. (Per-tx soft failures never reach here; ProcessBlock swallows
// those and builds anyway.)
cvm.pendingTxs = nil
return nil, fmt.Errorf("schain: build block result: %w", err)
}
@@ -170,6 +212,7 @@ func (cvm *ChainVM) BuildBlock(ctx context.Context) (chain.Block, error) {
timestamp: newTimestamp,
stateRoot: result.StateRoot,
txs: txs,
blockCtx: blockCtx,
result: result,
status: StatusProcessing,
}
@@ -185,6 +228,19 @@ func (cvm *ChainVM) BuildBlock(ctx context.Context) (chain.Block, error) {
return block, nil
}
// resolveBlockContext computes the deterministic BlockContext for a block at
// height. It delegates to the installed BlockContextBuilder (the consensus-runtime
// seam); with no builder it returns the empty context (the M0/no-allocate path).
// The caller holds cvm.lock, so this reads cvm.blockCtxBuilder directly without
// re-locking. The builder MUST be pure/local — no network I/O — so the resolved
// context is identical on the proposer and every verifier.
func (cvm *ChainVM) resolveBlockContext(ctx context.Context, height uint64) (BlockContext, error) {
if cvm.blockCtxBuilder == nil {
return BlockContext{}, nil
}
return cvm.blockCtxBuilder(ctx, height)
}
// ParseBlock parses a block from bytes, deduplicating against the index.
func (cvm *ChainVM) ParseBlock(ctx context.Context, data []byte) (chain.Block, error) {
cvm.lock.Lock()
@@ -197,6 +253,16 @@ func (cvm *ChainVM) ParseBlock(ctx context.Context, data []byte) (chain.Block, e
if existing, ok := cvm.blocks[block.id]; ok {
return existing, nil
}
// Reconstruct the deterministic BlockContext on the verifying side from the
// block's height (→ epoch + frozen validator set + proposer). This is the same
// resolver the proposer used, so every node resolves the SAME owner for an
// AllocateTx in this block. Without it a parsed AllocateTx block fails closed
// (errNoValidatorSet) — the safe default if the seam is not yet wired.
blockCtx, err := cvm.resolveBlockContext(ctx, block.height)
if err != nil {
return nil, fmt.Errorf("schain: resolve block context for parsed block: %w", err)
}
block.blockCtx = blockCtx
cvm.blocks[block.id] = block
return block, nil
}
+173
View File
@@ -0,0 +1,173 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// alloc_test.go — the state-layer proof for the per-range allocator counter
// (alloc/<range>), the leaderless pinned-writer replacement for raft's global
// volume-id / fileId sequence. It proves the counter behaves (absent reads as 0,
// monotonic, per-range disjoint, corruption-rejecting) AND — the load-bearing
// property — that the counter is folded into Root(): changing any allocator
// counter changes the state root, so a validator that diverges on an allocation
// can never share a root with an honest one.
package state
import (
"testing"
"github.com/luxfi/database/zapdb"
"github.com/luxfi/ids"
"github.com/luxfi/metric"
)
func newState(t *testing.T) *State {
t.Helper()
db, err := zapdb.New(t.TempDir(), nil, "schain-alloc-test", metric.NewRegistry())
if err != nil {
t.Fatalf("open zapdb: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
s := New(db)
if err := s.Initialize(); err != nil {
t.Fatalf("init state: %v", err)
}
return s
}
// TestAllocAbsentReadsZero proves an unallocated range reads as 0 — its first
// allocation starts handing out ids at 0.
func TestAllocAbsentReadsZero(t *testing.T) {
s := newState(t)
n, err := s.GetAlloc("never-allocated")
if err != nil {
t.Fatalf("GetAlloc: %v", err)
}
if n != 0 {
t.Fatalf("absent range counter = %d, want 0", n)
}
}
// TestAllocMonotonicPerRange proves Set/Get round-trips and that distinct ranges
// carry DISJOINT counters — advancing one never moves another (the property that
// lets disjoint ranges allocate independently).
func TestAllocMonotonicPerRange(t *testing.T) {
s := newState(t)
if err := s.SetAlloc("A", 5); err != nil {
t.Fatalf("SetAlloc A: %v", err)
}
if err := s.SetAlloc("B", 100); err != nil {
t.Fatalf("SetAlloc B: %v", err)
}
if n, _ := s.GetAlloc("A"); n != 5 {
t.Fatalf("A = %d, want 5", n)
}
if n, _ := s.GetAlloc("B"); n != 100 {
t.Fatalf("B = %d, want 100", n)
}
// Advance A; B is untouched.
if err := s.SetAlloc("A", 12); err != nil {
t.Fatalf("SetAlloc A again: %v", err)
}
if n, _ := s.GetAlloc("A"); n != 12 {
t.Fatalf("A after advance = %d, want 12", n)
}
if n, _ := s.GetAlloc("B"); n != 100 {
t.Fatalf("B moved when A advanced = %d, want 100 (counters not disjoint)", n)
}
}
// TestRootCoversAlloc is the load-bearing state-layer proof: the allocator
// counter is part of the state root. Two stores with identical manifests but a
// DIFFERENT allocator counter for a range produce DIFFERENT roots — so allocator
// divergence cannot hide behind a matching root. And an absent counter (reads 0)
// must NOT equal a counter explicitly set to 0-advanced: any non-equal counter
// value moves the root.
func TestRootCoversAlloc(t *testing.T) {
mkBase := func() *State {
s := newState(t)
// Identical manifest content in every store, so only the allocator differs.
if err := s.PutManifest("b", "o", Manifest{FileIDs: []string{"1"}, Size: 1, ETag: "e"}); err != nil {
t.Fatalf("seed manifest: %v", err)
}
return s
}
root := func(s *State) ids.ID {
r, err := s.Root()
if err != nil {
t.Fatalf("Root: %v", err)
}
return r
}
// Baseline: manifest only, no allocations.
baseRoot := root(mkBase())
// Adding an allocator counter moves the root.
withAlloc := mkBase()
if err := withAlloc.SetAlloc("R", 7); err != nil {
t.Fatalf("SetAlloc: %v", err)
}
if root(withAlloc) == baseRoot {
t.Fatal("setting an allocator counter did not change the root — alloc not covered")
}
// A DIFFERENT counter value for the same range yields a DIFFERENT root.
withAlloc2 := mkBase()
if err := withAlloc2.SetAlloc("R", 8); err != nil {
t.Fatalf("SetAlloc: %v", err)
}
if root(withAlloc) == root(withAlloc2) {
t.Fatal("distinct allocator counters share a root — divergence can hide")
}
// A counter on a DIFFERENT range also yields a different root (key is covered,
// not just value).
otherRange := mkBase()
if err := otherRange.SetAlloc("R2", 7); err != nil {
t.Fatalf("SetAlloc: %v", err)
}
if root(withAlloc) == root(otherRange) {
t.Fatal("same counter on different ranges share a root — range key not covered")
}
}
// TestRootAllocDeterministic proves the alloc fold is order-independent: the same
// allocator state written in different order into two stores yields the same root.
func TestRootAllocDeterministic(t *testing.T) {
a := newState(t)
b := newState(t)
sets := []struct {
rng string
n uint64
}{{"x", 1}, {"y", 2}, {"z", 3}}
for _, p := range sets {
if err := a.SetAlloc(p.rng, p.n); err != nil {
t.Fatalf("a.SetAlloc: %v", err)
}
}
for i := len(sets) - 1; i >= 0; i-- {
if err := b.SetAlloc(sets[i].rng, sets[i].n); err != nil {
t.Fatalf("b.SetAlloc: %v", err)
}
}
ra, rb := mustRoot(t, a), mustRoot(t, b)
if ra != rb {
t.Fatalf("alloc root not deterministic across write order: %s != %s", ra, rb)
}
if ra == ids.Empty {
t.Fatal("root empty for non-empty alloc state")
}
}
func mustRoot(t *testing.T, s *State) ids.ID {
t.Helper()
r, err := s.Root()
if err != nil {
t.Fatalf("Root: %v", err)
}
return r
}
+111 -35
View File
@@ -37,10 +37,20 @@ var (
// its expected shape — a corrupt record must never be silently reinterpreted.
ErrStateCorrupted = errors.New("state corrupted")
// Database prefixes. Manifests are keyed manifest/<bucket>/<object>; the
// last-block pointer is a single fixed key.
// Database prefixes. Manifests are keyed manifest/<bucket>/<object>; allocator
// counters are keyed alloc/<range>; the last-block pointer is a single fixed
// key. Manifests and allocator counters are BOTH committed object state and
// BOTH folded into Root() — the last-block pointer is consensus binding folded
// separately into the block header, never into the state root.
prefixManifest = []byte("manifest/")
prefixAlloc = []byte("alloc/")
prefixLastBlock = []byte("lastBlock")
// rootPrefixes is the ordered list of committed-state prefixes Root() walks.
// The order is FIXED (it is part of the root's definition): two validators
// must absorb the same prefixes in the same order to agree. Adding a new
// committed keyspace means appending here AND bumping stateRootDomain.
rootPrefixes = [][]byte{prefixManifest, prefixAlloc}
)
// Manifest is the committed content manifest for one object: the file blobs that
@@ -140,33 +150,95 @@ func (s *State) GetManifest(bucket, object string) (m Manifest, found bool, err
}
// ---------------------------------------------------------------------------
// Manifest state root — the deterministic commitment that makes the chain safe
// for more than one validator.
// Allocator counters — the per-range monotonic id sequence (alloc/<range>).
//
// This is the leaderless pinned-writer replacement for raft's global volume-id
// (MaxVolumeIdCommand) + fileId sequence (MemorySequencer). Each range carries
// its OWN counter, so disjoint ranges allocate independently (their counters are
// disjoint state keys) while same-range allocations serialize through the one
// HRW owner. The counter is COMMITTED VM state (not owner-local memory), so it
// survives owner re-pin at an epoch boundary with no id reuse (DESIGN §6.5).
// ---------------------------------------------------------------------------
// Root returns a deterministic SHA-256 commitment over the COMMITTED manifest
// keyspace: every manifest/<bucket>/<object> -> {fileIds,size,etag} entry the
// chain holds after this block's writes are staged. It is the value the block
// header binds, so two validators whose manifest state actually diverges (a
// different object set, a changed etag/size/fileIds for the same object) ALWAYS
// produce different roots — divergence can never hide behind a matching block
// hash. This is the manifest-VM analog of dexvm's State.StateHash (dexvm/state/
// state.go:395), narrowed to the storage VM's one keyspace.
// allocKey builds the deterministic key alloc/<range>. range is length-prefixed
// for symmetry with manifestKey, so no two distinct ranges can ever collide on a
// shared key boundary.
func allocKey(rng string) []byte {
k := make([]byte, 0, len(prefixAlloc)+4+len(rng))
k = append(k, prefixAlloc...)
var lp [4]byte
binary.BigEndian.PutUint32(lp[:], uint32(len(rng)))
k = append(k, lp[:]...)
k = append(k, rng...)
return k
}
// GetAlloc returns the current allocator counter for range — the next id that
// will be handed out. An absent range reads as 0 (its first allocation starts at
// id 0). Reads through the version layer, so it observes a counter staged in this
// block before commit and the durable value after commit.
func (s *State) GetAlloc(rng string) (uint64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := s.db.Get(allocKey(rng))
if err != nil {
if errors.Is(err, database.ErrNotFound) {
return 0, nil
}
return 0, err
}
if len(data) != 8 {
return 0, ErrStateCorrupted
}
return binary.BigEndian.Uint64(data), nil
}
// SetAlloc stages the allocator counter for range into the version layer. It
// becomes durable only when the VM commits the block's batch at Accept — the
// same versiondb/CommitBatch discipline as PutManifest. The value is a fixed
// 8-byte big-endian uint64 so the stored bytes are canonical (GetAlloc rejects
// any other width as corruption).
func (s *State) SetAlloc(rng string, n uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], n)
return s.db.Put(allocKey(rng), buf[:])
}
// ---------------------------------------------------------------------------
// State root — the deterministic commitment that makes the chain safe for more
// than one validator. Covers the manifest AND allocator keyspaces.
// ---------------------------------------------------------------------------
// Root returns a deterministic SHA-256 commitment over the COMMITTED object
// keyspaces: every manifest/<bucket>/<object> -> {fileIds,size,etag} entry AND
// every alloc/<range> -> counter entry the chain holds after this block's writes
// are staged. It is the value the block header binds, so two validators whose
// committed state actually diverges — a different object set, a changed
// etag/size/fileIds for an object, OR a different allocator counter for a range —
// ALWAYS produce different roots. Divergence can never hide behind a matching
// block hash. This is the manifest-VM analog of dexvm's State.StateHash
// (dexvm/state/state.go:395), covering the storage VM's two committed keyspaces.
//
// The walk uses the zapdb prefix iterator NewIteratorWithStartAndPrefix(nil,
// prefixManifest), so it reads through the versiondb (this block's staged
// in-memory writes merged over the durable base, deleted keys dropped) and only
// the manifest keyspace — the last-block pointer is consensus binding folded
// separately into the header via blockHash/height, NOT object state, so it is
// excluded here exactly as dexvm excludes its proposer-local receipt prefix.
// The walk iterates each rootPrefix in turn via the zapdb prefix iterator
// NewIteratorWithStartAndPrefix(nil, prefix), so it reads through the versiondb
// (this block's staged in-memory writes merged over the durable base, deleted
// keys dropped). Only the manifest + alloc keyspaces are folded — the last-block
// pointer is consensus binding folded separately into the header via
// blockHash/height, NOT object state, so it is excluded here exactly as dexvm
// excludes its proposer-local receipt prefix.
//
// Keys come out in lexicographic order, so the digest is independent of write
// history. Each field (domain, key, value) is framed with SP 800-185 left_encode
// of its BIT length before it is absorbed, so no two distinct (key,value) splits
// can collide by concatenation — the same canonicalization the validator NodeID
// scheme uses (ids/node_id_scheme.go). The hash is SHAKE256 (SHA-3): not
// length-extendable (unlike SHA-256), domain-separated by stateRootDomain, and
// PQ-strength (256-bit output → 128-bit collision/preimage even under Grover).
// The prefix order (rootPrefixes) is fixed and the keys within each prefix come
// out in lexicographic order, so the digest is independent of write history. Each
// field (domain, key, value) is framed with SP 800-185 left_encode of its BIT
// length before it is absorbed, so no two distinct (key,value) splits can collide
// by concatenation — the same canonicalization the validator NodeID scheme uses
// (ids/node_id_scheme.go). And because every key is absorbed WITH its full
// prefix, a manifest key and an alloc key can never alias even if their suffixes
// coincide. The hash is SHAKE256 (SHA-3): not length-extendable (unlike SHA-256),
// domain-separated by stateRootDomain, and PQ-strength (256-bit output → 128-bit
// collision/preimage even under Grover).
func (s *State) Root() (ids.ID, error) {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -180,14 +252,17 @@ func (s *State) Root() (ids.ID, error) {
_, _ = h.Write(b)
}
it := s.db.NewIteratorWithStartAndPrefix(nil, prefixManifest)
defer it.Release()
for it.Next() {
absorb(it.Key())
absorb(it.Value())
}
if err := it.Error(); err != nil {
return ids.Empty, fmt.Errorf("manifest state root: iterate: %w", err)
for _, prefix := range rootPrefixes {
it := s.db.NewIteratorWithStartAndPrefix(nil, prefix)
for it.Next() {
absorb(it.Key())
absorb(it.Value())
}
if err := it.Error(); err != nil {
it.Release()
return ids.Empty, fmt.Errorf("state root: iterate %q: %w", prefix, err)
}
it.Release()
}
var out ids.ID
_, _ = h.Read(out[:])
@@ -195,8 +270,9 @@ func (s *State) Root() (ids.ID, error) {
}
// stateRootDomain is the SP 800-185 customization string binding the state root
// to this construction and version. Bumping it invalidates every prior root.
const stateRootDomain = "SCHAIN_STATE_ROOT_V1"
// to this construction and version. Bumping it invalidates every prior root. V2
// folds the allocator keyspace (alloc/<range>) in alongside manifests.
const stateRootDomain = "SCHAIN_STATE_ROOT_V2"
// leftEncode is SP 800-185 left_encode: a length-self-describing prefix so the
// concatenation of framed fields is unambiguous (no two field boundaries can be
+73
View File
@@ -0,0 +1,73 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// allocate_test.go — the AllocateTx wire-codec proof. AllocateTx is the
// leaderless pinned-writer replacement for raft's volume-id / fileId sequence;
// these tests prove its codec is faithful (round-trips to an identical mutation
// with a stable, deterministic id) and its isolated Verify rejects the two
// degenerate forms (empty range, zero count) — exactly the discipline
// PutManifestTx holds, so the parser handles both tx types one way.
package txs
import (
"errors"
"testing"
)
// TestAllocateRoundTrip proves an AllocateTx parses back to an identical mutation
// with the same deterministic id — the same wire faithfulness PutManifest has.
func TestAllocateRoundTrip(t *testing.T) {
orig := NewAllocateTx("bucket-A/band-3", 64)
parser := &TxParser{}
parsed, err := parser.Parse(orig.Bytes())
if err != nil {
t.Fatalf("Parse: %v", err)
}
if parsed.Type() != TxAllocate {
t.Fatalf("parsed type = %v, want TxAllocate", parsed.Type())
}
if parsed.ID() != orig.ID() {
t.Fatalf("round-trip id mismatch: %s != %s", parsed.ID(), orig.ID())
}
at, ok := parsed.(*AllocateTx)
if !ok {
t.Fatalf("parsed type = %T, want *AllocateTx", parsed)
}
if at.Range != "bucket-A/band-3" || at.Count != 64 {
t.Fatalf("parsed fields wrong: %+v", at)
}
}
// TestAllocateDeterministicID proves the id is a pure function of (range, count):
// two independently-constructed AllocateTxs with the same fields share an id, and
// any field change moves it. This is what lets the id be the tx's content address.
func TestAllocateDeterministicID(t *testing.T) {
a := NewAllocateTx("r", 10)
b := NewAllocateTx("r", 10)
if a.ID() != b.ID() {
t.Fatalf("identical AllocateTx have different ids: %s != %s", a.ID(), b.ID())
}
if NewAllocateTx("r", 11).ID() == a.ID() {
t.Fatal("changing count did not change the id")
}
if NewAllocateTx("r2", 10).ID() == a.ID() {
t.Fatal("changing range did not change the id")
}
}
// TestAllocateVerify proves the isolated (state-free) Verify rejects the two
// degenerate allocations and accepts a well-formed one. The OWNER gate is NOT
// here — it is block-level (the VM's applyAllocate), because a single tx cannot
// see the block's validator set.
func TestAllocateVerify(t *testing.T) {
if err := NewAllocateTx("r", 1).Verify(); err != nil {
t.Fatalf("well-formed allocate rejected: %v", err)
}
if err := (&AllocateTx{Range: "", Count: 1}).Verify(); !errors.Is(err, ErrEmptyRange) {
t.Fatalf("empty range: err = %v, want ErrEmptyRange", err)
}
if err := (&AllocateTx{Range: "r", Count: 0}).Verify(); !errors.Is(err, ErrZeroCount) {
t.Fatalf("zero count: err = %v, want ErrZeroCount", err)
}
}
+64
View File
@@ -31,6 +31,12 @@ var (
// ErrNoFileIDs rejects a manifest that names no file blobs (M0 carries the
// content manifest; an object with zero files has nothing to commit).
ErrNoFileIDs = errors.New("manifest: no file ids")
// ErrEmptyRange rejects an allocation with no addressable partition key.
ErrEmptyRange = errors.New("allocate: empty range")
// ErrZeroCount rejects an allocation that requests no ids (a no-op write that
// would still consume a tx slot and move the counter by 0 — forbidden so every
// AllocateTx advances the counter and yields a non-empty id range).
ErrZeroCount = errors.New("allocate: zero count")
)
// TxType is the transaction discriminator (the leading wire byte).
@@ -40,12 +46,19 @@ const (
// TxPutManifest records a (bucket, object) -> manifest mapping. The single
// mutation of M0.
TxPutManifest TxType = iota
// TxAllocate reserves a contiguous, monotonic id range in a per-range
// allocator counter — the leaderless pinned-writer replacement for raft's
// global volume-id / fileId sequence. Emitted ONLY by the HRW owner of the
// range; the owner gate is enforced at block Verify, not in the tx codec.
TxAllocate
)
func (t TxType) String() string {
switch t {
case TxPutManifest:
return "put_manifest"
case TxAllocate:
return "allocate"
default:
return "unknown"
}
@@ -123,6 +136,55 @@ func (tx *PutManifestTx) Verify() error {
return nil
}
// AllocateTx reserves Count ids in the per-range allocator counter alloc/<Range>.
// Range is the pinned key-range / partition (a volume-collection or bucket-shard)
// the allocation belongs to; the HRW owner of Range is the ONLY validator
// permitted to emit this tx, and a block containing an AllocateTx whose proposer
// is not that owner is rejected at Verify (the leaderless pinned-writer safety
// gate — see DESIGN_pinned_writer.md §2-3). The id range it reserves is a pure
// function of the committed counter: [base, base+Count), where base is the
// counter's value before this tx — so every validator derives identical ids.
//
// Stage 1 carries Range + Count only. The Epoch/Owner/Fingerprint fields the
// DESIGN sketches (§2) are the master-cutover stage's concern: in Stage 1 the
// owner gate is evaluated by the VM against the block's threaded validator-set +
// proposer identity, so the owner identity is NOT yet self-attested in the tx.
// Keeping the tx minimal now means the codec does not change when those fields
// are added — they are additive JSON fields a later parse tolerates.
type AllocateTx struct {
BaseTx
Range string `json:"range"`
Count uint32 `json:"count"`
}
// NewAllocateTx builds a wire-ready Allocate transaction. finalize stamps the
// deterministic wire bytes + TxID, so the returned tx is immediately
// Parse-round-trippable (mirrors NewPutManifestTx and every dexvm New*Tx).
func NewAllocateTx(rng string, count uint32) *AllocateTx {
tx := &AllocateTx{
BaseTx: BaseTx{TxType: TxAllocate},
Range: rng,
Count: count,
}
return finalize(tx, &tx.BaseTx)
}
// Verify validates an Allocate in isolation: it must name a non-empty range and
// reserve at least one id. No state is consulted and the OWNER GATE is NOT
// checked here — Verify is pure and per-tx, but ownership is a function of the
// BLOCK's validator set + proposer, which a single tx cannot see. The owner gate
// lives in the VM's block-level apply (see schain.VM.applyAllocate), the same
// discipline that keeps PutManifestTx.Verify state-free.
func (tx *AllocateTx) Verify() error {
if tx.Range == "" {
return ErrEmptyRange
}
if tx.Count == 0 {
return ErrZeroCount
}
return nil
}
// TxParser parses raw transaction bytes off the wire.
type TxParser struct{}
@@ -134,6 +196,8 @@ func (p *TxParser) Parse(data []byte) (Tx, error) {
switch TxType(data[0]) {
case TxPutManifest:
return parse[PutManifestTx](data, TxPutManifest)
case TxAllocate:
return parse[AllocateTx](data, TxAllocate)
default:
return nil, ErrInvalidTxType
}
+139 -7
View File
@@ -46,6 +46,7 @@ import (
vmcore "github.com/luxfi/vm"
"github.com/luxfi/vm/chain"
"github.com/luxfi/chains/schain/pinning"
"github.com/luxfi/chains/schain/state"
"github.com/luxfi/chains/schain/txs"
)
@@ -53,9 +54,60 @@ import (
var (
errShutdown = errors.New("VM is shutting down")
// errNonOwnerAllocate is returned by ProcessBlock/Verify when a block carries
// an AllocateTx for a range whose HRW owner (under the block's frozen
// validator set + epoch) is NOT the block's proposer. This is the leaderless
// pinned-writer safety property: only the one owner of a range may emit an
// allocation, so two writers for the same range — and therefore a double
// allocation — are impossible by construction.
errNonOwnerAllocate = errors.New("allocate: proposer is not the range owner")
// errNoValidatorSet is returned when a block carries an AllocateTx but the
// block context supplies no validator set to resolve ownership against.
// Fail closed: with no set, nobody can be proven the owner, so no allocation
// may commit (never default to "I am the owner" — see pinning.TestEmptySet).
errNoValidatorSet = errors.New("allocate: empty validator set in block context")
parser = &txs.TxParser{}
)
// BlockContext carries the deterministic consensus inputs an AllocateTx's owner
// gate needs: the validator set frozen at the block's epoch (block.pChainHeight)
// and the identity of the block's proposer. Both are pure inputs — every node
// verifying the block resolves the SAME owner against the SAME frozen set, so the
// gate is evaluated inside deterministic block apply with ZERO network I/O
// (the purity premise the whole model rests on — DESIGN_pinned_writer.md §6.4).
//
// WIRE SEAM (master-cutover stage): in Stage 1 BlockContext is an explicit
// parameter threaded from the test harness / proposer. In production it must be
// populated from the REAL consensus runtime, ONCE, at the points the validator
// set + proposer are cleanly available WITHOUT a network round-trip inside
// Verify:
//
// - Members: pinning.Member projection of
// vm.consensusRuntime.ValidatorState.GetValidatorSet(ctx, block.pChainHeight,
// netID) — id+weight only. This MUST be a LOCAL lookup against already-synced
// P-Chain state at the historical height; if it can block on the network,
// resolution moves OUT of Verify (pin in BuildBlock, carry owner+fingerprint
// in the tx, Verify only re-checks the fingerprint). See §6.4 fallback.
// - Proposer: the block's proposer NodeID, from the consensus block header
// (block.pChainHeight's proposer / the engine's BuildBlock identity), NOT
// vm.consensusRuntime.NodeID — that is "me", which is only the proposer on
// the building node, not on a verifying node.
// - Epoch: block.pChainHeight, the height Members was frozen at, so a peer
// can recompute pinning.EpochFingerprint and reject an epoch-skewed pin.
//
// An empty BlockContext (nil Members) is the M0/no-allocate path: a block with
// no AllocateTx is unaffected, so existing PutManifest blocks need no context.
type BlockContext struct {
// Members is the validator set frozen at Epoch, projected to (NodeID, Weight).
Members []pinning.Member
// Proposer is the NodeID that built this block — the candidate range owner.
Proposer ids.NodeID
// Epoch is the P-Chain height Members was frozen at (block.pChainHeight).
Epoch uint64
}
// BlockResult is the deterministic result of processing one block. For the
// storage VM the per-block output is simply the height/time/blockHash binding —
// the manifest mutations are staged directly into the version layer by
@@ -156,7 +208,7 @@ func (vm *VM) Initialize(ctx context.Context, vmInit vmcore.Init) error {
// on every node, so Verify is a pure function (mirror of dexvm/vm.go:542). The
// manifest writes land in vm.db's in-memory layer and become durable only when
// acceptBlock commits the batch.
func (vm *VM) ProcessBlock(ctx context.Context, blockHeight uint64, blockTime time.Time, blockTxs [][]byte) (*BlockResult, error) {
func (vm *VM) ProcessBlock(ctx context.Context, blockHeight uint64, blockTime time.Time, blockTxs [][]byte, blockCtx BlockContext) (*BlockResult, error) {
vm.lock.Lock()
defer vm.lock.Unlock()
@@ -164,6 +216,21 @@ func (vm *VM) ProcessBlock(ctx context.Context, blockHeight uint64, blockTime ti
return nil, errShutdown
}
// Apply over COMMITTED state, not accumulated staging. A block is processed
// twice on the proposer — once in BuildBlock (to compute the claimed root) and
// again in Block.Verify — and both must produce the identical post-state. For
// an idempotent write (PutManifest: same key→same value) re-application is
// harmless, but the allocator is a READ-MODIFY-WRITE: re-applying over the
// prior staging would double-advance the counter (base 0→Count, then Count→2·
// Count) and the two roots would diverge. Aborting the versiondb's in-memory
// layer first discards any uncommitted staging so every ProcessBlock starts
// from the last-accepted state — making the apply a pure function of COMMITTED
// state + this block's txs, idempotent for every tx type. (Abort never touches
// the durable base; CommitBatch at Accept is still the only durability point.)
if vm.db != nil {
vm.db.Abort()
}
blockHash := deriveBlockHash(blockHeight, blockTime)
result := &BlockResult{
BlockHeight: blockHeight,
@@ -172,10 +239,21 @@ func (vm *VM) ProcessBlock(ctx context.Context, blockHeight uint64, blockTime ti
}
for i, txBytes := range blockTxs {
if err := vm.processTx(txBytes); err != nil {
// An individual tx failure does not fail the block: a malformed or
// invalid manifest simply stages no write. Mirrors dexvm's
// per-tx-failure-continues discipline (dexvm/vm.go:562).
if err := vm.processTx(txBytes, blockCtx); err != nil {
// Two failure classes, two dispositions:
//
// - A SAFETY-GATE violation (a non-owner AllocateTx, or an allocate
// with no validator set to prove ownership) FAILS THE WHOLE BLOCK.
// Letting it through as a skipped tx would silently admit a block a
// malicious proposer built to write a range it does not own — the
// exact double-write the pinned writer forbids. The block must be
// rejected so no validator accepts it.
// - Any OTHER per-tx failure (a malformed/invalid manifest) does not
// fail the block: it simply stages no write, mirroring dexvm's
// per-tx-failure-continues discipline (dexvm/vm.go:562).
if errors.Is(err, errNonOwnerAllocate) || errors.Is(err, errNoValidatorSet) {
return nil, err
}
if !vm.log.IsZero() {
vm.log.Warn("S-Chain transaction failed", "index", i, "error", err)
}
@@ -233,8 +311,8 @@ func (vm *VM) computeStateRoot(blockHash ids.ID) (ids.ID, error) {
}
// processTx parses one transaction and applies its mutation to the version
// layer. M0 has a single mutation: PutManifest.
func (vm *VM) processTx(txBytes []byte) error {
// layer. The S-Chain has two mutations: PutManifest (M0) and Allocate (Stage 1).
func (vm *VM) processTx(txBytes []byte, blockCtx BlockContext) error {
tx, err := parser.Parse(txBytes)
if err != nil {
return err
@@ -249,11 +327,65 @@ func (vm *VM) processTx(txBytes []byte) error {
Size: t.Size,
ETag: t.ETag,
})
case *txs.AllocateTx:
return vm.applyAllocate(t, blockCtx)
default:
return txs.ErrInvalidTxType
}
}
// applyAllocate deterministically reserves tx.Count ids in the per-range counter
// alloc/<tx.Range>, gated on the block proposer being the HRW OWNER of the range.
// It mirrors PutManifest's discipline exactly: version-layer only, NO I/O, pure
// function of (committed state, block context) — so every validator that applies
// the same tx against the same frozen validator set derives the IDENTICAL id
// range and the IDENTICAL post-state, and the owner gate resolves to the same
// verdict on every node.
//
// The owner gate is the leaderless-pinned-writer enforcement: a block may carry
// an AllocateTx for range R ONLY if its proposer is pinning.Owner(R, V(epoch)).
// A non-owner allocate is rejected (errNonOwnerAllocate), failing the block — so
// the same range can never have two writers, and the same id can never be
// allocated twice. With no validator set the gate fails closed (errNoValidatorSet):
// ownership cannot be proven, so nothing may be written (never assume self-owner).
//
// The reserved id range is [base, base+Count) where base is the counter's
// committed value before this tx — a pure function of prior committed state, so
// the ids are reproduced identically on every node and continue monotonically
// across blocks AND across owner re-pin (the counter is committed VM state, not
// owner-local memory — DESIGN §6.5).
func (vm *VM) applyAllocate(tx *txs.AllocateTx, blockCtx BlockContext) error {
// Owner gate. Fail closed when the set is empty: with no validators nobody can
// be proven the owner (pinning.Owner returns false on an empty set), so we must
// refuse rather than silently treat the proposer as owner.
if len(blockCtx.Members) == 0 {
return errNoValidatorSet
}
rangeKey := []byte(tx.Range)
if !pinning.IsOwner(rangeKey, blockCtx.Proposer, blockCtx.Members) {
return errNonOwnerAllocate
}
base, err := vm.state.GetAlloc(tx.Range)
if err != nil {
return fmt.Errorf("allocate: read counter for range %q: %w", tx.Range, err)
}
next := base + uint64(tx.Count)
if next < base {
// uint64 wraparound — the range has exhausted its 2^64 id space. Refuse
// rather than reissue ids from 0 (which would violate uniqueness). In
// practice unreachable, but a silent wrap would be a correctness hole.
return fmt.Errorf("allocate: range %q counter overflow", tx.Range)
}
if err := vm.state.SetAlloc(tx.Range, next); err != nil {
return fmt.Errorf("allocate: stage counter for range %q: %w", tx.Range, err)
}
if !vm.log.IsZero() {
vm.log.Debug("S-Chain allocate", "range", tx.Range, "base", base, "count", tx.Count, "next", next)
}
return nil
}
// acceptBlock is the SINGLE COMMIT POINT. It snapshots the version layer's
// staged writes into one batch and writes that batch atomically, then aborts the
// version layer's in-memory view (the platformvm defer-Abort pattern). This is