diff --git a/upgrade/upgrade.go b/upgrade/upgrade.go index 70a27fb82..582229cd2 100644 --- a/upgrade/upgrade.go +++ b/upgrade/upgrade.go @@ -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, } ) diff --git a/vms/xvm/block/builder/builder.go b/vms/xvm/block/builder/builder.go index 7e6ea4136..c1c60fc3f 100644 --- a/vms/xvm/block/builder/builder.go +++ b/vms/xvm/block/builder/builder.go @@ -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, diff --git a/vms/xvm/block/builder/builder_merkleroot_test.go b/vms/xvm/block/builder/builder_merkleroot_test.go index e6f6aa8ce..db34d5e15 100644 --- a/vms/xvm/block/builder/builder_merkleroot_test.go +++ b/vms/xvm/block/builder/builder_merkleroot_test.go @@ -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 diff --git a/vms/xvm/block/builder/builder_test.go b/vms/xvm/block/builder/builder_test.go index 8f9061786..936fa22ef 100644 --- a/vms/xvm/block/builder/builder_test.go +++ b/vms/xvm/block/builder/builder_test.go @@ -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( diff --git a/vms/xvm/block/executor/block.go b/vms/xvm/block/executor/block.go index fcc443546..0dcef86ef 100644 --- a/vms/xvm/block/executor/block.go +++ b/vms/xvm/block/executor/block.go @@ -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 diff --git a/vms/xvm/block/executor/block_test.go b/vms/xvm/block/executor/block_test.go index 044d0a9ab..f31c71644 100644 --- a/vms/xvm/block/executor/block_test.go +++ b/vms/xvm/block/executor/block_test.go @@ -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{}, } diff --git a/vms/xvm/config.go b/vms/xvm/config.go index bd515d29b..bdd61abb5 100644 --- a/vms/xvm/config.go +++ b/vms/xvm/config.go @@ -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, }, } diff --git a/vms/xvm/config/config.go b/vms/xvm/config/config.go index f20db5c4f..687d647cf 100644 --- a/vms/xvm/config/config.go +++ b/vms/xvm/config/config.go @@ -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 } diff --git a/vms/xvm/vm_merkleroot_test.go b/vms/xvm/vm_merkleroot_test.go index b9cbc6188..00ae7f5d2 100644 --- a/vms/xvm/vm_merkleroot_test.go +++ b/vms/xvm/vm_merkleroot_test.go @@ -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/ diff --git a/vms/xvm/vm_test_helpers_test.go b/vms/xvm/vm_test_helpers_test.go index 0bfc15a4e..bdca35934 100644 --- a/vms/xvm/vm_test_helpers_test.go +++ b/vms/xvm/vm_test_helpers_test.go @@ -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)