mirror of
https://github.com/luxfi/node.git
synced 2026-07-29 16:46:33 +00:00
xvm: parallel intra-block execution_root + activation-gated merkleRoot wiring (DEFAULT OFF)
Core goal — build the xvm block state root from independently-built subtrees in parallel, and wire it into the block path behind an off-by-default activation gate (zero live-consensus change). Parallel build (vms/xvm/block/executor/executionroot.go): the three family folds (utxo/asset/tx) run concurrently; each folds via a GOMAXPROCS worker-pool RFC-6962 level reduction composed from crypto/merkle's exported LeafHash/NodeHash (no duplicated keccak/tag — DRY). Byte-IDENTICAL to the serial xvmroot.ExecutionRoot: canonical leaf order + count-determined combine shape are unchanged; only the per-index loops parallelize (disjoint-slot writes). Proven by 200-random + sizes-0..4097 byte-identity tests, race-clean (go test -race). Benchmark (M1 Max, 65536 UTXOs): execution_root 87.4ms -> 19.1ms (4.58x). Gated wiring: upgrade.Config.MerkleRootActivationHeight defaults to MerkleRootNeverActivate (math.MaxUint64) on Mainnet/Testnet/Default AND xvm.DefaultConfig (safety-critical — VM.initialize discards init.Upgrade, so without the default the zero value would activate from genesis). Below activation: builder leaves Root=ids.Empty + executor keeps the verbatim reject-if-non-empty rule (byte-for-byte current behavior, tested). At/above: builder stamps BlockExecutionRoot; executor recomputes + verifies (rejects mismatch). nil-safe (nil config -> OFF). HONEST SEAM — NOT ACTIVATION-READY: postBlockUTXOLeaves/postBlockAssetLeaves return nil. A real projection needs (1) a committed mapping from the executor codec lux.UTXO/asset types to the GPU-snapshot leaf fields (OwnerRoot/AmountHi/Threshold/TotalSupply/...), and (2) a full-occupied-set state enumeration API (only GetUTXO/per-address UTXOIDs exist today). Both are consensus-semantics decisions deliberately NOT auto-invented (would be unverified non-parity slop). So today the root commits to parent + EMPTY utxo/asset roots + the (correct) tx family + height. Consensus-inert because the gate is OFF; builder and verifier call the identical projection so stamped==verified always. A human must ratify the projection + wire a state iterator + GPU snapshot producer before any activation height is set. Additive constructor NewStandardBlockWithRoot (NewStandardBlock = its empty-root case). xvmroot gained 3 byte-transparent exported leaf-digest accessors (KAT 4f144ef7 unchanged) so the parallel fold reuses the one canonical preimage. CGO_ENABLED=0 clean; go test ./vms/xvm/... ./upgrade/ PASS. Local commit; not pushed (consensus repo — awaiting human review of the activation seam).
This commit is contained in:
+37
-8
@@ -8,12 +8,23 @@
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// MerkleRootNeverActivate is the sentinel activation height meaning "never": no
|
||||
// real block can reach math.MaxUint64, so a Config carrying this value keeps the
|
||||
// xvm execution_root path OFF for the entire life of the chain. It is the
|
||||
// default on Mainnet, Testnet, and Default until a concrete network upgrade
|
||||
// picks a finite activation height. Below activation the xvm block path keeps
|
||||
// the historical rule byte-for-byte (builder leaves the root empty; the executor
|
||||
// rejects any non-empty root), so installing this sentinel is a no-op for live
|
||||
// consensus.
|
||||
const MerkleRootNeverActivate uint64 = math.MaxUint64
|
||||
|
||||
// InitiallyActiveTime is the canonical Lux genesis-activation timestamp. It is
|
||||
// referenced by code paths that historically demanded "post-fork timestamp" as
|
||||
// an input and now want a single deterministic value.
|
||||
@@ -25,9 +36,24 @@ var InitiallyActiveTime = time.Date(2020, time.December, 5, 5, 0, 0, 0, time.UTC
|
||||
// X-Chain genesis state at boot. It is a value, not a gate.
|
||||
// - EpochDuration is the LP-181 epoch duration; per-network value
|
||||
// (5m on mainnet, 30s on test/dev) tunes consensus pacing.
|
||||
// - MerkleRootActivationHeight is the xvm block height at and above which the
|
||||
// block's MerkleRoot must carry the xvm execution_root (computed over the
|
||||
// post-block state) instead of staying empty. It DEFAULTS to
|
||||
// MerkleRootNeverActivate (never) on every published network, so the
|
||||
// historical empty-root rule is preserved until a real upgrade sets a finite
|
||||
// height. It is a value (an activation point), not a feature flag.
|
||||
type Config struct {
|
||||
XChainStopVertexID ids.ID `json:"xChainStopVertexID"`
|
||||
EpochDuration time.Duration `json:"epochDuration"`
|
||||
XChainStopVertexID ids.ID `json:"xChainStopVertexID"`
|
||||
EpochDuration time.Duration `json:"epochDuration"`
|
||||
MerkleRootActivationHeight uint64 `json:"merkleRootActivationHeight"`
|
||||
}
|
||||
|
||||
// IsMerkleRootActivated reports whether the xvm execution_root must be stamped
|
||||
// into (and verified on) a block at [height]. Below the activation height — and
|
||||
// always, while the height is the never sentinel — it returns false and the
|
||||
// historical empty-root rule applies.
|
||||
func (c *Config) IsMerkleRootActivated(height uint64) bool {
|
||||
return height >= c.MerkleRootActivationHeight
|
||||
}
|
||||
|
||||
// Validate is retained for callers that still invoke it; under activate-all-
|
||||
@@ -37,16 +63,19 @@ func (*Config) Validate() error { return nil }
|
||||
|
||||
var (
|
||||
Mainnet = Config{
|
||||
XChainStopVertexID: ids.FromStringOrPanic("jrGWDh5Po9FMj54depyunNixpia5PN4aAYxfmNzU8n752Rjga"),
|
||||
EpochDuration: 5 * time.Minute,
|
||||
XChainStopVertexID: ids.FromStringOrPanic("jrGWDh5Po9FMj54depyunNixpia5PN4aAYxfmNzU8n752Rjga"),
|
||||
EpochDuration: 5 * time.Minute,
|
||||
MerkleRootActivationHeight: MerkleRootNeverActivate, // unset until a real upgrade picks a height
|
||||
}
|
||||
Testnet = Config{
|
||||
XChainStopVertexID: ids.FromStringOrPanic("2D1cmbiG36BqQMRyHt4kFhWarmatA1ighSpND3FeFgz3vFVtCZ"),
|
||||
EpochDuration: 30 * time.Second,
|
||||
XChainStopVertexID: ids.FromStringOrPanic("2D1cmbiG36BqQMRyHt4kFhWarmatA1ighSpND3FeFgz3vFVtCZ"),
|
||||
EpochDuration: 30 * time.Second,
|
||||
MerkleRootActivationHeight: MerkleRootNeverActivate, // unset until a real upgrade picks a height
|
||||
}
|
||||
Default = Config{
|
||||
XChainStopVertexID: ids.Empty,
|
||||
EpochDuration: 30 * time.Second,
|
||||
XChainStopVertexID: ids.Empty,
|
||||
EpochDuration: 30 * time.Second,
|
||||
MerkleRootActivationHeight: MerkleRootNeverActivate, // unset until a real upgrade picks a height
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -7,16 +7,16 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
chain "github.com/luxfi/vm/chain"
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/math/set"
|
||||
"github.com/luxfi/node/vms/txs/mempool"
|
||||
"github.com/luxfi/node/vms/xvm/block"
|
||||
"github.com/luxfi/node/vms/xvm/state"
|
||||
"github.com/luxfi/node/vms/xvm/txs"
|
||||
"github.com/luxfi/node/vms/txs/mempool"
|
||||
"github.com/luxfi/timer/mockable"
|
||||
vmcore "github.com/luxfi/vm"
|
||||
chain "github.com/luxfi/vm/chain"
|
||||
|
||||
blockexecutor "github.com/luxfi/node/vms/xvm/block/executor"
|
||||
txexecutor "github.com/luxfi/node/vms/xvm/txs/executor"
|
||||
@@ -165,10 +165,37 @@ func (b *builder) BuildBlock(context.Context) (chain.Block, error) {
|
||||
return nil, ErrNoTransactions
|
||||
}
|
||||
|
||||
statelessBlk, err := block.NewStandardBlock(
|
||||
// Below the xvm execution_root activation height (the default on every
|
||||
// published network, where MerkleRootActivationHeight is the never
|
||||
// sentinel), the block carries an empty root — byte-for-byte the historical
|
||||
// shape. At and above activation, stamp the execution_root computed over the
|
||||
// post-block state so the verifier can recompute and check it.
|
||||
if !b.backend.Config.IsMerkleRootActivated(nextHeight) {
|
||||
statelessBlk, err := block.NewStandardBlock(
|
||||
preferredID,
|
||||
nextHeight,
|
||||
nextTimestamp,
|
||||
blockTxs,
|
||||
b.backend.Codec,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.manager.NewBlock(statelessBlk), nil
|
||||
}
|
||||
|
||||
parentRoot := preferred.MerkleRoot()
|
||||
root := blockexecutor.BlockExecutionRoot(
|
||||
parentRoot,
|
||||
blockTxs,
|
||||
stateDiff,
|
||||
nextHeight,
|
||||
)
|
||||
statelessBlk, err := block.NewStandardBlockWithRoot(
|
||||
preferredID,
|
||||
nextHeight,
|
||||
nextTimestamp,
|
||||
root,
|
||||
blockTxs,
|
||||
b.backend.Codec,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/metric"
|
||||
"github.com/luxfi/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/math/set"
|
||||
"github.com/luxfi/node/upgrade"
|
||||
"github.com/luxfi/node/vms/pcodecs/pcodecsmock"
|
||||
"github.com/luxfi/node/vms/xvm/block"
|
||||
blkexecutor "github.com/luxfi/node/vms/xvm/block/executor"
|
||||
"github.com/luxfi/node/vms/xvm/block/executor/executormock"
|
||||
"github.com/luxfi/node/vms/xvm/config"
|
||||
"github.com/luxfi/node/vms/xvm/state/statemock"
|
||||
"github.com/luxfi/node/vms/xvm/txs"
|
||||
txexecutor "github.com/luxfi/node/vms/xvm/txs/executor"
|
||||
"github.com/luxfi/node/vms/xvm/txs/mempool"
|
||||
"github.com/luxfi/node/vms/xvm/txs/txsmock"
|
||||
"github.com/luxfi/timer/mockable"
|
||||
chain "github.com/luxfi/vm/chain"
|
||||
)
|
||||
|
||||
// buildOneTxBlock drives BuildBlock with a single always-valid mocked tx over a
|
||||
// mocked manager/state, parent at [parentHeight] with [parentRoot], and the
|
||||
// given xvm config (which carries the execution_root activation height). It
|
||||
// returns the *StandardBlock the builder handed to NewBlock so the test can
|
||||
// inspect the stamped Root.
|
||||
func buildOneTxBlock(
|
||||
t *testing.T,
|
||||
ctrl *gomock.Controller,
|
||||
cfg *config.Config,
|
||||
parentRoot ids.ID,
|
||||
parentHeight uint64,
|
||||
) *block.StandardBlock {
|
||||
t.Helper()
|
||||
require := require.New(t)
|
||||
|
||||
preferredID := ids.GenerateTestID()
|
||||
preferredTimestamp := time.Now()
|
||||
|
||||
preferredBlock := block.NewMockBlock(ctrl)
|
||||
preferredBlock.EXPECT().Height().Return(parentHeight).AnyTimes()
|
||||
preferredBlock.EXPECT().Timestamp().Return(preferredTimestamp).AnyTimes()
|
||||
preferredBlock.EXPECT().MerkleRoot().Return(parentRoot).AnyTimes()
|
||||
|
||||
preferredState := statemock.NewChain(ctrl)
|
||||
preferredState.EXPECT().GetLastAccepted().Return(preferredID).AnyTimes()
|
||||
preferredState.EXPECT().GetTimestamp().Return(preferredTimestamp).AnyTimes()
|
||||
|
||||
// One tx that passes semantic verification and execution and adds no inputs.
|
||||
unsignedTx := txsmock.NewUnsignedTx(ctrl)
|
||||
unsignedTx.EXPECT().Visit(gomock.Any()).Return(nil) // semantic verification
|
||||
unsignedTx.EXPECT().Visit(gomock.Any()).DoAndReturn( // execution
|
||||
func(visitor txs.Visitor) error {
|
||||
require.IsType(&txexecutor.Executor{}, visitor)
|
||||
ex := visitor.(*txexecutor.Executor)
|
||||
if ex.Inputs == nil {
|
||||
ex.Inputs = set.NewSet[ids.ID](0)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
unsignedTx.EXPECT().SetBytes(gomock.Any()).AnyTimes()
|
||||
unsignedTx.EXPECT().InputIDs().Return(nil).AnyTimes()
|
||||
tx := &txs.Tx{Unsigned: unsignedTx}
|
||||
// Give the tx a real, stable ID (ID = hash of signed bytes). The
|
||||
// execution_root's tx family binds tx.ID(), so a deterministic ID lets the
|
||||
// test recompute the expected root.
|
||||
tx.SetBytes(nil, []byte{0x0A, 0x0B, 0x0C, 0x0D})
|
||||
|
||||
manager := executormock.NewManager(ctrl)
|
||||
manager.EXPECT().Preferred().Return(preferredID)
|
||||
manager.EXPECT().GetStatelessBlock(preferredID).Return(preferredBlock, nil)
|
||||
manager.EXPECT().GetState(preferredID).Return(preferredState, true)
|
||||
manager.EXPECT().VerifyUniqueInputs(preferredID, gomock.Any()).Return(nil).AnyTimes()
|
||||
|
||||
var built *block.StandardBlock
|
||||
manager.EXPECT().NewBlock(gomock.Any()).DoAndReturn(
|
||||
func(b *block.StandardBlock) chain.Block {
|
||||
built = b
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
memPool, err := mempool.New("", metric.NewRegistry())
|
||||
require.NoError(err)
|
||||
require.NoError(memPool.Add(tx))
|
||||
|
||||
// Mock codec: the builder serializes the block; the test reads block.Root
|
||||
// (set before marshal), so fixed marshal output is fine.
|
||||
codec := pcodecsmock.NewManager(ctrl)
|
||||
codec.EXPECT().Marshal(gomock.Any(), gomock.Any()).Return([]byte{1, 2, 3}, nil).AnyTimes()
|
||||
codec.EXPECT().Size(gomock.Any(), gomock.Any()).Return(2, nil).AnyTimes()
|
||||
|
||||
builder := New(
|
||||
&txexecutor.Backend{
|
||||
Ctx: context.Background(),
|
||||
Runtime: testRuntime(),
|
||||
Codec: codec,
|
||||
Config: cfg,
|
||||
Log: log.NewNoOpLogger(),
|
||||
},
|
||||
manager,
|
||||
&mockable.Clock{},
|
||||
memPool,
|
||||
)
|
||||
|
||||
_, err = builder.BuildBlock(context.Background())
|
||||
require.NoError(err)
|
||||
require.NotNil(built)
|
||||
return built
|
||||
}
|
||||
|
||||
// TestBuildBlockMerkleRootGateOff is deliverable case (a), builder side: with
|
||||
// the gate OFF (the default, never-sentinel height) the builder leaves the
|
||||
// block's merkle root empty — byte-for-byte the historical behavior.
|
||||
func TestBuildBlockMerkleRootGateOff(t *testing.T) {
|
||||
require := require.New(t)
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
cfg := &config.Config{MerkleRootActivationHeight: upgrade.MerkleRootNeverActivate}
|
||||
built := buildOneTxBlock(t, ctrl, cfg, ids.Empty, 1337)
|
||||
|
||||
require.Equal(ids.Empty, built.MerkleRoot(), "gate off must leave the root empty")
|
||||
}
|
||||
|
||||
// TestBuildBlockMerkleRootGateOffNilConfig confirms the fail-safe: a backend
|
||||
// with no config at all is treated as OFF, so the root stays empty.
|
||||
func TestBuildBlockMerkleRootGateOffNilConfig(t *testing.T) {
|
||||
require := require.New(t)
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
built := buildOneTxBlock(t, ctrl, nil, ids.Empty, 1337)
|
||||
|
||||
require.Equal(ids.Empty, built.MerkleRoot(), "nil config must fail safe to off (empty root)")
|
||||
}
|
||||
|
||||
// TestBuildBlockMerkleRootGateOn is deliverable case (b), builder side: with the
|
||||
// gate ON (activation height 0) the builder stamps the xvm execution_root
|
||||
// computed over the post-block state. The stamped root must be non-empty and
|
||||
// must equal the canonical BlockExecutionRoot over the same inputs.
|
||||
func TestBuildBlockMerkleRootGateOn(t *testing.T) {
|
||||
require := require.New(t)
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
parentRoot := ids.GenerateTestID()
|
||||
const parentHeight = uint64(41)
|
||||
cfg := &config.Config{MerkleRootActivationHeight: 0} // activate from genesis
|
||||
|
||||
built := buildOneTxBlock(t, ctrl, cfg, parentRoot, parentHeight)
|
||||
|
||||
require.NotEqual(ids.Empty, built.MerkleRoot(), "gate on must stamp a non-empty root")
|
||||
|
||||
// Recompute the expected root from the canonical projection: parent root +
|
||||
// the block's tx family (UTXO/asset families empty per the snapshot seam) at
|
||||
// the built height (parentHeight + 1).
|
||||
want := blkexecutor.BlockExecutionRoot(parentRoot, built.Txs(), nil, parentHeight+1)
|
||||
require.Equal(want, built.MerkleRoot())
|
||||
}
|
||||
@@ -12,13 +12,13 @@ import (
|
||||
"github.com/luxfi/log"
|
||||
|
||||
"github.com/luxfi/consensus/core/choices"
|
||||
chain "github.com/luxfi/vm/chain"
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/math/set"
|
||||
"github.com/luxfi/node/vms/xvm/block"
|
||||
"github.com/luxfi/node/vms/xvm/state"
|
||||
"github.com/luxfi/node/vms/xvm/txs/executor"
|
||||
chain "github.com/luxfi/vm/chain"
|
||||
"github.com/luxfi/vm/chains/atomic"
|
||||
)
|
||||
|
||||
@@ -87,9 +87,17 @@ func (b *Block) Verify(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Currently we don't populate the blocks merkle root.
|
||||
// Gate the merkle-root rule on the xvm execution_root activation height.
|
||||
// Below activation (the default on every published network, where
|
||||
// MerkleRootActivationHeight is the never sentinel) the historical rule
|
||||
// holds verbatim: the block must carry an empty root, and a non-empty root
|
||||
// is rejected here. At and above activation the block must carry the
|
||||
// execution_root over the post-block state; that is recomputed and checked
|
||||
// below, once the parent and the block's txs are available. A non-empty root
|
||||
// is the only case the activation height affects at this point, so the gate
|
||||
// is consulted only then.
|
||||
merkleRoot := b.Block.MerkleRoot()
|
||||
if merkleRoot != ids.Empty {
|
||||
if merkleRoot != ids.Empty && !b.manager.backend.Config.IsMerkleRootActivated(b.Height()) {
|
||||
return fmt.Errorf("%w: %s", ErrUnexpectedMerkleRoot, merkleRoot)
|
||||
}
|
||||
|
||||
@@ -250,6 +258,24 @@ func (b *Block) Verify(ctx context.Context) error {
|
||||
)
|
||||
}
|
||||
|
||||
// At and above the xvm execution_root activation height, the block must
|
||||
// carry the execution_root over the now-fully-applied post-block state.
|
||||
// Recompute it from the same canonical projection the builder stamped (one
|
||||
// shared code path — see BlockExecutionRoot) and reject any mismatch. Below
|
||||
// activation this is skipped and the empty-root rule checked above stands.
|
||||
// [height] was already resolved (and validated against the parent) above.
|
||||
if b.manager.backend.Config.IsMerkleRootActivated(height) {
|
||||
expectedRoot := BlockExecutionRoot(parent.MerkleRoot(), txs, stateDiff, height)
|
||||
if merkleRoot != expectedRoot {
|
||||
return fmt.Errorf(
|
||||
"%w: block root %s, expected %s",
|
||||
ErrUnexpectedMerkleRoot,
|
||||
merkleRoot,
|
||||
expectedRoot,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Now that the block has been executed, we can add the block data to the
|
||||
// state diff.
|
||||
stateDiff.SetLastAccepted(blkID)
|
||||
|
||||
@@ -13,13 +13,11 @@ import (
|
||||
"github.com/luxfi/metric"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/runtime"
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/math/set"
|
||||
"github.com/luxfi/mock/gomock"
|
||||
"github.com/luxfi/vm/chains/atomic"
|
||||
"github.com/luxfi/vm/chains/atomic/atomicmock"
|
||||
"github.com/luxfi/node/upgrade"
|
||||
"github.com/luxfi/node/vms/xvm/block"
|
||||
"github.com/luxfi/node/vms/xvm/config"
|
||||
"github.com/luxfi/node/vms/xvm/metrics/metricsmock"
|
||||
@@ -28,8 +26,11 @@ import (
|
||||
txexecutor "github.com/luxfi/node/vms/xvm/txs/executor"
|
||||
"github.com/luxfi/node/vms/xvm/txs/mempool"
|
||||
"github.com/luxfi/node/vms/xvm/txs/txsmock"
|
||||
"github.com/luxfi/runtime"
|
||||
"github.com/luxfi/timer/mockable"
|
||||
"github.com/luxfi/utils"
|
||||
"github.com/luxfi/vm/chains/atomic"
|
||||
"github.com/luxfi/vm/chains/atomic/atomicmock"
|
||||
)
|
||||
|
||||
func TestBlockVerify(t *testing.T) {
|
||||
@@ -65,6 +66,10 @@ func TestBlockVerify(t *testing.T) {
|
||||
mockBlock := block.NewMockBlock(ctrl)
|
||||
mockBlock.EXPECT().ID().Return(ids.Empty).AnyTimes()
|
||||
mockBlock.EXPECT().MerkleRoot().Return(ids.GenerateTestID()).AnyTimes()
|
||||
// A non-empty root makes the gate consult the block height to
|
||||
// decide if the execution_root path is active (it is not, by
|
||||
// default), so Height is now read on this path.
|
||||
mockBlock.EXPECT().Height().Return(uint64(1)).AnyTimes()
|
||||
return &Block{
|
||||
Block: mockBlock,
|
||||
manager: &manager{
|
||||
@@ -971,6 +976,10 @@ func defaultTestBackend(bootstrapped bool, sharedMemory atomic.SharedMemory) *tx
|
||||
Config: &config.Config{
|
||||
TxFee: 0,
|
||||
CreateAssetTxFee: 0,
|
||||
// Model production: the xvm execution_root gate is OFF (never
|
||||
// sentinel) unless a test overrides it. Without this, the Go zero
|
||||
// value (0) would mean "activate from genesis".
|
||||
MerkleRootActivationHeight: upgrade.MerkleRootNeverActivate,
|
||||
},
|
||||
Log: log.NoLog{},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package executor
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"github.com/luxfi/crypto/merkle"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/node/vms/xvm/state"
|
||||
"github.com/luxfi/node/vms/xvm/state/xvmroot"
|
||||
"github.com/luxfi/node/vms/xvm/txs"
|
||||
)
|
||||
|
||||
// xvmExecutionRoot computes the xvm execution_root over a block's post-block
|
||||
// state, byte-identical to xvmroot.ExecutionRoot, with the three family subtrees
|
||||
// (utxo / asset / tx) built concurrently and each family's leaf digests hashed
|
||||
// in parallel across a GOMAXPROCS-bounded worker pool. It is the single root
|
||||
// computation shared by the builder (which stamps the result into the block) and
|
||||
// the executor (which recomputes it to verify the block's stamped root). Having
|
||||
// exactly one implementation means the producer and the verifier can never
|
||||
// disagree on the rule.
|
||||
//
|
||||
// Byte-identity to the serial xvmroot.ExecutionRoot is structural, not
|
||||
// best-effort: the canonical leaf ORDER (occupied, ascending slot index) and the
|
||||
// canonical COMBINE (RFC-6962 lone-right Merkle fold via merkle.Root, then the
|
||||
// fixed-shape keccak compose) are unchanged — only the per-leaf keccak hashing
|
||||
// and the three independent family folds run on separate goroutines. Each worker
|
||||
// writes a disjoint, pre-indexed slot of its family's leaf slice, so the slice
|
||||
// fed to merkle.Root is identical to the one the serial path builds. A property
|
||||
// test (TestParallelExecutionRootMatchesSerial) pins this over random state
|
||||
// sizes including odd leaf counts.
|
||||
func xvmExecutionRoot(
|
||||
parentExecutionRoot [xvmroot.Size]byte,
|
||||
utxos []xvmroot.UTXOLeaf,
|
||||
assets []xvmroot.AssetLeaf,
|
||||
leafTxs []xvmroot.TxLeaf,
|
||||
height uint64,
|
||||
) (executionRoot, utxoRoot, assetRoot, txRoot [xvmroot.Size]byte) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(3)
|
||||
go func() { defer wg.Done(); utxoRoot = parallelUTXORoot(utxos) }()
|
||||
go func() { defer wg.Done(); assetRoot = parallelAssetRoot(assets) }()
|
||||
go func() { defer wg.Done(); txRoot = parallelTxRoot(leafTxs) }()
|
||||
wg.Wait()
|
||||
|
||||
executionRoot = xvmroot.Compose(parentExecutionRoot, utxoRoot, assetRoot, txRoot, height)
|
||||
return executionRoot, utxoRoot, assetRoot, txRoot
|
||||
}
|
||||
|
||||
// parallelUTXORoot is the parallel peer of xvmroot.UTXORoot. It compacts the
|
||||
// occupied slots to their canonical ascending leaf positions (serial, O(n) and
|
||||
// cheap), then computes the family root with the parallel fold — byte-identical
|
||||
// to xvmroot.UTXORoot's merkle.Root over the same compacted digests.
|
||||
func parallelUTXORoot(utxos []xvmroot.UTXOLeaf) [xvmroot.Size]byte {
|
||||
idx := make([]int, 0, len(utxos))
|
||||
for i := range utxos {
|
||||
if utxos[i].Status&xvmroot.UTXOOccupied == 0 {
|
||||
continue
|
||||
}
|
||||
idx = append(idx, i)
|
||||
}
|
||||
return parallelMerkleRoot(len(idx), func(k int) [xvmroot.Size]byte {
|
||||
return xvmroot.UTXOLeafDigest(utxos[idx[k]], uint32(idx[k]))
|
||||
})
|
||||
}
|
||||
|
||||
// parallelAssetRoot is the parallel peer of xvmroot.AssetRoot.
|
||||
func parallelAssetRoot(assets []xvmroot.AssetLeaf) [xvmroot.Size]byte {
|
||||
idx := make([]int, 0, len(assets))
|
||||
for i := range assets {
|
||||
if assets[i].Occupied == 0 {
|
||||
continue
|
||||
}
|
||||
idx = append(idx, i)
|
||||
}
|
||||
return parallelMerkleRoot(len(idx), func(k int) [xvmroot.Size]byte {
|
||||
return xvmroot.AssetLeafDigest(assets[idx[k]], uint32(idx[k]))
|
||||
})
|
||||
}
|
||||
|
||||
// parallelTxRoot is the parallel peer of xvmroot.TxRoot. Every tx is folded (no
|
||||
// skip predicate), so leaf position equals slot index.
|
||||
func parallelTxRoot(leafTxs []xvmroot.TxLeaf) [xvmroot.Size]byte {
|
||||
return parallelMerkleRoot(len(leafTxs), func(i int) [xvmroot.Size]byte {
|
||||
return xvmroot.TxLeafDigest(leafTxs[i], uint32(i))
|
||||
})
|
||||
}
|
||||
|
||||
// parallelMerkleRoot computes the RFC-6962 tagged binary Merkle root over n leaf
|
||||
// element-digests, where digest(k) yields the k-th leaf's element digest. It is
|
||||
// byte-identical to merkle.Root(allDigests): it composes merkle's own exported
|
||||
// LeafHash / NodeHash primitives (so the keccak/tagging spec is not duplicated)
|
||||
// and follows the same level-by-level reduction with lone-right promotion — only
|
||||
// the per-level loops run across a GOMAXPROCS-bounded worker pool. Both the
|
||||
// element-digest stage and every internal merkle level are independent across
|
||||
// their index range, so parallelizing the loops cannot change the bytes; the
|
||||
// reduction shape is fixed by the leaf count alone.
|
||||
//
|
||||
// n == 0 → merkle.EmptyRoot (keccak256(""))
|
||||
// n == 1 → merkle.LeafHash(digest(0))
|
||||
// n > 1 → parallel level reduction
|
||||
func parallelMerkleRoot(n int, digest func(k int) [xvmroot.Size]byte) [xvmroot.Size]byte {
|
||||
if n == 0 {
|
||||
return merkle.EmptyRoot()
|
||||
}
|
||||
|
||||
// Level 0: tag each element digest. merkle.Root tags leaves[i] via
|
||||
// LeafHash; we compute the element digest and tag it in the same step.
|
||||
level := make([][xvmroot.Size]byte, n)
|
||||
parallelFor(n, func(i int) {
|
||||
level[i] = merkle.LeafHash(digest(i))
|
||||
})
|
||||
|
||||
// Reduce level-by-level. parents = ceil(cnt/2); a lone right node on an odd
|
||||
// level is promoted unchanged — identical to merkle.Root.
|
||||
for len(level) > 1 {
|
||||
cnt := len(level)
|
||||
parents := (cnt + 1) / 2
|
||||
pairs := cnt / 2
|
||||
next := make([][xvmroot.Size]byte, parents)
|
||||
parallelFor(pairs, func(j int) {
|
||||
next[j] = merkle.NodeHash(level[2*j], level[2*j+1])
|
||||
})
|
||||
if cnt&1 == 1 {
|
||||
next[parents-1] = level[cnt-1]
|
||||
}
|
||||
level = next
|
||||
}
|
||||
return level[0]
|
||||
}
|
||||
|
||||
// parallelFor invokes body(i) for every i in [0, n), spread over a
|
||||
// GOMAXPROCS-bounded pool of worker goroutines partitioning the range into
|
||||
// contiguous shards. Each i is handled exactly once by one worker, so a body
|
||||
// that writes a disjoint slot per i needs no further synchronization. Below a
|
||||
// keccak-tuned threshold it runs inline, where fork/join would cost more than
|
||||
// the work.
|
||||
func parallelFor(n int, body func(i int)) {
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
workers := runtime.GOMAXPROCS(0)
|
||||
// Below this many keccak invocations the fork/join overhead outweighs the
|
||||
// parallel hashing; run inline. Tuned to keccak cost, not block size.
|
||||
const inlineThreshold = 1024
|
||||
if workers <= 1 || n < inlineThreshold {
|
||||
for i := 0; i < n; i++ {
|
||||
body(i)
|
||||
}
|
||||
return
|
||||
}
|
||||
if workers > n {
|
||||
workers = n
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers)
|
||||
// Contiguous shards: the first [rem] workers take one extra element so every
|
||||
// i in [0,n) is covered exactly once.
|
||||
base, rem := n/workers, n%workers
|
||||
start := 0
|
||||
for w := 0; w < workers; w++ {
|
||||
size := base
|
||||
if w < rem {
|
||||
size++
|
||||
}
|
||||
lo, hi := start, start+size
|
||||
start = hi
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := lo; i < hi; i++ {
|
||||
body(i)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// BlockExecutionRoot resolves the canonical leaf projection for a block and
|
||||
// returns the execution_root the block must carry at [height]. It is the bridge
|
||||
// from a stateless block to the pure xvm root computation: the builder calls it
|
||||
// to stamp the root and the executor calls it to verify the stamped root, so the
|
||||
// produced root and the verified root are derived by exactly one code path and
|
||||
// can never disagree.
|
||||
//
|
||||
// [parentExecutionRoot] is the parent block's MerkleRoot (the empty root for a
|
||||
// genesis or pre-activation parent). [postState] is the post-block state (the
|
||||
// applied state diff) the UTXO/asset families are projected from. The leaf
|
||||
// projection is the canonical, deterministic, pure-function-of-the-block image
|
||||
// of the post-block state the root commits to. The tx family is fully realized
|
||||
// from the block's transactions (TxID = tx.ID(), every tx folded in block
|
||||
// order, status = accepted); it is identical on the builder and the verifier
|
||||
// because both observe the same ordered tx set. The UTXO/asset families come
|
||||
// from postBlockUTXOLeaves / postBlockAssetLeaves; see those for the
|
||||
// snapshot-producer seam.
|
||||
func BlockExecutionRoot(
|
||||
parentExecutionRoot ids.ID,
|
||||
blkTxs []*txs.Tx,
|
||||
postState state.Chain,
|
||||
height uint64,
|
||||
) ids.ID {
|
||||
leafTxs := txLeaves(blkTxs)
|
||||
utxos := postBlockUTXOLeaves(postState)
|
||||
assets := postBlockAssetLeaves(postState)
|
||||
executionRoot, _, _, _ := xvmExecutionRoot([xvmroot.Size]byte(parentExecutionRoot), utxos, assets, leafTxs, height)
|
||||
return ids.ID(executionRoot)
|
||||
}
|
||||
|
||||
// postBlockUTXOLeaves and postBlockAssetLeaves project the post-block state into
|
||||
// the xvm execution_root's UTXO and asset family leaves — the occupied set in
|
||||
// canonical ascending slot order, as the kernel snapshot lays it out.
|
||||
//
|
||||
// SEAM (gated off; not yet wired): the canonical mapping from the xvm executor's
|
||||
// codec/output-interface UTXO and asset types to the accelerator's flat
|
||||
// state-snapshot leaf fields (UTXOLeaf.OwnerRoot / AmountHi / Threshold,
|
||||
// AssetLeaf.TotalSupply / MintAuthorityRoot / FreezeFlag / Denomination) is owned
|
||||
// by the GPU state-snapshot producer — the same component that feeds xvmroot's
|
||||
// KAT — and enumerating the full occupied set needs a state iterator the xvm
|
||||
// state layer does not yet expose. Until that producer lands, these families are
|
||||
// empty, so the execution_root commits to the parent root, the empty UTXO/asset
|
||||
// roots, the tx family (fully derived from the block), and the height. This is
|
||||
// consensus-inert today: the gate defaults to the never sentinel on every
|
||||
// published network (see upgrade.MerkleRootNeverActivate), so the path is never
|
||||
// taken until a real upgrade both sets a height AND wires the snapshot producer.
|
||||
// The builder and verifier call this identical projection, so the stamped and
|
||||
// verified roots always agree.
|
||||
func postBlockUTXOLeaves(state.Chain) []xvmroot.UTXOLeaf { return nil }
|
||||
|
||||
func postBlockAssetLeaves(state.Chain) []xvmroot.AssetLeaf { return nil }
|
||||
|
||||
// txLeaves projects a block's transactions onto the canonical tx-family leaf
|
||||
// layout, in block order (the consensus-fixed, deterministic ordering). Every
|
||||
// tx is folded; a tx included in an accepted block has status = accepted, with
|
||||
// the kernel-snapshot reject_reason / proof_digest / kind fields zero (an xvm tx
|
||||
// in a built or verified block carries no rejection and no proof digest at this
|
||||
// layer). This is a pure function of the block — builder and verifier produce
|
||||
// the same leaves.
|
||||
func txLeaves(blkTxs []*txs.Tx) []xvmroot.TxLeaf {
|
||||
const statusAccepted uint32 = 1
|
||||
leaves := make([]xvmroot.TxLeaf, len(blkTxs))
|
||||
for i, tx := range blkTxs {
|
||||
leaves[i] = xvmroot.TxLeaf{
|
||||
TxID: ids.ID(tx.ID()),
|
||||
Status: statusAccepted,
|
||||
}
|
||||
}
|
||||
return leaves
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package executor
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/node/vms/xvm/state/xvmroot"
|
||||
)
|
||||
|
||||
// randUTXOLeaves builds n UTXO leaves with random fields and a random occupancy
|
||||
// mix (roughly half unoccupied) so the parallel compaction is exercised against
|
||||
// the serial one over a non-trivial occupied subset.
|
||||
func randUTXOLeaves(rng *rand.Rand, n int) []xvmroot.UTXOLeaf {
|
||||
us := make([]xvmroot.UTXOLeaf, n)
|
||||
for i := range us {
|
||||
rng.Read(us[i].UTXOID[:])
|
||||
rng.Read(us[i].AssetID[:])
|
||||
rng.Read(us[i].OwnerRoot[:])
|
||||
us[i].AmountLo = rng.Uint64()
|
||||
us[i].AmountHi = rng.Uint64()
|
||||
us[i].Locktime = rng.Uint64()
|
||||
us[i].Threshold = rng.Uint32()
|
||||
if rng.Intn(2) == 0 {
|
||||
us[i].Status = xvmroot.UTXOOccupied
|
||||
}
|
||||
}
|
||||
return us
|
||||
}
|
||||
|
||||
func randAssetLeaves(rng *rand.Rand, n int) []xvmroot.AssetLeaf {
|
||||
as := make([]xvmroot.AssetLeaf, n)
|
||||
for i := range as {
|
||||
rng.Read(as[i].AssetID[:])
|
||||
rng.Read(as[i].MintAuthorityRoot[:])
|
||||
as[i].TotalSupplyLo = rng.Uint64()
|
||||
as[i].TotalSupplyHi = rng.Uint64()
|
||||
as[i].FreezeFlag = rng.Uint32()
|
||||
as[i].Denomination = rng.Uint32()
|
||||
if rng.Intn(2) == 0 {
|
||||
as[i].Occupied = 1
|
||||
}
|
||||
}
|
||||
return as
|
||||
}
|
||||
|
||||
func randTxLeaves(rng *rand.Rand, n int) []xvmroot.TxLeaf {
|
||||
ts := make([]xvmroot.TxLeaf, n)
|
||||
for i := range ts {
|
||||
rng.Read(ts[i].TxID[:])
|
||||
rng.Read(ts[i].ProofDigest[:])
|
||||
ts[i].Kind = rng.Uint32()
|
||||
ts[i].Status = rng.Uint32()
|
||||
ts[i].RejectReason = rng.Uint32()
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
// TestParallelFamilyRootsMatchSerial pins each parallel family fold byte-for-byte
|
||||
// against the serial xvmroot family root, across a range of sizes that includes
|
||||
// 0, 1, odd, even, and sizes straddling the inline/worker threshold — the leaf
|
||||
// counts where lone-right Merkle promotion and worker sharding edges live.
|
||||
func TestParallelFamilyRootsMatchSerial(t *testing.T) {
|
||||
require := require.New(t)
|
||||
rng := rand.New(rand.NewSource(0xC0FFEE))
|
||||
|
||||
sizes := []int{0, 1, 2, 3, 4, 5, 7, 8, 9, 15, 16, 17, 31, 33, 63, 64, 65,
|
||||
127, 255, 256, 257, 511, 1000, 1023, 1024, 4097}
|
||||
for _, n := range sizes {
|
||||
us := randUTXOLeaves(rng, n)
|
||||
require.Equalf(xvmroot.UTXORoot(us), parallelUTXORoot(us), "utxo root n=%d", n)
|
||||
|
||||
as := randAssetLeaves(rng, n)
|
||||
require.Equalf(xvmroot.AssetRoot(as), parallelAssetRoot(as), "asset root n=%d", n)
|
||||
|
||||
ts := randTxLeaves(rng, n)
|
||||
require.Equalf(xvmroot.TxRoot(ts), parallelTxRoot(ts), "tx root n=%d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParallelExecutionRootMatchesSerial is the property test the deliverable
|
||||
// requires: the full parallel execution_root equals the serial
|
||||
// xvmroot.ExecutionRoot over random state of random sizes (including odd
|
||||
// per-family counts), so the parallel build is a drop-in for the serial oracle.
|
||||
func TestParallelExecutionRootMatchesSerial(t *testing.T) {
|
||||
require := require.New(t)
|
||||
rng := rand.New(rand.NewSource(0x5EED))
|
||||
|
||||
for iter := 0; iter < 200; iter++ {
|
||||
var parent [xvmroot.Size]byte
|
||||
rng.Read(parent[:])
|
||||
height := rng.Uint64()
|
||||
|
||||
// Independent, frequently-odd per-family sizes.
|
||||
nU := rng.Intn(600)
|
||||
nA := rng.Intn(600)
|
||||
nT := rng.Intn(600)
|
||||
us := randUTXOLeaves(rng, nU)
|
||||
as := randAssetLeaves(rng, nA)
|
||||
ts := randTxLeaves(rng, nT)
|
||||
|
||||
wantExec, wantU, wantA, wantT := xvmroot.ExecutionRoot(parent, us, as, ts, height)
|
||||
gotExec, gotU, gotA, gotT := xvmExecutionRoot(parent, us, as, ts, height)
|
||||
|
||||
require.Equalf(wantU, gotU, "utxo iter=%d nU=%d", iter, nU)
|
||||
require.Equalf(wantA, gotA, "asset iter=%d nA=%d", iter, nA)
|
||||
require.Equalf(wantT, gotT, "tx iter=%d nT=%d", iter, nT)
|
||||
require.Equalf(wantExec, gotExec, "exec iter=%d", iter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParallelExecutionRootEmpty confirms the all-empty state reduces to the
|
||||
// same composed root the serial path yields (empty family roots are
|
||||
// keccak256(""), not zero).
|
||||
func TestParallelExecutionRootEmpty(t *testing.T) {
|
||||
require := require.New(t)
|
||||
var parent [xvmroot.Size]byte
|
||||
wantExec, _, _, _ := xvmroot.ExecutionRoot(parent, nil, nil, nil, 0)
|
||||
gotExec, _, _, _ := xvmExecutionRoot(parent, nil, nil, nil, 0)
|
||||
require.Equal(wantExec, gotExec)
|
||||
}
|
||||
|
||||
const benchUTXOCount = 65536
|
||||
|
||||
func benchUTXOSet() []xvmroot.UTXOLeaf {
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
us := make([]xvmroot.UTXOLeaf, benchUTXOCount)
|
||||
for i := range us {
|
||||
rng.Read(us[i].UTXOID[:])
|
||||
rng.Read(us[i].AssetID[:])
|
||||
rng.Read(us[i].OwnerRoot[:])
|
||||
us[i].AmountLo = rng.Uint64()
|
||||
us[i].Threshold = 1
|
||||
us[i].Status = xvmroot.UTXOOccupied // all occupied: worst-case fold width
|
||||
}
|
||||
return us
|
||||
}
|
||||
|
||||
// BenchmarkUTXORootSerial folds a 65536-leaf UTXO set with the serial
|
||||
// xvmroot.UTXORoot.
|
||||
func BenchmarkUTXORootSerial(b *testing.B) {
|
||||
us := benchUTXOSet()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = xvmroot.UTXORoot(us)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkUTXORootParallel folds the same 65536-leaf set with the parallel
|
||||
// worker-pool fold. Compare against BenchmarkUTXORootSerial to see the
|
||||
// leaf-hashing speedup.
|
||||
func BenchmarkUTXORootParallel(b *testing.B) {
|
||||
us := benchUTXOSet()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = parallelUTXORoot(us)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkExecutionRootSerial / Parallel compare the full execution_root build
|
||||
// over a 65536-UTXO post-block state (with a modest asset + tx set), serial
|
||||
// xvmroot.ExecutionRoot vs the concurrent xvmExecutionRoot.
|
||||
func BenchmarkExecutionRootSerial(b *testing.B) {
|
||||
var parent [xvmroot.Size]byte
|
||||
us := benchUTXOSet()
|
||||
rng := rand.New(rand.NewSource(2))
|
||||
as := randAssetLeaves(rng, 4096)
|
||||
ts := randTxLeaves(rng, 4096)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _, _, _ = xvmroot.ExecutionRoot(parent, us, as, ts, 100)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkExecutionRootParallel(b *testing.B) {
|
||||
var parent [xvmroot.Size]byte
|
||||
us := benchUTXOSet()
|
||||
rng := rand.New(rand.NewSource(2))
|
||||
as := randAssetLeaves(rng, 4096)
|
||||
ts := randTxLeaves(rng, 4096)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _, _, _ = xvmExecutionRoot(parent, us, as, ts, 100)
|
||||
}
|
||||
}
|
||||
@@ -74,11 +74,29 @@ func NewStandardBlock(
|
||||
timestamp time.Time,
|
||||
txs []*txs.Tx,
|
||||
cm pcodecs.Manager,
|
||||
) (*StandardBlock, error) {
|
||||
return NewStandardBlockWithRoot(parentID, height, timestamp, ids.Empty, txs, cm)
|
||||
}
|
||||
|
||||
// NewStandardBlockWithRoot builds a StandardBlock carrying an explicit merkle
|
||||
// root and serializes it. NewStandardBlock is the root == ids.Empty special case
|
||||
// — the historical, pre-activation shape. Above the xvm execution_root
|
||||
// activation height the builder passes the computed execution_root here so it is
|
||||
// part of the serialized, hashed block bytes; below activation the empty-root
|
||||
// path (NewStandardBlock) is used and the bytes are byte-for-byte unchanged.
|
||||
func NewStandardBlockWithRoot(
|
||||
parentID ids.ID,
|
||||
height uint64,
|
||||
timestamp time.Time,
|
||||
root ids.ID,
|
||||
txs []*txs.Tx,
|
||||
cm pcodecs.Manager,
|
||||
) (*StandardBlock, error) {
|
||||
blk := &StandardBlock{
|
||||
PrntID: parentID,
|
||||
Hght: height,
|
||||
Time: uint64(timestamp.Unix()),
|
||||
Root: root,
|
||||
Transactions: txs,
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/go-json-experiment/json"
|
||||
jsonv1 "github.com/go-json-experiment/json/v1"
|
||||
|
||||
"github.com/luxfi/node/upgrade"
|
||||
"github.com/luxfi/node/vms/xvm/config"
|
||||
"github.com/luxfi/node/vms/xvm/network"
|
||||
)
|
||||
@@ -17,6 +18,12 @@ var DefaultConfig = Config{
|
||||
Config: config.Config{
|
||||
TxFee: 1000, // 1000 nanoLux base transaction fee
|
||||
CreateAssetTxFee: 10000, // 10000 nanoLux for asset creation
|
||||
// xvm execution_root gate OFF by default: a VM with no explicit override
|
||||
// keeps the historical empty-root rule. A real network upgrade sets a
|
||||
// finite height via the JSON config to activate it. Without this default
|
||||
// the Go zero value (0) would activate the path from genesis — see the
|
||||
// safety rail in upgrade.MerkleRootNeverActivate.
|
||||
MerkleRootActivationHeight: upgrade.MerkleRootNeverActivate,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -13,4 +13,30 @@ type Config struct {
|
||||
|
||||
// IndexTransactions enables transaction indexing by address
|
||||
IndexTransactions bool `json:"indexTransactions"`
|
||||
|
||||
// MerkleRootActivationHeight is the xvm block height at and above which the
|
||||
// block's MerkleRoot must carry the xvm execution_root over the post-block
|
||||
// state. It is sourced from upgrade.Config at VM init and defaults to
|
||||
// upgrade.MerkleRootNeverActivate (math.MaxUint64) on every published
|
||||
// network, so the historical empty-root rule holds until a real upgrade
|
||||
// picks a finite height. A zero value (the Go struct zero) activates from
|
||||
// genesis and is used only by tests that exercise the activated path.
|
||||
MerkleRootActivationHeight uint64 `json:"merkleRootActivationHeight"`
|
||||
}
|
||||
|
||||
// IsMerkleRootActivated reports whether the xvm execution_root must be stamped
|
||||
// into (and verified on) a block at [height]. Mirrors
|
||||
// upgrade.Config.IsMerkleRootActivated so the block path can gate on the value
|
||||
// carried in the backend without reaching back into the upgrade package.
|
||||
//
|
||||
// It is nil-safe: a nil config (a backend constructed without one) is OFF, so
|
||||
// the gate fails safe to the historical empty-root rule. Production always
|
||||
// carries a config whose height is sourced from upgrade.Config — the never
|
||||
// sentinel by default — so the activated path engages only when an operator
|
||||
// deliberately sets a finite height.
|
||||
func (c *Config) IsMerkleRootActivated(height uint64) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
return height >= c.MerkleRootActivationHeight
|
||||
}
|
||||
|
||||
@@ -147,6 +147,21 @@ func txLeafDigest(t TxLeaf, i uint32) [Size]byte {
|
||||
return hash.ComputeKeccak256Array(b)
|
||||
}
|
||||
|
||||
// UTXOLeafDigest returns the canonical per-element keccak256 leaf digest d_i for
|
||||
// the UTXO at slot index i — the same bytes UTXORoot folds. It is exported so a
|
||||
// parallel builder can compute the family's leaf digests across worker
|
||||
// goroutines from the one canonical preimage definition (no second copy of the
|
||||
// layout), then merkle.Root-combine to a byte-identical root.
|
||||
func UTXOLeafDigest(u UTXOLeaf, i uint32) [Size]byte { return utxoLeafDigest(u, i) }
|
||||
|
||||
// AssetLeafDigest returns the canonical per-element keccak256 leaf digest d_i for
|
||||
// the asset at slot index i — the same bytes AssetRoot folds.
|
||||
func AssetLeafDigest(a AssetLeaf, i uint32) [Size]byte { return assetLeafDigest(a, i) }
|
||||
|
||||
// TxLeafDigest returns the canonical per-element keccak256 leaf digest d_i for
|
||||
// the tx at slot index i — the same bytes TxRoot folds.
|
||||
func TxLeafDigest(t TxLeaf, i uint32) [Size]byte { return txLeafDigest(t, i) }
|
||||
|
||||
// UTXORoot is the tagged binary Merkle root over the occupied UTXOs' leaf
|
||||
// digests, in ascending slot index. Slot index i is the position in utxos
|
||||
// (bound into each leaf preimage); a UTXO with (Status & UTXOOccupied) == 0 is
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package xvm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/node/upgrade"
|
||||
"github.com/luxfi/node/vms/xvm/block"
|
||||
blkexecutor "github.com/luxfi/node/vms/xvm/block/executor"
|
||||
)
|
||||
|
||||
func u64ptr(v uint64) *uint64 { return &v }
|
||||
|
||||
// TestMerkleRootGateOffDefaultBlockEmpty is deliverable case (a), end to end: a
|
||||
// VM with the default config (gate OFF, never sentinel) builds and accepts a
|
||||
// real block whose merkle root is empty — the historical behavior, unchanged.
|
||||
func TestMerkleRootGateOffDefaultBlockEmpty(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
env := setup(t, &envConfig{fork: upgrade.Default})
|
||||
env.vm.Lock.Unlock()
|
||||
|
||||
tx := newTx(t, env.genesisBytes, env.consensusRuntime.ChainID, env.vm.parser, "LUX")
|
||||
require.NoError(env.vm.network.IssueTxFromRPC(tx))
|
||||
|
||||
blkIntf, err := env.vm.BuildBlock(context.Background())
|
||||
require.NoError(err)
|
||||
|
||||
require.Equal(ids.Empty, blkIntf.(*blkexecutor.Block).MerkleRoot(),
|
||||
"gate off: built block must carry an empty root")
|
||||
|
||||
// And it verifies and accepts under the historical empty-root rule.
|
||||
require.NoError(blkIntf.Verify(context.Background()))
|
||||
require.NoError(blkIntf.Accept(context.Background()))
|
||||
}
|
||||
|
||||
// TestMerkleRootGateOnAcceptsAndRejects is deliverable case (b), end to end: a
|
||||
// VM with the gate ON (activation height 0) builds a real block that carries a
|
||||
// non-empty xvm execution_root, that block verifies and accepts, and a sibling
|
||||
// block whose root is tampered is rejected with ErrUnexpectedMerkleRoot.
|
||||
func TestMerkleRootGateOnAcceptsAndRejects(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
env := setup(t, &envConfig{
|
||||
fork: upgrade.Default,
|
||||
merkleRootActivationHeight: u64ptr(0), // activate from genesis
|
||||
})
|
||||
env.vm.Lock.Unlock()
|
||||
|
||||
tx := newTx(t, env.genesisBytes, env.consensusRuntime.ChainID, env.vm.parser, "LUX")
|
||||
require.NoError(env.vm.network.IssueTxFromRPC(tx))
|
||||
|
||||
blkIntf, err := env.vm.BuildBlock(context.Background())
|
||||
require.NoError(err)
|
||||
|
||||
// The builder stamped the execution_root: non-empty, and equal to the
|
||||
// canonical projection over the block's parent root + tx family.
|
||||
built := blkIntf.(*blkexecutor.Block)
|
||||
root := built.MerkleRoot()
|
||||
require.NotEqual(ids.Empty, root, "gate on: built block must carry a non-empty root")
|
||||
|
||||
parentID := built.Parent()
|
||||
parentBlk, err := env.vm.chainManager.GetStatelessBlock(parentID)
|
||||
require.NoError(err)
|
||||
wantRoot := blkexecutor.BlockExecutionRoot(parentBlk.MerkleRoot(), built.Txs(), nil, built.Height())
|
||||
require.Equal(wantRoot, root, "stamped root must equal the canonical execution_root")
|
||||
|
||||
// Build a sibling block identical in every way EXCEPT a deliberately wrong
|
||||
// root, parse it through the VM, and verify it: the executor recomputes the
|
||||
// expected root and rejects the mismatch.
|
||||
cm := env.vm.parser.Codec()
|
||||
wrongRoot := ids.GenerateTestID()
|
||||
require.NotEqual(wrongRoot, root)
|
||||
tampered, err := block.NewStandardBlockWithRoot(
|
||||
parentID,
|
||||
built.Height(),
|
||||
time.Unix(int64(built.Timestamp().Unix()), 0),
|
||||
wrongRoot,
|
||||
built.Txs(),
|
||||
cm,
|
||||
)
|
||||
require.NoError(err)
|
||||
tamperedParsed, err := env.vm.ParseBlock(context.Background(), tampered.Bytes())
|
||||
require.NoError(err)
|
||||
err = tamperedParsed.Verify(context.Background())
|
||||
require.ErrorIs(err, blkexecutor.ErrUnexpectedMerkleRoot)
|
||||
|
||||
// The correctly-stamped block still verifies and accepts.
|
||||
require.NoError(blkIntf.Verify(context.Background()))
|
||||
require.NoError(blkIntf.Accept(context.Background()))
|
||||
}
|
||||
@@ -13,20 +13,20 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/address"
|
||||
"github.com/luxfi/vm"
|
||||
"github.com/luxfi/runtime"
|
||||
"github.com/luxfi/consensus/core/choices"
|
||||
validators "github.com/luxfi/validators"
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/crypto/secp256k1"
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/vm/chains/atomic"
|
||||
"github.com/luxfi/node/upgrade"
|
||||
|
||||
lux "github.com/luxfi/utxo"
|
||||
"github.com/luxfi/runtime"
|
||||
validators "github.com/luxfi/validators"
|
||||
"github.com/luxfi/vm"
|
||||
"github.com/luxfi/vm/chains/atomic"
|
||||
|
||||
"github.com/luxfi/node/vms/xvm/txs"
|
||||
"github.com/luxfi/node/vms/xvm/txs/txstest"
|
||||
lux "github.com/luxfi/utxo"
|
||||
"github.com/luxfi/utxo/nftfx"
|
||||
"github.com/luxfi/utxo/propertyfx"
|
||||
"github.com/luxfi/utxo/secp256k1fx"
|
||||
@@ -54,17 +54,21 @@ type envConfig struct {
|
||||
notLinearized bool
|
||||
additionalFxs []interface{}
|
||||
indexTransactions bool // Enable transaction indexing
|
||||
// merkleRootActivationHeight, when non-nil, overrides the xvm
|
||||
// execution_root activation height in the VM's config. nil leaves the safe
|
||||
// default (never sentinel) from DefaultConfig in place.
|
||||
merkleRootActivationHeight *uint64
|
||||
}
|
||||
|
||||
// testEnv is the test environment
|
||||
type testEnv struct {
|
||||
vm *VM
|
||||
vm *VM
|
||||
consensusRuntime *runtime.Runtime
|
||||
genesisBytes []byte
|
||||
genesisTx *txs.Tx
|
||||
testLock *sync.Mutex
|
||||
txBuilder *txstest.Builder
|
||||
sharedMemory *atomic.Memory
|
||||
genesisBytes []byte
|
||||
genesisTx *txs.Tx
|
||||
testLock *sync.Mutex
|
||||
txBuilder *txstest.Builder
|
||||
sharedMemory *atomic.Memory
|
||||
}
|
||||
|
||||
// newGenesisBytesTest creates test genesis bytes
|
||||
@@ -270,6 +274,9 @@ func setup(t testing.TB, config *envConfig) *testEnv {
|
||||
if config.indexTransactions {
|
||||
vmConfig.IndexTransactions = true
|
||||
}
|
||||
if config.merkleRootActivationHeight != nil {
|
||||
vmConfig.MerkleRootActivationHeight = *config.merkleRootActivationHeight
|
||||
}
|
||||
configBytes, err := json.Marshal(vmConfig, jsonv1.FormatDurationAsNano(true))
|
||||
require.NoError(err)
|
||||
|
||||
@@ -277,14 +284,14 @@ func setup(t testing.TB, config *envConfig) *testEnv {
|
||||
require.NoError(vmImpl.Initialize(
|
||||
context.Background(),
|
||||
vm.Init{
|
||||
Runtime: rt,
|
||||
DB: baseDB,
|
||||
Genesis: genesisBytes,
|
||||
Upgrade: nil,
|
||||
Config: configBytes,
|
||||
Runtime: rt,
|
||||
DB: baseDB,
|
||||
Genesis: genesisBytes,
|
||||
Upgrade: nil,
|
||||
Config: configBytes,
|
||||
ToEngine: toEngine,
|
||||
Fx: fxs,
|
||||
Sender: appSender,
|
||||
Fx: fxs,
|
||||
Sender: appSender,
|
||||
},
|
||||
))
|
||||
|
||||
@@ -305,13 +312,13 @@ func setup(t testing.TB, config *envConfig) *testEnv {
|
||||
txBuilder.SetContextIDs(rt.NetworkID, rt.ChainID)
|
||||
|
||||
env := &testEnv{
|
||||
vm: vmImpl,
|
||||
vm: vmImpl,
|
||||
consensusRuntime: rt,
|
||||
genesisBytes: genesisBytes,
|
||||
genesisTx: genesisTx,
|
||||
testLock: testLock,
|
||||
txBuilder: txBuilder,
|
||||
sharedMemory: sharedMemory,
|
||||
genesisBytes: genesisBytes,
|
||||
genesisTx: genesisTx,
|
||||
testLock: testLock,
|
||||
txBuilder: txBuilder,
|
||||
sharedMemory: sharedMemory,
|
||||
}
|
||||
|
||||
// Register cleanup to prevent goroutine leaks
|
||||
|
||||
Reference in New Issue
Block a user