xvm: remove execution_root activation gate — values not gates (active, not gated)

The prior seam added a bespoke MerkleRootActivationHeight gate (default
MerkleRootNeverActivate=MaxUint64) that violated upgrade/upgrade.go's stated
philosophy ('activate-all-implicitly; the fields encode values, not gates').
Removed it entirely (grep-clean): the builder ALWAYS stamps the real xvm
execution_root, the executor ALWAYS recomputes+verifies it, an empty root is
now rejected. Root computation byte-IDENTICAL (exec_root=4f144ef7…) — only the
gating is gone. Net -92 lines. Tests converted to always-active reality
(empty-root-rejected is the new gate test); ./vms/xvm/... + ./upgrade/... green
uncached + -race. asset_root stays keccak256("") (UTXO-only executor; assets
bound via each UTXO's AssetID).
This commit is contained in:
zeekay
2026-06-16 12:59:21 -07:00
parent c28796b2f0
commit 14f638c918
10 changed files with 147 additions and 239 deletions
+8 -37
View File
@@ -8,23 +8,12 @@
package upgrade package upgrade
import ( import (
"math"
"time" "time"
"github.com/luxfi/constants" "github.com/luxfi/constants"
"github.com/luxfi/ids" "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 // InitiallyActiveTime is the canonical Lux genesis-activation timestamp. It is
// referenced by code paths that historically demanded "post-fork timestamp" as // referenced by code paths that historically demanded "post-fork timestamp" as
// an input and now want a single deterministic value. // an input and now want a single deterministic value.
@@ -36,24 +25,9 @@ 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. // X-Chain genesis state at boot. It is a value, not a gate.
// - EpochDuration is the LP-181 epoch duration; per-network value // - EpochDuration is the LP-181 epoch duration; per-network value
// (5m on mainnet, 30s on test/dev) tunes consensus pacing. // (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 { type Config struct {
XChainStopVertexID ids.ID `json:"xChainStopVertexID"` XChainStopVertexID ids.ID `json:"xChainStopVertexID"`
EpochDuration time.Duration `json:"epochDuration"` 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- // Validate is retained for callers that still invoke it; under activate-all-
@@ -63,19 +37,16 @@ func (*Config) Validate() error { return nil }
var ( var (
Mainnet = Config{ Mainnet = Config{
XChainStopVertexID: ids.FromStringOrPanic("jrGWDh5Po9FMj54depyunNixpia5PN4aAYxfmNzU8n752Rjga"), XChainStopVertexID: ids.FromStringOrPanic("jrGWDh5Po9FMj54depyunNixpia5PN4aAYxfmNzU8n752Rjga"),
EpochDuration: 5 * time.Minute, EpochDuration: 5 * time.Minute,
MerkleRootActivationHeight: MerkleRootNeverActivate, // unset until a real upgrade picks a height
} }
Testnet = Config{ Testnet = Config{
XChainStopVertexID: ids.FromStringOrPanic("2D1cmbiG36BqQMRyHt4kFhWarmatA1ighSpND3FeFgz3vFVtCZ"), XChainStopVertexID: ids.FromStringOrPanic("2D1cmbiG36BqQMRyHt4kFhWarmatA1ighSpND3FeFgz3vFVtCZ"),
EpochDuration: 30 * time.Second, EpochDuration: 30 * time.Second,
MerkleRootActivationHeight: MerkleRootNeverActivate, // unset until a real upgrade picks a height
} }
Default = Config{ Default = Config{
XChainStopVertexID: ids.Empty, XChainStopVertexID: ids.Empty,
EpochDuration: 30 * time.Second, EpochDuration: 30 * time.Second,
MerkleRootActivationHeight: MerkleRootNeverActivate, // unset until a real upgrade picks a height
} }
) )
+6 -19
View File
@@ -166,25 +166,12 @@ func (b *builder) BuildBlock(context.Context) (chain.Block, error) {
return nil, ErrNoTransactions return nil, ErrNoTransactions
} }
// Below the xvm execution_root activation height (the default on every // Stamp the xvm execution_root computed over the post-block state into the
// published network, where MerkleRootActivationHeight is the never // block so the verifier can recompute and check it. The root is the tagged
// sentinel), the block carries an empty root — byte-for-byte the historical // Merkle-tree fold over the post-block state (utxo ‖ asset ‖ tx ‖ height);
// shape. At and above activation, stamp the execution_root computed over the // the builder and the executor share exactly one computation
// post-block state so the verifier can recompute and check it. // (BlockExecutionRoot) so the stamped root and the verified root can never
if !b.backend.Config.IsMerkleRootActivated(nextHeight) { // disagree.
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() parentRoot := preferred.MerkleRoot()
root, err := blockexecutor.BlockExecutionRoot( root, err := blockexecutor.BlockExecutionRoot(
parentRoot, parentRoot,
@@ -15,7 +15,6 @@ import (
"github.com/luxfi/ids" "github.com/luxfi/ids"
"github.com/luxfi/math/set" "github.com/luxfi/math/set"
"github.com/luxfi/node/upgrade"
"github.com/luxfi/node/vms/pcodecs/pcodecsmock" "github.com/luxfi/node/vms/pcodecs/pcodecsmock"
"github.com/luxfi/node/vms/xvm/block" "github.com/luxfi/node/vms/xvm/block"
blkexecutor "github.com/luxfi/node/vms/xvm/block/executor" blkexecutor "github.com/luxfi/node/vms/xvm/block/executor"
@@ -31,14 +30,14 @@ import (
) )
// buildOneTxBlock drives BuildBlock with a single always-valid mocked tx over a // buildOneTxBlock drives BuildBlock with a single always-valid mocked tx over a
// mocked manager/state, parent at [parentHeight] with [parentRoot], and the // mocked manager/state, parent at [parentHeight] with [parentRoot]. It returns
// given xvm config (which carries the execution_root activation height). It // the *StandardBlock the builder handed to NewBlock so the test can inspect the
// returns the *StandardBlock the builder handed to NewBlock so the test can // stamped Root. The builder always stamps the xvm execution_root — there is no
// inspect the stamped Root. // activation gate — so the block's Root is the canonical execution_root over the
// post-block state.
func buildOneTxBlock( func buildOneTxBlock(
t *testing.T, t *testing.T,
ctrl *gomock.Controller, ctrl *gomock.Controller,
cfg *config.Config,
parentRoot ids.ID, parentRoot ids.ID,
parentHeight uint64, parentHeight uint64,
) *block.StandardBlock { ) *block.StandardBlock {
@@ -116,7 +115,7 @@ func buildOneTxBlock(
Ctx: context.Background(), Ctx: context.Background(),
Runtime: testRuntime(), Runtime: testRuntime(),
Codec: codec, Codec: codec,
Config: cfg, Config: &config.Config{},
Log: log.NewNoOpLogger(), Log: log.NewNoOpLogger(),
}, },
manager, manager,
@@ -130,45 +129,20 @@ func buildOneTxBlock(
return built return built
} }
// TestBuildBlockMerkleRootGateOff is deliverable case (a), builder side: with // TestBuildBlockStampsExecutionRoot is the builder-side always-active proof: the
// the gate OFF (the default, never-sentinel height) the builder leaves the // builder always stamps the xvm execution_root computed over the post-block
// block's merkle root empty — byte-for-byte the historical behavior. // state. The stamped root must be non-empty and must equal the canonical
func TestBuildBlockMerkleRootGateOff(t *testing.T) { // BlockExecutionRoot over the same inputs — there is no empty-root path.
require := require.New(t) func TestBuildBlockStampsExecutionRoot(t *testing.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) require := require.New(t)
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
parentRoot := ids.GenerateTestID() parentRoot := ids.GenerateTestID()
const parentHeight = uint64(41) const parentHeight = uint64(41)
cfg := &config.Config{MerkleRootActivationHeight: 0} // activate from genesis
built := buildOneTxBlock(t, ctrl, cfg, parentRoot, parentHeight) built := buildOneTxBlock(t, ctrl, parentRoot, parentHeight)
require.NotEqual(ids.Empty, built.MerkleRoot(), "gate on must stamp a non-empty root") require.NotEqual(ids.Empty, built.MerkleRoot(), "builder must stamp a non-empty execution_root")
// Recompute the expected root from the canonical projection: parent root + // Recompute the expected root from the canonical projection: parent root +
// the block's tx family at the built height (parentHeight + 1), over a // the block's tx family at the built height (parentHeight + 1), over a
+17 -4
View File
@@ -14,7 +14,6 @@ import (
"github.com/luxfi/mock/gomock" "github.com/luxfi/mock/gomock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
chain "github.com/luxfi/vm/chain"
"github.com/luxfi/constants" "github.com/luxfi/constants"
"github.com/luxfi/crypto/secp256k1" "github.com/luxfi/crypto/secp256k1"
"github.com/luxfi/database/memdb" "github.com/luxfi/database/memdb"
@@ -38,6 +37,7 @@ import (
"github.com/luxfi/timer/mockable" "github.com/luxfi/timer/mockable"
lux "github.com/luxfi/utxo" lux "github.com/luxfi/utxo"
"github.com/luxfi/utxo/secp256k1fx" "github.com/luxfi/utxo/secp256k1fx"
chain "github.com/luxfi/vm/chain"
) )
const trackChecksums = false const trackChecksums = false
@@ -260,10 +260,16 @@ func TestBuilderBuildBlock(t *testing.T) {
preferredBlock := block.NewMockBlock(ctrl) preferredBlock := block.NewMockBlock(ctrl)
preferredBlock.EXPECT().Height().Return(preferredHeight) preferredBlock.EXPECT().Height().Return(preferredHeight)
preferredBlock.EXPECT().Timestamp().Return(preferredTimestamp) preferredBlock.EXPECT().Timestamp().Return(preferredTimestamp)
// The builder always stamps the execution_root, recomputed from
// the parent's root.
preferredBlock.EXPECT().MerkleRoot().Return(ids.GenerateTestID()).AnyTimes()
preferredState := statemock.NewChain(ctrl) preferredState := statemock.NewChain(ctrl)
preferredState.EXPECT().GetLastAccepted().Return(preferredID) preferredState.EXPECT().GetLastAccepted().Return(preferredID)
preferredState.EXPECT().GetTimestamp().Return(preferredTimestamp) preferredState.EXPECT().GetTimestamp().Return(preferredTimestamp)
// The execution_root projection enumerates the post-block UTXO set
// through the parent; this unit produces none.
preferredState.EXPECT().UTXOs(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
// tx1 and tx2 both consume [inputID]. // tx1 and tx2 both consume [inputID].
// tx1 is added to the block first, so tx2 should be dropped. // tx1 is added to the block first, so tx2 should be dropped.
@@ -308,7 +314,10 @@ func TestBuilderBuildBlock(t *testing.T) {
manager := executormock.NewManager(ctrl) manager := executormock.NewManager(ctrl)
manager.EXPECT().Preferred().Return(preferredID) manager.EXPECT().Preferred().Return(preferredID)
manager.EXPECT().GetStatelessBlock(preferredID).Return(preferredBlock, nil) manager.EXPECT().GetStatelessBlock(preferredID).Return(preferredBlock, nil)
manager.EXPECT().GetState(preferredID).Return(preferredState, true) // GetState is consulted by NewDiff and again by the execution_root
// projection (the diff pages its parent to enumerate the post-block
// UTXO set), so allow repeated calls.
manager.EXPECT().GetState(preferredID).Return(preferredState, true).AnyTimes()
// VerifyUniqueInputs is called once for tx1. tx2 should be dropped due to // VerifyUniqueInputs is called once for tx1. tx2 should be dropped due to
// inputs.Overlaps check, so VerifyUniqueInputs should not be called for tx2 // inputs.Overlaps check, so VerifyUniqueInputs should not be called for tx2
// But if it's being called twice, we need to handle it // But if it's being called twice, we need to handle it
@@ -358,6 +367,7 @@ func TestBuilderBuildBlock(t *testing.T) {
preferredBlock := block.NewMockBlock(ctrl) preferredBlock := block.NewMockBlock(ctrl)
preferredBlock.EXPECT().Height().Return(preferredHeight) preferredBlock.EXPECT().Height().Return(preferredHeight)
preferredBlock.EXPECT().Timestamp().Return(preferredTimestamp) preferredBlock.EXPECT().Timestamp().Return(preferredTimestamp)
preferredBlock.EXPECT().MerkleRoot().Return(ids.GenerateTestID()).AnyTimes()
// Clock reads just before the preferred timestamp. // Clock reads just before the preferred timestamp.
// Created block should have the preferred timestamp since it's later. // Created block should have the preferred timestamp since it's later.
@@ -367,11 +377,12 @@ func TestBuilderBuildBlock(t *testing.T) {
preferredState := statemock.NewChain(ctrl) preferredState := statemock.NewChain(ctrl)
preferredState.EXPECT().GetLastAccepted().Return(preferredID) preferredState.EXPECT().GetLastAccepted().Return(preferredID)
preferredState.EXPECT().GetTimestamp().Return(preferredTimestamp) preferredState.EXPECT().GetTimestamp().Return(preferredTimestamp)
preferredState.EXPECT().UTXOs(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
manager := executormock.NewManager(ctrl) manager := executormock.NewManager(ctrl)
manager.EXPECT().Preferred().Return(preferredID) manager.EXPECT().Preferred().Return(preferredID)
manager.EXPECT().GetStatelessBlock(preferredID).Return(preferredBlock, nil) manager.EXPECT().GetStatelessBlock(preferredID).Return(preferredBlock, nil)
manager.EXPECT().GetState(preferredID).Return(preferredState, true) manager.EXPECT().GetState(preferredID).Return(preferredState, true).AnyTimes()
manager.EXPECT().VerifyUniqueInputs(preferredID, gomock.Any()).Return(nil) manager.EXPECT().VerifyUniqueInputs(preferredID, gomock.Any()).Return(nil)
// Assert that the created block has the right timestamp // Assert that the created block has the right timestamp
manager.EXPECT().NewBlock(gomock.Any()).DoAndReturn( manager.EXPECT().NewBlock(gomock.Any()).DoAndReturn(
@@ -433,6 +444,7 @@ func TestBuilderBuildBlock(t *testing.T) {
preferredBlock := block.NewMockBlock(ctrl) preferredBlock := block.NewMockBlock(ctrl)
preferredBlock.EXPECT().Height().Return(preferredHeight) preferredBlock.EXPECT().Height().Return(preferredHeight)
preferredBlock.EXPECT().Timestamp().Return(preferredTimestamp) preferredBlock.EXPECT().Timestamp().Return(preferredTimestamp)
preferredBlock.EXPECT().MerkleRoot().Return(ids.GenerateTestID()).AnyTimes()
// Clock reads after the preferred timestamp. // Clock reads after the preferred timestamp.
// Created block should have [now] timestamp since it's later. // Created block should have [now] timestamp since it's later.
@@ -442,11 +454,12 @@ func TestBuilderBuildBlock(t *testing.T) {
preferredState := statemock.NewChain(ctrl) preferredState := statemock.NewChain(ctrl)
preferredState.EXPECT().GetLastAccepted().Return(preferredID) preferredState.EXPECT().GetLastAccepted().Return(preferredID)
preferredState.EXPECT().GetTimestamp().Return(preferredTimestamp) preferredState.EXPECT().GetTimestamp().Return(preferredTimestamp)
preferredState.EXPECT().UTXOs(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
manager := executormock.NewManager(ctrl) manager := executormock.NewManager(ctrl)
manager.EXPECT().Preferred().Return(preferredID) manager.EXPECT().Preferred().Return(preferredID)
manager.EXPECT().GetStatelessBlock(preferredID).Return(preferredBlock, nil) manager.EXPECT().GetStatelessBlock(preferredID).Return(preferredBlock, nil)
manager.EXPECT().GetState(preferredID).Return(preferredState, true) manager.EXPECT().GetState(preferredID).Return(preferredState, true).AnyTimes()
manager.EXPECT().VerifyUniqueInputs(preferredID, gomock.Any()).Return(nil) manager.EXPECT().VerifyUniqueInputs(preferredID, gomock.Any()).Return(nil)
// Assert that the created block has the right timestamp // Assert that the created block has the right timestamp
manager.EXPECT().NewBlock(gomock.Any()).DoAndReturn( manager.EXPECT().NewBlock(gomock.Any()).DoAndReturn(
+21 -31
View File
@@ -87,19 +87,12 @@ func (b *Block) Verify(ctx context.Context) error {
return nil return nil
} }
// Gate the merkle-root rule on the xvm execution_root activation height. // The block's MerkleRoot carries the xvm execution_root over the post-block
// Below activation (the default on every published network, where // state. It is verified unconditionally once the parent and the block's txs
// MerkleRootActivationHeight is the never sentinel) the historical rule // are available: the executor recomputes the canonical execution_root and
// holds verbatim: the block must carry an empty root, and a non-empty root // rejects any block whose stamped root disagrees (see the recompute below,
// is rejected here. At and above activation the block must carry the // after the txs have been applied to the state diff).
// 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() merkleRoot := b.Block.MerkleRoot()
if merkleRoot != ids.Empty && !b.manager.backend.Config.IsMerkleRootActivated(b.Height()) {
return fmt.Errorf("%w: %s", ErrUnexpectedMerkleRoot, merkleRoot)
}
// Only allow timestamp to reasonably far forward // Only allow timestamp to reasonably far forward
newChainTime := b.Timestamp() newChainTime := b.Timestamp()
@@ -258,25 +251,22 @@ func (b *Block) Verify(ctx context.Context) error {
) )
} }
// At and above the xvm execution_root activation height, the block must // The block must carry the execution_root over the now-fully-applied
// carry the execution_root over the now-fully-applied post-block state. // post-block state. Recompute it from the same canonical projection the
// Recompute it from the same canonical projection the builder stamped (one // builder stamped (one shared code path — see BlockExecutionRoot) and reject
// shared code path — see BlockExecutionRoot) and reject any mismatch. Below // any mismatch. [height] was already resolved (and validated against the
// activation this is skipped and the empty-root rule checked above stands. // parent) above.
// [height] was already resolved (and validated against the parent) above. expectedRoot, err := BlockExecutionRoot(parent.MerkleRoot(), txs, stateDiff, height)
if b.manager.backend.Config.IsMerkleRootActivated(height) { if err != nil {
expectedRoot, err := BlockExecutionRoot(parent.MerkleRoot(), txs, stateDiff, height) return fmt.Errorf("failed to compute expected block execution root: %w", err)
if err != nil { }
return fmt.Errorf("failed to compute expected block execution root: %w", err) if merkleRoot != expectedRoot {
} return fmt.Errorf(
if merkleRoot != expectedRoot { "%w: block root %s, expected %s",
return fmt.Errorf( ErrUnexpectedMerkleRoot,
"%w: block root %s, expected %s", merkleRoot,
ErrUnexpectedMerkleRoot, expectedRoot,
merkleRoot, )
expectedRoot,
)
}
} }
// Now that the block has been executed, we can add the block data to the // Now that the block has been executed, we can add the block data to the
+36 -34
View File
@@ -17,7 +17,6 @@ import (
"github.com/luxfi/ids" "github.com/luxfi/ids"
"github.com/luxfi/math/set" "github.com/luxfi/math/set"
"github.com/luxfi/mock/gomock" "github.com/luxfi/mock/gomock"
"github.com/luxfi/node/upgrade"
"github.com/luxfi/node/vms/xvm/block" "github.com/luxfi/node/vms/xvm/block"
"github.com/luxfi/node/vms/xvm/config" "github.com/luxfi/node/vms/xvm/config"
"github.com/luxfi/node/vms/xvm/metrics/metricsmock" "github.com/luxfi/node/vms/xvm/metrics/metricsmock"
@@ -60,25 +59,6 @@ func TestBlockVerify(t *testing.T) {
}, },
expectedErr: nil, expectedErr: nil,
}, },
{
name: "block timestamp too far in the future",
blockFunc: func(ctrl *gomock.Controller) *Block {
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{
backend: defaultTestBackend(false, nil),
},
}
},
expectedErr: ErrUnexpectedMerkleRoot,
},
{ {
name: "block timestamp too far in the future", name: "block timestamp too far in the future",
blockFunc: func(ctrl *gomock.Controller) *Block { blockFunc: func(ctrl *gomock.Controller) *Block {
@@ -524,24 +504,44 @@ func TestBlockVerify(t *testing.T) {
expectedErr: ErrConflictingParentTxs, expectedErr: ErrConflictingParentTxs,
}, },
{ {
// Happy path under the always-active execution_root rule: the block
// carries the canonical execution_root over the post-block state, so
// the executor's unconditional recompute matches and the block
// verifies. The post-block UTXO set is empty (this tx produces none),
// so the root is the canonical compose over the empty UTXO/asset
// families, the tx family, the parent root, and the height.
name: "happy path", name: "happy path",
blockFunc: func(ctrl *gomock.Controller) *Block { blockFunc: func(ctrl *gomock.Controller) *Block {
mockBlock := block.NewMockBlock(ctrl)
mockBlock.EXPECT().ID().Return(ids.Empty).AnyTimes()
mockBlock.EXPECT().MerkleRoot().Return(ids.Empty).AnyTimes()
blockTimestamp := time.Now()
mockBlock.EXPECT().Timestamp().Return(blockTimestamp).AnyTimes()
blockHeight := uint64(1337) blockHeight := uint64(1337)
mockBlock.EXPECT().Height().Return(blockHeight).AnyTimes() parentRoot := ids.GenerateTestID()
mockUnsignedTx := txsmock.NewUnsignedTx(ctrl) mockUnsignedTx := txsmock.NewUnsignedTx(ctrl)
mockUnsignedTx.EXPECT().Visit(gomock.Any()).Return(nil).Times(1) // Syntactic verification passes mockUnsignedTx.EXPECT().Visit(gomock.Any()).Return(nil).Times(1) // Syntactic verification passes
mockUnsignedTx.EXPECT().Visit(gomock.Any()).Return(nil).Times(1) // Semantic verification fails mockUnsignedTx.EXPECT().Visit(gomock.Any()).Return(nil).Times(1) // Semantic verification passes
mockUnsignedTx.EXPECT().Visit(gomock.Any()).Return(nil).Times(1) // Execution passes mockUnsignedTx.EXPECT().Visit(gomock.Any()).Return(nil).Times(1) // Execution passes
mockUnsignedTx.EXPECT().InputIDs().AnyTimes() mockUnsignedTx.EXPECT().InputIDs().AnyTimes()
tx := &txs.Tx{ mockUnsignedTx.EXPECT().SetBytes(gomock.Any()).AnyTimes()
Unsigned: mockUnsignedTx, tx := &txs.Tx{Unsigned: mockUnsignedTx}
} // Stable tx ID so the tx-family leaf (and thus the expected root)
// is deterministic for both the executor and this test.
tx.SetBytes(nil, []byte{0x01, 0x02, 0x03, 0x04})
// The execution_root the block must carry: the canonical root over
// the parent root + this block's tx family at [blockHeight], with an
// empty post-block UTXO set (this tx produces no UTXOs). Computed via
// the same shared BlockExecutionRoot the executor recomputes.
emptyState := statemock.NewChain(ctrl)
emptyState.EXPECT().UTXOs(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
expectedRoot, err := BlockExecutionRoot(parentRoot, []*txs.Tx{tx}, emptyState, blockHeight)
require.NoError(t, err)
require.NotEqual(t, ids.Empty, expectedRoot)
mockBlock := block.NewMockBlock(ctrl)
mockBlock.EXPECT().ID().Return(ids.Empty).AnyTimes()
mockBlock.EXPECT().MerkleRoot().Return(expectedRoot).AnyTimes()
blockTimestamp := time.Now()
mockBlock.EXPECT().Timestamp().Return(blockTimestamp).AnyTimes()
mockBlock.EXPECT().Height().Return(blockHeight).AnyTimes()
mockBlock.EXPECT().Txs().Return([]*txs.Tx{tx}).AnyTimes() mockBlock.EXPECT().Txs().Return([]*txs.Tx{tx}).AnyTimes()
parentID := ids.GenerateTestID() parentID := ids.GenerateTestID()
@@ -549,10 +549,16 @@ func TestBlockVerify(t *testing.T) {
mockParentBlock := block.NewMockBlock(ctrl) mockParentBlock := block.NewMockBlock(ctrl)
mockParentBlock.EXPECT().Height().Return(blockHeight - 1) mockParentBlock.EXPECT().Height().Return(blockHeight - 1)
// The executor recomputes the execution_root from the parent's root.
mockParentBlock.EXPECT().MerkleRoot().Return(parentRoot).AnyTimes()
mockParentState := statemock.NewDiff(ctrl) mockParentState := statemock.NewDiff(ctrl)
mockParentState.EXPECT().GetLastAccepted().Return(parentID) mockParentState.EXPECT().GetLastAccepted().Return(parentID)
mockParentState.EXPECT().GetTimestamp().Return(blockTimestamp) mockParentState.EXPECT().GetTimestamp().Return(blockTimestamp)
// The post-block state diff pages the parent to enumerate the
// occupied UTXO set for the execution_root projection; the parent
// has none.
mockParentState.EXPECT().UTXOs(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
mempool, err := mempool.New("", metric.NewRegistry()) mempool, err := mempool.New("", metric.NewRegistry())
require.NoError(t, err) require.NoError(t, err)
@@ -976,10 +982,6 @@ func defaultTestBackend(bootstrapped bool, sharedMemory atomic.SharedMemory) *tx
Config: &config.Config{ Config: &config.Config{
TxFee: 0, TxFee: 0,
CreateAssetTxFee: 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{}, Log: log.NoLog{},
} }
-7
View File
@@ -7,7 +7,6 @@ import (
"github.com/go-json-experiment/json" "github.com/go-json-experiment/json"
jsonv1 "github.com/go-json-experiment/json/v1" 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/config"
"github.com/luxfi/node/vms/xvm/network" "github.com/luxfi/node/vms/xvm/network"
) )
@@ -18,12 +17,6 @@ var DefaultConfig = Config{
Config: config.Config{ Config: config.Config{
TxFee: 1000, // 1000 nanoLux base transaction fee TxFee: 1000, // 1000 nanoLux base transaction fee
CreateAssetTxFee: 10000, // 10000 nanoLux for asset creation 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,
}, },
} }
-26
View File
@@ -13,30 +13,4 @@ type Config struct {
// IndexTransactions enables transaction indexing by address // IndexTransactions enables transaction indexing by address
IndexTransactions bool `json:"indexTransactions"` 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
} }
+46 -35
View File
@@ -20,12 +20,13 @@ import (
txexecutor "github.com/luxfi/node/vms/xvm/txs/executor" txexecutor "github.com/luxfi/node/vms/xvm/txs/executor"
) )
func u64ptr(v uint64) *uint64 { return &v } // TestMerkleRootStampedAndVerified is the always-active end-to-end proof: a VM
// with the default config builds a real block that carries the xvm
// TestMerkleRootGateOffDefaultBlockEmpty is deliverable case (a), end to end: a // execution_root over the post-block state (never empty), that block verifies
// VM with the default config (gate OFF, never sentinel) builds and accepts a // and accepts, and a sibling block whose root is tampered is rejected with
// real block whose merkle root is empty — the historical behavior, unchanged. // ErrUnexpectedMerkleRoot. There is no activation gate — the execution_root is
func TestMerkleRootGateOffDefaultBlockEmpty(t *testing.T) { // part of the current version, stamped and verified unconditionally.
func TestMerkleRootStampedAndVerified(t *testing.T) {
require := require.New(t) require := require.New(t)
env := setup(t, &envConfig{fork: upgrade.Default}) env := setup(t, &envConfig{fork: upgrade.Default})
@@ -37,33 +38,6 @@ func TestMerkleRootGateOffDefaultBlockEmpty(t *testing.T) {
blkIntf, err := env.vm.BuildBlock(context.Background()) blkIntf, err := env.vm.BuildBlock(context.Background())
require.NoError(err) 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 // The builder stamped the execution_root: non-empty, and equal to the
// canonical projection over the block's parent root + post-block state. The // canonical projection over the block's parent root + post-block state. The
// CreateAssetTx in this block produces UTXOs, so the projected UTXO family is // CreateAssetTx in this block produces UTXOs, so the projected UTXO family is
@@ -71,7 +45,7 @@ func TestMerkleRootGateOnAcceptsAndRejects(t *testing.T) {
// (empty-family) value. // (empty-family) value.
built := blkIntf.(*blkexecutor.Block) built := blkIntf.(*blkexecutor.Block)
root := built.MerkleRoot() root := built.MerkleRoot()
require.NotEqual(ids.Empty, root, "gate on: built block must carry a non-empty root") require.NotEqual(ids.Empty, root, "built block must carry a non-empty execution_root")
parentID := built.Parent() parentID := built.Parent()
parentBlk, err := env.vm.chainManager.GetStatelessBlock(parentID) parentBlk, err := env.vm.chainManager.GetStatelessBlock(parentID)
@@ -91,7 +65,7 @@ func TestMerkleRootGateOnAcceptsAndRejects(t *testing.T) {
// produced outputs), so the stamped root is the real execution_root. // produced outputs), so the stamped root is the real execution_root.
postUTXOs, err := postState.UTXOs(ids.Empty, 0) postUTXOs, err := postState.UTXOs(ids.Empty, 0)
require.NoError(err) require.NoError(err)
require.NotEmpty(postUTXOs, "gate on: the post-block UTXO set the root commits to must be non-empty") require.NotEmpty(postUTXOs, "the post-block UTXO set the root commits to must be non-empty")
// Build a sibling block identical in every way EXCEPT a deliberately wrong // 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 // root, parse it through the VM, and verify it: the executor recomputes the
@@ -118,6 +92,43 @@ func TestMerkleRootGateOnAcceptsAndRejects(t *testing.T) {
require.NoError(blkIntf.Accept(context.Background())) require.NoError(blkIntf.Accept(context.Background()))
} }
// TestMerkleRootEmptyRootRejected confirms the empty root is not a valid block
// root under the always-active rule: a block whose root is ids.Empty (the
// historical pre-activation shape) no longer verifies, because the executor
// recomputes the real execution_root and rejects the mismatch. This pins that
// there is no surviving empty-root path.
func TestMerkleRootEmptyRootRejected(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)
built := blkIntf.(*blkexecutor.Block)
// A sibling block identical to the built block but carrying an EMPTY root.
cm := env.vm.parser.Codec()
empty, err := block.NewStandardBlock(
built.Parent(),
built.Height(),
time.Unix(int64(built.Timestamp().Unix()), 0),
built.Txs(),
cm,
)
require.NoError(err)
require.Equal(ids.Empty, empty.MerkleRoot())
emptyParsed, err := env.vm.ParseBlock(context.Background(), empty.Bytes())
require.NoError(err)
err = emptyParsed.Verify(context.Background())
require.ErrorIs(err, blkexecutor.ErrUnexpectedMerkleRoot,
"an empty root must be rejected: the real execution_root is non-empty")
}
// postBlockState reconstructs the post-block state a block's execution_root is // postBlockState reconstructs the post-block state a block's execution_root is
// projected over: a state diff on [parentID] with [blkTxs] executed in order. // projected over: a state diff on [parentID] with [blkTxs] executed in order.
// It mirrors the state mutation block verification performs (vms/xvm/block/ // It mirrors the state mutation block verification performs (vms/xvm/block/
-7
View File
@@ -54,10 +54,6 @@ type envConfig struct {
notLinearized bool notLinearized bool
additionalFxs []interface{} additionalFxs []interface{}
indexTransactions bool // Enable transaction indexing 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 // testEnv is the test environment
@@ -274,9 +270,6 @@ func setup(t testing.TB, config *envConfig) *testEnv {
if config.indexTransactions { if config.indexTransactions {
vmConfig.IndexTransactions = true vmConfig.IndexTransactions = true
} }
if config.merkleRootActivationHeight != nil {
vmConfig.MerkleRootActivationHeight = *config.merkleRootActivationHeight
}
configBytes, err := json.Marshal(vmConfig, jsonv1.FormatDurationAsNano(true)) configBytes, err := json.Marshal(vmConfig, jsonv1.FormatDurationAsNano(true))
require.NoError(err) require.NoError(err)