mirror of
https://github.com/luxfi/chains.git
synced 2026-07-27 03:39:41 +00:00
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:
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user