mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:06:23 +00:00
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:
+8
-37
@@ -8,23 +8,12 @@
|
||||
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.
|
||||
@@ -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.
|
||||
// - 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"`
|
||||
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
|
||||
XChainStopVertexID ids.ID `json:"xChainStopVertexID"`
|
||||
EpochDuration time.Duration `json:"epochDuration"`
|
||||
}
|
||||
|
||||
// Validate is retained for callers that still invoke it; under activate-all-
|
||||
@@ -63,19 +37,16 @@ func (*Config) Validate() error { return nil }
|
||||
|
||||
var (
|
||||
Mainnet = Config{
|
||||
XChainStopVertexID: ids.FromStringOrPanic("jrGWDh5Po9FMj54depyunNixpia5PN4aAYxfmNzU8n752Rjga"),
|
||||
EpochDuration: 5 * time.Minute,
|
||||
MerkleRootActivationHeight: MerkleRootNeverActivate, // unset until a real upgrade picks a height
|
||||
XChainStopVertexID: ids.FromStringOrPanic("jrGWDh5Po9FMj54depyunNixpia5PN4aAYxfmNzU8n752Rjga"),
|
||||
EpochDuration: 5 * time.Minute,
|
||||
}
|
||||
Testnet = Config{
|
||||
XChainStopVertexID: ids.FromStringOrPanic("2D1cmbiG36BqQMRyHt4kFhWarmatA1ighSpND3FeFgz3vFVtCZ"),
|
||||
EpochDuration: 30 * time.Second,
|
||||
MerkleRootActivationHeight: MerkleRootNeverActivate, // unset until a real upgrade picks a height
|
||||
XChainStopVertexID: ids.FromStringOrPanic("2D1cmbiG36BqQMRyHt4kFhWarmatA1ighSpND3FeFgz3vFVtCZ"),
|
||||
EpochDuration: 30 * time.Second,
|
||||
}
|
||||
Default = Config{
|
||||
XChainStopVertexID: ids.Empty,
|
||||
EpochDuration: 30 * time.Second,
|
||||
MerkleRootActivationHeight: MerkleRootNeverActivate, // unset until a real upgrade picks a height
|
||||
XChainStopVertexID: ids.Empty,
|
||||
EpochDuration: 30 * time.Second,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -166,25 +166,12 @@ func (b *builder) BuildBlock(context.Context) (chain.Block, error) {
|
||||
return nil, ErrNoTransactions
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Stamp the xvm execution_root computed over the post-block state into the
|
||||
// block so the verifier can recompute and check it. The root is the tagged
|
||||
// Merkle-tree fold over the post-block state (utxo ‖ asset ‖ tx ‖ height);
|
||||
// the builder and the executor share exactly one computation
|
||||
// (BlockExecutionRoot) so the stamped root and the verified root can never
|
||||
// disagree.
|
||||
parentRoot := preferred.MerkleRoot()
|
||||
root, err := blockexecutor.BlockExecutionRoot(
|
||||
parentRoot,
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
|
||||
"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"
|
||||
@@ -31,14 +30,14 @@ import (
|
||||
)
|
||||
|
||||
// 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.
|
||||
// mocked manager/state, parent at [parentHeight] with [parentRoot]. It returns
|
||||
// the *StandardBlock the builder handed to NewBlock so the test can inspect the
|
||||
// stamped Root. The builder always stamps the xvm execution_root — there is no
|
||||
// activation gate — so the block's Root is the canonical execution_root over the
|
||||
// post-block state.
|
||||
func buildOneTxBlock(
|
||||
t *testing.T,
|
||||
ctrl *gomock.Controller,
|
||||
cfg *config.Config,
|
||||
parentRoot ids.ID,
|
||||
parentHeight uint64,
|
||||
) *block.StandardBlock {
|
||||
@@ -116,7 +115,7 @@ func buildOneTxBlock(
|
||||
Ctx: context.Background(),
|
||||
Runtime: testRuntime(),
|
||||
Codec: codec,
|
||||
Config: cfg,
|
||||
Config: &config.Config{},
|
||||
Log: log.NewNoOpLogger(),
|
||||
},
|
||||
manager,
|
||||
@@ -130,45 +129,20 @@ func buildOneTxBlock(
|
||||
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) {
|
||||
// TestBuildBlockStampsExecutionRoot is the builder-side always-active proof: the
|
||||
// builder always 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 — there is no empty-root path.
|
||||
func TestBuildBlockStampsExecutionRoot(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)
|
||||
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 +
|
||||
// the block's tx family at the built height (parentHeight + 1), over a
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/luxfi/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
chain "github.com/luxfi/vm/chain"
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/crypto/secp256k1"
|
||||
"github.com/luxfi/database/memdb"
|
||||
@@ -38,6 +37,7 @@ import (
|
||||
"github.com/luxfi/timer/mockable"
|
||||
lux "github.com/luxfi/utxo"
|
||||
"github.com/luxfi/utxo/secp256k1fx"
|
||||
chain "github.com/luxfi/vm/chain"
|
||||
)
|
||||
|
||||
const trackChecksums = false
|
||||
@@ -260,10 +260,16 @@ func TestBuilderBuildBlock(t *testing.T) {
|
||||
preferredBlock := block.NewMockBlock(ctrl)
|
||||
preferredBlock.EXPECT().Height().Return(preferredHeight)
|
||||
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.EXPECT().GetLastAccepted().Return(preferredID)
|
||||
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 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.EXPECT().Preferred().Return(preferredID)
|
||||
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
|
||||
// inputs.Overlaps check, so VerifyUniqueInputs should not be called for tx2
|
||||
// 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.EXPECT().Height().Return(preferredHeight)
|
||||
preferredBlock.EXPECT().Timestamp().Return(preferredTimestamp)
|
||||
preferredBlock.EXPECT().MerkleRoot().Return(ids.GenerateTestID()).AnyTimes()
|
||||
|
||||
// Clock reads just before the preferred timestamp.
|
||||
// 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.EXPECT().GetLastAccepted().Return(preferredID)
|
||||
preferredState.EXPECT().GetTimestamp().Return(preferredTimestamp)
|
||||
preferredState.EXPECT().UTXOs(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
|
||||
|
||||
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().GetState(preferredID).Return(preferredState, true).AnyTimes()
|
||||
manager.EXPECT().VerifyUniqueInputs(preferredID, gomock.Any()).Return(nil)
|
||||
// Assert that the created block has the right timestamp
|
||||
manager.EXPECT().NewBlock(gomock.Any()).DoAndReturn(
|
||||
@@ -433,6 +444,7 @@ func TestBuilderBuildBlock(t *testing.T) {
|
||||
preferredBlock := block.NewMockBlock(ctrl)
|
||||
preferredBlock.EXPECT().Height().Return(preferredHeight)
|
||||
preferredBlock.EXPECT().Timestamp().Return(preferredTimestamp)
|
||||
preferredBlock.EXPECT().MerkleRoot().Return(ids.GenerateTestID()).AnyTimes()
|
||||
|
||||
// Clock reads after the preferred timestamp.
|
||||
// Created block should have [now] timestamp since it's later.
|
||||
@@ -442,11 +454,12 @@ func TestBuilderBuildBlock(t *testing.T) {
|
||||
preferredState := statemock.NewChain(ctrl)
|
||||
preferredState.EXPECT().GetLastAccepted().Return(preferredID)
|
||||
preferredState.EXPECT().GetTimestamp().Return(preferredTimestamp)
|
||||
preferredState.EXPECT().UTXOs(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
|
||||
|
||||
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().GetState(preferredID).Return(preferredState, true).AnyTimes()
|
||||
manager.EXPECT().VerifyUniqueInputs(preferredID, gomock.Any()).Return(nil)
|
||||
// Assert that the created block has the right timestamp
|
||||
manager.EXPECT().NewBlock(gomock.Any()).DoAndReturn(
|
||||
|
||||
@@ -87,19 +87,12 @@ func (b *Block) Verify(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// The block's MerkleRoot carries the xvm execution_root over the post-block
|
||||
// state. It is verified unconditionally once the parent and the block's txs
|
||||
// are available: the executor recomputes the canonical execution_root and
|
||||
// rejects any block whose stamped root disagrees (see the recompute below,
|
||||
// after the txs have been applied to the state diff).
|
||||
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
|
||||
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
|
||||
// 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, err := BlockExecutionRoot(parent.MerkleRoot(), txs, stateDiff, height)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compute expected block execution root: %w", err)
|
||||
}
|
||||
if merkleRoot != expectedRoot {
|
||||
return fmt.Errorf(
|
||||
"%w: block root %s, expected %s",
|
||||
ErrUnexpectedMerkleRoot,
|
||||
merkleRoot,
|
||||
expectedRoot,
|
||||
)
|
||||
}
|
||||
// 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. [height] was already resolved (and validated against the
|
||||
// parent) above.
|
||||
expectedRoot, err := BlockExecutionRoot(parent.MerkleRoot(), txs, stateDiff, height)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compute expected block execution root: %w", err)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/math/set"
|
||||
"github.com/luxfi/mock/gomock"
|
||||
"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"
|
||||
@@ -60,25 +59,6 @@ func TestBlockVerify(t *testing.T) {
|
||||
},
|
||||
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",
|
||||
blockFunc: func(ctrl *gomock.Controller) *Block {
|
||||
@@ -524,24 +504,44 @@ func TestBlockVerify(t *testing.T) {
|
||||
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",
|
||||
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)
|
||||
mockBlock.EXPECT().Height().Return(blockHeight).AnyTimes()
|
||||
parentRoot := ids.GenerateTestID()
|
||||
|
||||
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) // 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().InputIDs().AnyTimes()
|
||||
tx := &txs.Tx{
|
||||
Unsigned: mockUnsignedTx,
|
||||
}
|
||||
mockUnsignedTx.EXPECT().SetBytes(gomock.Any()).AnyTimes()
|
||||
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()
|
||||
|
||||
parentID := ids.GenerateTestID()
|
||||
@@ -549,10 +549,16 @@ func TestBlockVerify(t *testing.T) {
|
||||
|
||||
mockParentBlock := block.NewMockBlock(ctrl)
|
||||
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.EXPECT().GetLastAccepted().Return(parentID)
|
||||
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())
|
||||
require.NoError(t, err)
|
||||
@@ -976,10 +982,6 @@ 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{},
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ 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"
|
||||
)
|
||||
@@ -18,12 +17,6 @@ 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,30 +13,4 @@ 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
|
||||
}
|
||||
|
||||
@@ -20,12 +20,13 @@ import (
|
||||
txexecutor "github.com/luxfi/node/vms/xvm/txs/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) {
|
||||
// TestMerkleRootStampedAndVerified is the always-active end-to-end proof: a VM
|
||||
// with the default config builds a real block that carries the xvm
|
||||
// execution_root over the post-block state (never empty), that block verifies
|
||||
// and accepts, and a sibling block whose root is tampered is rejected with
|
||||
// ErrUnexpectedMerkleRoot. There is no activation gate — the execution_root is
|
||||
// part of the current version, stamped and verified unconditionally.
|
||||
func TestMerkleRootStampedAndVerified(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
env := setup(t, &envConfig{fork: upgrade.Default})
|
||||
@@ -37,33 +38,6 @@ func TestMerkleRootGateOffDefaultBlockEmpty(t *testing.T) {
|
||||
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 + post-block state. The
|
||||
// CreateAssetTx in this block produces UTXOs, so the projected UTXO family is
|
||||
@@ -71,7 +45,7 @@ func TestMerkleRootGateOnAcceptsAndRejects(t *testing.T) {
|
||||
// (empty-family) value.
|
||||
built := blkIntf.(*blkexecutor.Block)
|
||||
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()
|
||||
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.
|
||||
postUTXOs, err := postState.UTXOs(ids.Empty, 0)
|
||||
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
|
||||
// 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()))
|
||||
}
|
||||
|
||||
// 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
|
||||
// projected over: a state diff on [parentID] with [blkTxs] executed in order.
|
||||
// It mirrors the state mutation block verification performs (vms/xvm/block/
|
||||
|
||||
@@ -54,10 +54,6 @@ 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
|
||||
@@ -274,9 +270,6 @@ 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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user