diff --git a/consensus/validator.go b/consensus/validator.go index 9a4b201c2..a70f61c4e 100644 --- a/consensus/validator.go +++ b/consensus/validator.go @@ -276,16 +276,16 @@ func (c *Consensus) verifyBlock(blk *block.Block, state *state.State, blockConfl return chain.HasTransaction(txid, txBlockRef) } - for _, tx := range txs { + for _, trx := range txs { // check if tx existed - if found, err := hasTx(tx.ID(), tx.BlockRef().Number()); err != nil { + if found, err := hasTx(trx.ID(), trx.BlockRef().Number()); err != nil { return nil, nil, err } else if found { return nil, nil, consensusError("tx already exists") } // check depended tx - if dep := tx.DependsOn(); dep != nil { + if dep := trx.DependsOn(); dep != nil { found, reverted, err := findDep(*dep) if err != nil { return nil, nil, err @@ -299,7 +299,22 @@ func (c *Consensus) verifyBlock(blk *block.Block, state *state.State, blockConfl } } - receipt, err := rt.ExecuteTransaction(tx) + // Eth tx requires linear nonce growth (mismatch is a hard consensus error). + if trx.Type() == tx.TypeEthDynamicFee { + origin, err := trx.Origin() + if err != nil { + return nil, nil, consensusError(fmt.Sprintf("tx signer unavailable: %v", err)) + } + accNonce, err := state.GetNonce(origin) + if err != nil { + return nil, nil, err + } + if trx.Nonce() != accNonce { + return nil, nil, consensusError(fmt.Sprintf("tx nonce mismatch: want %v, have %v", accNonce, trx.Nonce())) + } + } + + receipt, err := rt.ExecuteTransaction(trx) if err != nil { return nil, nil, err } @@ -310,7 +325,7 @@ func (c *Consensus) verifyBlock(blk *block.Block, state *state.State, blockConfl } receipts = append(receipts, receipt) - processedTxs[tx.ID()] = receipt.Reverted + processedTxs[trx.ID()] = receipt.Reverted } if header.GasUsed() != totalGasUsed { diff --git a/go.mod b/go.mod index c3476d231..c8cd15423 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.4.1 github.com/hashicorp/golang-lru v0.0.0-20160813221303-0a025b7e63ad - github.com/holiman/uint256 v1.2.4 + github.com/holiman/uint256 v1.3.2 github.com/huin/goupnp v0.0.0-20171109214107-dceda08e705b github.com/jackpal/go-nat-pmp v1.0.2-0.20160603034137-1fa385a6f458 github.com/mattn/go-isatty v0.0.3 @@ -69,4 +69,4 @@ require ( replace github.com/syndtr/goleveldb => github.com/vechain/goleveldb v1.0.1-0.20220809091043-51eb019c8655 -replace github.com/ethereum/go-ethereum => github.com/vechain/go-ethereum v1.8.15-0.20260324060835-4fc778eca93e +replace github.com/ethereum/go-ethereum => github.com/vechain/go-ethereum v1.8.15-0.20260511103518-c6cd268fa5ce diff --git a/go.sum b/go.sum index 2ee0ae10a..0c191d9d3 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= -github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= -github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/cespare/cp v1.1.1 h1:nCb6ZLdB7NRaqsm91JtQTAme2SKJzXVsdPIPkyJr1MU= github.com/cespare/cp v1.1.1/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -88,8 +86,8 @@ github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvK github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru v0.0.0-20160813221303-0a025b7e63ad h1:eMxs9EL0PvIGS9TTtxg4R+JxuPGav82J8rA+GFnY7po= github.com/hashicorp/golang-lru v0.0.0-20160813221303-0a025b7e63ad/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/holiman/uint256 v1.2.4 h1:jUc4Nk8fm9jZabQuqr2JzednajVmBpC+oiTiXZJEApU= -github.com/holiman/uint256 v1.2.4/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huin/goupnp v0.0.0-20171109214107-dceda08e705b h1:mvnS3LbcRgdM4nBLksEjecaLvASuBsg1mIJHc0l22iI= github.com/huin/goupnp v0.0.0-20171109214107-dceda08e705b/go.mod h1:MZ2ZmwcBpvOoJ22IJsc7va19ZwoheaBk43rKg12SKag= @@ -168,8 +166,8 @@ github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo= github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/vechain/go-ecvrf v0.0.0-20251211112124-5d5a3ef70fc9 h1:g6xRR8HD50ABVYhLhDv1MfEfDSWlDVV+GD/IKvTOQG8= github.com/vechain/go-ecvrf v0.0.0-20251211112124-5d5a3ef70fc9/go.mod h1:Yoa6emaGryEaOlrvv6Eg6iX7vM7cqZAf0i9D1SCobnY= -github.com/vechain/go-ethereum v1.8.15-0.20260324060835-4fc778eca93e h1:0/g3bVEx1fFoYD6He8t7DgVNkgPNlZZl7h1WdvCd5K0= -github.com/vechain/go-ethereum v1.8.15-0.20260324060835-4fc778eca93e/go.mod h1:LVuf3xPnVtHmoIP5+mN7aPnIeRBgo0xVq/wVELtSeIA= +github.com/vechain/go-ethereum v1.8.15-0.20260511103518-c6cd268fa5ce h1:5N1rxNvAN8q2HXStzz41dGXTDL3MgLukeRH2MwB3YNI= +github.com/vechain/go-ethereum v1.8.15-0.20260511103518-c6cd268fa5ce/go.mod h1:WwsMAst71TTecDPsaZ/nj/MnA7uy+OEUjYEdZFD0gjA= github.com/vechain/goleveldb v1.0.1-0.20220809091043-51eb019c8655 h1:CbHcWpCi7wOYfpoErRABh3Slyq9vO0Ay/EHN5GuJSXQ= github.com/vechain/goleveldb v1.0.1-0.20220809091043-51eb019c8655/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/packer/flow.go b/packer/flow.go index 740c58b72..209235e6e 100644 --- a/packer/flow.go +++ b/packer/flow.go @@ -196,6 +196,20 @@ func (f *Flow) Adopt(t *tx.Transaction) error { } } + // Eth tx requires linear nonce growth. + if t.Type() == tx.TypeEthDynamicFee { + accNonce, err := f.runtime.State().GetNonce(origin) + if err != nil { + return err + } + if t.Nonce() < accNonce { + return errTxNotAdoptableForever + } + if t.Nonce() > accNonce { + return errTxNotAdoptableNow + } + } + // check if tx already there if found, err := f.hasTx(t.ID(), t.BlockRef().Number()); err != nil { return err diff --git a/packer/flow_test.go b/packer/flow_test.go index 5710aac6c..14351ede9 100644 --- a/packer/flow_test.go +++ b/packer/flow_test.go @@ -264,6 +264,81 @@ func TestPackAfterGalacticaFork(t *testing.T) { } } +// TestEthDynFee_AdoptRejectedPreInterstellar verifies packer rejects 0x02 +// txs before INTERSTELLAR activation (V2 statedb / 64-bit chainid not yet on). +func TestEthDynFee_AdoptRejectedPreInterstellar(t *testing.T) { + tc, err := testchain.NewWithFork(&thor.ForkConfig{GALACTICA: 0, HAYABUSA: math.MaxUint32, INTERSTELLAR: math.MaxUint32}, 180) + require.NoError(t, err) + + validator, ok := tc.NextValidator() + require.True(t, ok) + + pkr := packer.New(tc.Repo(), tc.Stater(), validator.Address, nil, tc.GetForkConfig(), 0) + best := tc.Repo().BestBlockSummary() + flow, err := pkr.Schedule(best, best.Header.Timestamp()+thor.BlockInterval()) + require.NoError(t, err) + + to := thor.BytesToAddress([]byte("to")) + trx := tx.NewBuilder(tx.TypeEthDynamicFee). + Gas(21000). + MaxFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxPriorityFeePerGas(big.NewInt(0)). + ChainID(tc.Repo().ChainID()). + Nonce(0). + To(&to).Value(big.NewInt(1)). + Build() + trx = tx.MustSign(trx, genesis.DevAccounts()[1].PrivateKey) + + err = flow.Adopt(trx) + assert.Error(t, err) + assert.Equal(t, "bad tx: invalid tx type", err.Error()) +} + +// TestEthDynFee_AdoptNonceLinearGrowth verifies that packer rejects eth txs +// whose nonce doesn't match the on-state nonce: future nonce → not adoptable +// now (queued semantics); equal nonce → passes the nonce gate. +func TestEthDynFee_AdoptNonceLinearGrowth(t *testing.T) { + tc, err := testchain.NewWithFork(&thor.ForkConfig{GALACTICA: 0, HAYABUSA: math.MaxUint32, INTERSTELLAR: 0}, 180) + require.NoError(t, err) + + validator, ok := tc.NextValidator() + require.True(t, ok) + + pkr := packer.New(tc.Repo(), tc.Stater(), validator.Address, nil, tc.GetForkConfig(), 0) + best := tc.Repo().BestBlockSummary() + flow, err := pkr.Schedule(best, best.Header.Timestamp()+thor.BlockInterval()) + require.NoError(t, err) + + to := thor.BytesToAddress([]byte("to")) + + // nonce > state.nonce → not adoptable now. + futureTx := tx.NewBuilder(tx.TypeEthDynamicFee). + Gas(21000). + MaxFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxPriorityFeePerGas(big.NewInt(0)). + ChainID(tc.Repo().ChainID()). + Nonce(99). + To(&to).Value(big.NewInt(1)). + Build() + futureTx = tx.MustSign(futureTx, genesis.DevAccounts()[1].PrivateKey) + err = flow.Adopt(futureTx) + assert.Error(t, err) + assert.Equal(t, "tx not adoptable now", err.Error()) + + // nonce == state.nonce → passes nonce gate. + okTx := tx.NewBuilder(tx.TypeEthDynamicFee). + Gas(21000). + MaxFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxPriorityFeePerGas(big.NewInt(0)). + ChainID(tc.Repo().ChainID()). + Nonce(0). + To(&to).Value(big.NewInt(1)). + Build() + okTx = tx.MustSign(okTx, genesis.DevAccounts()[2].PrivateKey) + err = flow.Adopt(okTx) + assert.Nil(t, err, "eth tx with matching nonce must adopt") +} + func TestAdoptErr(t *testing.T) { db := muxdb.NewMem() stater := state.NewStater(db) diff --git a/runtime/resolved_tx.go b/runtime/resolved_tx.go index fe581232e..97cdcbdab 100644 --- a/runtime/resolved_tx.go +++ b/runtime/resolved_tx.go @@ -252,6 +252,7 @@ func (r *ResolvedTransaction) ToContext( return nil, err } return &xenv.TransactionContext{ + Type: r.tx.Type(), ID: r.tx.ID(), Origin: r.Origin, GasPayer: gasPayer, @@ -260,6 +261,5 @@ func (r *ResolvedTransaction) ToContext( BlockRef: r.tx.BlockRef(), Expiration: r.tx.Expiration(), ClauseCount: uint32(len(r.Clauses)), - Type: r.tx.Type(), }, nil } diff --git a/runtime/runtime.go b/runtime/runtime.go index c5f5a8824..3c526d89e 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -233,16 +233,10 @@ func (rt *Runtime) newEVM(stateDB *statedb.StateDB, clauseIndex uint32, txCtx *x return common.Hash(id) }, NewContractAddress: func(_ *vm.EVM, caller common.Address, counter uint32) common.Address { - switch txCtx.Type { - case tx.TypeEthDynamicFee: - // Ethereum formula: keccak256(rlp([caller, nonce])). counter is unused here — - // nonces play the equivalent role for Ethereum txs. With nonce tracking stubbed, - // stateDB.GetNonce always returns 0; sequential creates from the same caller - // will collide on the second call until real nonce tracking is implemented. + if txCtx.Type == tx.TypeEthDynamicFee { return crypto.CreateAddress(caller, stateDB.GetNonce(caller)) - default: - return common.Address(thor.CreateContractAddress(txCtx.ID, clauseIndex, counter)) } + return common.Address(thor.CreateContractAddress(txCtx.ID, clauseIndex, counter)) }, InterceptContractCall: func(evm *vm.EVM, contract *vm.Contract, readonly bool) ([]byte, error, bool) { if evm.Depth() < 2 { @@ -390,7 +384,7 @@ func (rt *Runtime) PrepareClause( txCtx *xenv.TransactionContext, ) (exec func() (output *Output, interrupted bool, err error), interrupt func()) { var ( - stateDB = statedb.New(rt.state, txCtx.Type) + stateDB = statedb.New(rt.state, txCtx.Type == tx.TypeEthDynamicFee) evm = rt.newEVM(stateDB, clauseIndex, txCtx) data []byte leftOverGas uint64 @@ -419,6 +413,14 @@ func (rt *Runtime) PrepareClause( data, caddr, leftOverGas, vmErr = evm.Create(vm.AccountRef(txCtx.Origin), clause.Data(), gas, clause.Value()) contractAddr = (*thor.Address)(&caddr) } else { + if txCtx.Type == tx.TypeEthDynamicFee { + nonce := stateDB.GetNonce(common.Address(txCtx.Origin)) + if nonce+1 < nonce { + return nil, false, errors.New("nonce has max value") + } + + stateDB.SetNonce(common.Address(txCtx.Origin), nonce+1) + } data, leftOverGas, vmErr = evm.Call(vm.AccountRef(txCtx.Origin), common.Address(*clause.To()), clause.Data(), gas, clause.Value()) } @@ -533,9 +535,12 @@ func (rt *Runtime) PrepareTransaction(trx *tx.Transaction) (*TransactionExecutor leftOverGas += refund if output.VMErr != nil { - // vm exception here - // revert all executed clauses - rt.state.RevertTo(checkpoint) + if txCtx.Type != tx.TypeEthDynamicFee { + // multi-clause: undo prior clauses' state. + // eth tx needs to preserve the nonce increment and EVM's internal + // RevertToSnapshot already cleaned the failed call. + rt.state.RevertTo(checkpoint) + } reverted = true txOutputs = nil return @@ -568,25 +573,6 @@ func (rt *Runtime) PrepareTransaction(trx *tx.Transaction) (*TransactionExecutor return nil, err } - // EIP-2: nonce is always consumed for EthereumTx, even if the tx reverts. - // For CALL txs the EVM never touches the nonce, so we always increment here. - // For CREATE txs that succeeded the EVM already incremented the nonce via - // stateDB.SetNonce before the inner snapshot; that survived the tx. - // For CREATE txs that reverted, rt.state.RevertTo(checkpoint) undid the EVM's - // increment, so we must re-apply it here. - if trx.Type() == tx.TypeEthDynamicFee { - isCreate := resolvedTx.Clauses[0].IsCreatingContract() - if !isCreate || reverted { - nonce, err := rt.state.GetNonce(txCtx.Origin) - if err != nil { - return nil, err - } - if err := rt.state.SetNonce(txCtx.Origin, nonce+1); err != nil { - return nil, err - } - } - } - if !thor.IsForked(rt.ctx.Number, rt.forkConfig.GALACTICA) { provedWork, err := trx.ProvedWork(rt.ctx.Number-1, rt.chain.GetBlockID) if err != nil { diff --git a/runtime/runtime_eth_dynamic_fee_test.go b/runtime/runtime_eth_dynamic_fee_test.go new file mode 100644 index 000000000..1b71d4777 --- /dev/null +++ b/runtime/runtime_eth_dynamic_fee_test.go @@ -0,0 +1,628 @@ +// Copyright (c) 2026 The VeChainThor developers + +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package runtime_test + +import ( + "math" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + gomath "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/block" + "github.com/vechain/thor/v2/builtin" + "github.com/vechain/thor/v2/chain" + "github.com/vechain/thor/v2/genesis" + "github.com/vechain/thor/v2/muxdb" + "github.com/vechain/thor/v2/runtime" + "github.com/vechain/thor/v2/state" + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/trie" + "github.com/vechain/thor/v2/tx" + "github.com/vechain/thor/v2/vm" + "github.com/vechain/thor/v2/xenv" +) + +// setupEthTxRuntime builds a devnet with GALACTICA active at block 1 and +// returns (repo at b1, fresh state at b0, baseFee at b1, b1 timestamp). +// The state isn't advanced past b0 because b1 has no txs; ctx.BaseFee / +// ctx.Number / ctx.Time supplied at runtime time are sufficient to land +// in the post-galactica branch of runtime.go. +func setupEthTxRuntime(t *testing.T) (*chain.Repository, *state.State, *big.Int, uint64) { + t.Helper() + db := muxdb.NewMem() + + fc := &thor.SoloFork + hayabusaTP := uint32(math.MaxUint32) + thor.SetConfig(thor.Config{HayabusaTP: &hayabusaTP}) + fc.HAYABUSA = math.MaxUint32 + fc.GALACTICA = 1 + + g := genesis.NewDevnetWithConfig(genesis.DevConfig{ForkConfig: fc}) + b0, _, _, err := g.Build(state.NewStater(db)) + assert.Nil(t, err) + repo, _ := chain.NewRepository(db, b0) + + st := state.New(db, trie.Root{Hash: b0.Header().StateRoot()}) + ver := trie.Version{Major: b0.Header().Number() + 1, Minor: 0} + stg, err := st.Stage(ver) + assert.Nil(t, err) + root, err := stg.Commit() + assert.Nil(t, err) + + baseFee := big.NewInt(thor.InitialBaseFee) + b1 := new(block.Builder). + ParentID(b0.Header().ID()). + Timestamp(b0.Header().Timestamp() + thor.BlockInterval()). + GasLimit(b0.Header().GasLimit()). + BaseFee(baseFee). + StateRoot(root). + Build() + repo.AddBlock(b1, nil, 0, true) + + st = state.New(db, trie.Root{Hash: b0.Header().StateRoot()}) + return repo, st, baseFee, b1.Header().Timestamp() +} + +func TestEthDynFee_PlainTransfer(t *testing.T) { + repo, st, baseFee, blockTime := setupEthTxRuntime(t) + + origin := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + beneficiary := thor.BytesToAddress([]byte("proposer")) + + value := big.NewInt(1000) + maxFee := new(big.Int).Mul(baseFee, big.NewInt(2)) + maxPriority := new(big.Int).Set(baseFee) + gas := uint64(21000) + + addr := recipient.Address + trx := tx.NewBuilder(tx.TypeEthDynamicFee). + Gas(gas). + MaxFeePerGas(maxFee). + MaxPriorityFeePerGas(maxPriority). + ChainID(0). + Nonce(1). + To(&addr).Value(value). + Build() + trx = tx.MustSign(trx, origin.PrivateKey) + + prevOriginEnergy, err := builtin.Energy.Native(st, blockTime).Get(origin.Address) + assert.Nil(t, err) + prevBeneficiaryEnergy, err := builtin.Energy.Native(st, blockTime).Get(beneficiary) + assert.Nil(t, err) + + rt := runtime.New( + repo.NewChain(repo.BestBlockSummary().Header.ID()), + st, + &xenv.BlockContext{ + Time: blockTime, + Number: repo.BestBlockSummary().Header.Number() + 1, + GasLimit: repo.BestBlockSummary().Header.GasLimit(), + BaseFee: baseFee, + Beneficiary: beneficiary, + }, + &thor.SoloFork, + ) + + receipt, err := rt.ExecuteTransaction(trx) + assert.Nil(t, err) + assert.False(t, receipt.Reverted, "plain transfer must not revert") + + // Intrinsic gas: pure transfer with empty data → 21000. + assert.Equal(t, uint64(21000), receipt.GasUsed) + + // Tip routing: priorityFee × gasUsed → beneficiary. + currBeneficiaryEnergy, err := builtin.Energy.Native(st, blockTime).Get(beneficiary) + assert.Nil(t, err) + tipDelta := new(big.Int).Sub(currBeneficiaryEnergy, prevBeneficiaryEnergy) + expectedTip := new(big.Int).Mul(maxPriority, big.NewInt(int64(receipt.GasUsed))) + assert.Equal(t, expectedTip, tipDelta, "tip = maxPriority × gasUsed") + + // Origin paid: gasUsed × effectiveGasPrice. + currOriginEnergy, err := builtin.Energy.Native(st, blockTime).Get(origin.Address) + assert.Nil(t, err) + originDelta := new(big.Int).Sub(prevOriginEnergy, currOriginEnergy) + expectedPaid := new(big.Int).Mul(maxFee, big.NewInt(int64(receipt.GasUsed))) + assert.Equal(t, expectedPaid, originDelta, "origin paid = effectiveGasPrice × gasUsed") + assert.Equal(t, expectedPaid, receipt.Paid) + assert.Equal(t, origin.Address, receipt.GasPayer) +} + +func TestEthDynFee_ContractCreation(t *testing.T) { + repo, st, baseFee, blockTime := setupEthTxRuntime(t) + + origin := genesis.DevAccounts()[0] + beneficiary := thor.BytesToAddress([]byte("proposer")) + + // Minimal valid creation bytecode: STOP. Empty deployed code, dataGas = 4 (one zero byte). + code := []byte{0x00} + + maxFee := new(big.Int).Mul(baseFee, big.NewInt(2)) + maxPriority := new(big.Int).Set(baseFee) + + trx := tx.NewBuilder(tx.TypeEthDynamicFee). + Gas(100000). + MaxFeePerGas(maxFee). + MaxPriorityFeePerGas(maxPriority). + ChainID(0). + Nonce(2). + Data(code). // nil To → contract creation + Build() + trx = tx.MustSign(trx, origin.PrivateKey) + + rt := runtime.New( + repo.NewChain(repo.BestBlockSummary().Header.ID()), + st, + &xenv.BlockContext{ + Time: blockTime, + Number: repo.BestBlockSummary().Header.Number() + 1, + GasLimit: repo.BestBlockSummary().Header.GasLimit(), + BaseFee: baseFee, + Beneficiary: beneficiary, + }, + &thor.SoloFork, + ) + + receipt, err := rt.ExecuteTransaction(trx) + assert.Nil(t, err) + assert.False(t, receipt.Reverted, "creation must not revert") + + // Intrinsic gas floor: TxGas + ClauseGasContractCreation + dataGas(0x00) + // = 5000 + 48000 + 4 = 53004. gasUsed >= floor. + assert.GreaterOrEqual(t, receipt.GasUsed, uint64(53004)) + + // Eth tx uses Ethereum's nonce-based rule: CreateAddress(origin, nonce-before-increment). + // On-state nonce starts at 0 for the genesis account. + assert.Len(t, receipt.Outputs, 1) + expectedAddr := thor.Address(crypto.CreateAddress(common.Address(origin.Address), 0)) + exists, existsErr := st.Exists(expectedAddr) + assert.Nil(t, existsErr) + assert.True(t, exists, "contract account must exist at eth-derived address") +} + +// TestEthDynFee_RevertPreservesNonce guards eth tx revert semantics: when a +// clause hits VMErr, the receipt is marked reverted but the sender nonce +// increment must persist (matches Ethereum: failed txs still consume nonce). +func TestEthDynFee_RevertPreservesNonce(t *testing.T) { + repo, st, baseFee, blockTime := setupEthTxRuntime(t) + + origin := genesis.DevAccounts()[0] + beneficiary := thor.BytesToAddress([]byte("proposer")) + + // Init code = INVALID opcode → ErrInvalidOpCode → VMErr. + // CREATE path: evm.create increments nonce before its snapshot, so the + // increment is preserved across EVM-internal RevertToSnapshot. + maxFee := new(big.Int).Mul(baseFee, big.NewInt(2)) + maxPriority := new(big.Int).Set(baseFee) + + trx := tx.NewBuilder(tx.TypeEthDynamicFee). + Gas(100000). + MaxFeePerGas(maxFee). + MaxPriorityFeePerGas(maxPriority). + ChainID(0). + Nonce(0). + Data([]byte{0xfe}). + Build() + trx = tx.MustSign(trx, origin.PrivateKey) + + prevNonce, err := st.GetNonce(origin.Address) + assert.Nil(t, err) + assert.Equal(t, uint64(0), prevNonce) + + rt := runtime.New( + repo.NewChain(repo.BestBlockSummary().Header.ID()), + st, + &xenv.BlockContext{ + Time: blockTime, + Number: repo.BestBlockSummary().Header.Number() + 1, + GasLimit: repo.BestBlockSummary().Header.GasLimit(), + BaseFee: baseFee, + Beneficiary: beneficiary, + }, + &thor.SoloFork, + ) + + receipt, err := rt.ExecuteTransaction(trx) + assert.Nil(t, err, "ExecuteTransaction should not error on clause-level revert") + assert.True(t, receipt.Reverted, "eth tx must be marked reverted on VMErr") + assert.Nil(t, receipt.Outputs, "outputs must be cleared on revert") + + // Nonce persists post-revert (Ethereum semantics). + currNonce, err := st.GetNonce(origin.Address) + assert.Nil(t, err) + assert.Equal(t, uint64(1), currNonce, "sender nonce must increment even on reverted eth tx") + + // Contract account must NOT exist at the eth-derived address. + contractAddr := thor.Address(crypto.CreateAddress(common.Address(origin.Address), 0)) + exists, err := st.Exists(contractAddr) + assert.Nil(t, err) + assert.False(t, exists, "failed init must not leave a contract account") +} + +func TestEthDynFee_SponsoredCall(t *testing.T) { + repo, st, baseFee, blockTime := setupEthTxRuntime(t) + + origin := genesis.DevAccounts()[0] + sponsor := genesis.DevAccounts()[2] + beneficiary := thor.BytesToAddress([]byte("proposer")) + + // Set up Prototype contract as a sponsored target where origin is a user + // and sponsor is the selected sponsor. + target := builtin.Prototype.Address + bind := builtin.Prototype.Native(st).Bind(target) + err := bind.SetCreditPlan(gomath.MaxBig256, big.NewInt(1000)) + assert.Nil(t, err) + err = bind.AddUser(origin.Address, blockTime) + assert.Nil(t, err) + err = bind.Sponsor(sponsor.Address, true) + assert.Nil(t, err) + bind.SelectSponsor(sponsor.Address) + + // Fund sponsor with enough energy to cover gas. + builtin.Energy.Native(st, blockTime).Add(sponsor.Address, gomath.MaxBig256) + + prevSponsorEnergy, err := builtin.Energy.Native(st, blockTime).Get(sponsor.Address) + assert.Nil(t, err) + prevOriginEnergy, err := builtin.Energy.Native(st, blockTime).Get(origin.Address) + assert.Nil(t, err) + + maxFee := new(big.Int).Mul(baseFee, big.NewInt(2)) + maxPriority := new(big.Int).Set(baseFee) + + // Encode a no-op-ish call: Prototype.master(target). Read-only, gas-cheap. + method, found := builtin.Prototype.ABI.MethodByName("master") + assert.True(t, found) + callData, err := method.EncodeInput(target) + assert.Nil(t, err) + + trx := tx.NewBuilder(tx.TypeEthDynamicFee). + Gas(100000). + MaxFeePerGas(maxFee). + MaxPriorityFeePerGas(maxPriority). + ChainID(0). + Nonce(3). + To(&target).Data(callData). + Build() + trx = tx.MustSign(trx, origin.PrivateKey) + + rt := runtime.New( + repo.NewChain(repo.BestBlockSummary().Header.ID()), + st, + &xenv.BlockContext{ + Time: blockTime, + Number: repo.BestBlockSummary().Header.Number() + 1, + GasLimit: repo.BestBlockSummary().Header.GasLimit(), + BaseFee: baseFee, + Beneficiary: beneficiary, + }, + &thor.SoloFork, + ) + + receipt, err := rt.ExecuteTransaction(trx) + assert.Nil(t, err) + assert.False(t, receipt.Reverted) + + // Sponsor pays. + assert.Equal(t, sponsor.Address, receipt.GasPayer) + currSponsorEnergy, err := builtin.Energy.Native(st, blockTime).Get(sponsor.Address) + assert.Nil(t, err) + assert.True(t, currSponsorEnergy.Cmp(prevSponsorEnergy) < 0, "sponsor energy decreased") + + // Origin DOES NOT pay. + currOriginEnergy, err := builtin.Energy.Native(st, blockTime).Get(origin.Address) + assert.Nil(t, err) + assert.Equal(t, prevOriginEnergy, currOriginEnergy, "origin energy unchanged") +} + +func TestEthDynFee_BaseFeeFloor(t *testing.T) { + _, st, baseFee, _ := setupEthTxRuntime(t) + + origin := genesis.DevAccounts()[0] + addr := genesis.DevAccounts()[1].Address + + // maxFee BELOW baseFee. + maxFee := new(big.Int).Sub(baseFee, big.NewInt(1)) + + trx := tx.NewBuilder(tx.TypeEthDynamicFee). + Gas(21000). + MaxFeePerGas(maxFee). + MaxPriorityFeePerGas(big.NewInt(0)). + ChainID(0). + Nonce(4). + To(&addr).Value(big.NewInt(1)). + Build() + trx = tx.MustSign(trx, origin.PrivateKey) + + resolved, err := runtime.ResolveTransaction(trx) + assert.Nil(t, err, "resolution itself should pass — baseFee is checked at BuyGas, not resolution") + + _, _, _, _, _, err = resolved.BuyGas(st, 0, baseFee) + assert.ErrorContains(t, err, "gas price is less than block base fee") +} + +func TestEthDynFee_InsufficientBalance(t *testing.T) { + _, st, baseFee, blockTime := setupEthTxRuntime(t) + + // Fresh key with zero energy — devnet DevAccounts have huge balances, so we need a new account. + pk, err := crypto.GenerateKey() + assert.Nil(t, err) + pauper := thor.Address(crypto.PubkeyToAddress(pk.PublicKey)) + + // Sanity: pauper has zero energy. + pauperEnergy, err := builtin.Energy.Native(st, blockTime).Get(pauper) + assert.Nil(t, err) + assert.Equal(t, 0, pauperEnergy.Sign()) + + addr := genesis.DevAccounts()[1].Address + trx := tx.NewBuilder(tx.TypeEthDynamicFee). + Gas(21000). + MaxFeePerGas(new(big.Int).Mul(baseFee, big.NewInt(2))). + MaxPriorityFeePerGas(new(big.Int).Set(baseFee)). + ChainID(0). + Nonce(5). + To(&addr).Value(big.NewInt(1)). + Build() + trx = tx.MustSign(trx, pk) + + resolved, err := runtime.ResolveTransaction(trx) + assert.Nil(t, err) + + _, _, _, _, _, err = resolved.BuyGas(st, blockTime, baseFee) + assert.ErrorContains(t, err, "insufficient energy") +} + +// TestEthDynFee_GasCapEIP7825 verifies that the EIP-7825 per-tx gas cap +// (runtime/runtime.go: trx.Gas() > thor.MaxTxGasLimit) is enforced for eth +// tx the same as for VeChain-native tx. +func TestEthDynFee_GasCapEIP7825(t *testing.T) { + repo, st, baseFee, blockTime := setupEthTxRuntime(t) + + origin := genesis.DevAccounts()[0] + beneficiary := thor.BytesToAddress([]byte("proposer")) + recipient := genesis.DevAccounts()[1].Address + + maxFee := new(big.Int).Mul(baseFee, big.NewInt(2)) + maxPriority := new(big.Int).Set(baseFee) + + trx := tx.NewBuilder(tx.TypeEthDynamicFee). + Gas(thor.MaxTxGasLimit + 1). + MaxFeePerGas(maxFee). + MaxPriorityFeePerGas(maxPriority). + ChainID(0). + Nonce(0). + To(&recipient).Value(big.NewInt(1)). + Build() + trx = tx.MustSign(trx, origin.PrivateKey) + + rt := runtime.New( + repo.NewChain(repo.BestBlockSummary().Header.ID()), + st, + &xenv.BlockContext{ + Time: blockTime, + Number: repo.BestBlockSummary().Header.Number() + 1, + GasLimit: thor.MaxTxGasLimit + 100, + BaseFee: baseFee, + Beneficiary: beneficiary, + }, + &thor.SoloFork, + ) + + _, err := rt.ExecuteTransaction(trx) + assert.ErrorContains(t, err, "tx gas limit exceeds the maximum allowed") +} + +// TestEthDynFee_TransientStorageIsolation verifies that EIP-1153 transient +// storage is cleared between separate eth tx executions. Two eth txs each +// call a contract that TSTORE+TLOAD the same key; both must return the +// stored value (1153) — neither sees stale state from the other. A third +// eth tx that only TLOAD without prior TSTORE must read 0, proving the +// stackedmap-backed transient storage is a per-call/per-tx fresh slate. +func TestEthDynFee_TransientStorageIsolation(t *testing.T) { + repo, st, baseFee, blockTime := setupEthTxRuntime(t) + + origin := genesis.DevAccounts()[0] + beneficiary := thor.BytesToAddress([]byte("proposer")) + + // tstore(1, 1153); tload(1); mstore(0x80,_); return(0x80, 0x20) + codeStoreLoad := []byte{ + byte(vm.PUSH2), 0x04, 0x81, + byte(vm.PUSH1), 0x1, byte(vm.TSTORE), + byte(vm.PUSH1), 0x1, byte(vm.TLOAD), + byte(vm.PUSH1), 0x80, byte(vm.MSTORE), + byte(vm.PUSH1), 0x20, byte(vm.PUSH1), 0x80, byte(vm.RETURN), + } + // tload(1); mstore(0x80,_); return(0x80, 0x20) + codeLoadOnly := []byte{ + byte(vm.PUSH1), 0x1, byte(vm.TLOAD), + byte(vm.PUSH1), 0x80, byte(vm.MSTORE), + byte(vm.PUSH1), 0x20, byte(vm.PUSH1), 0x80, byte(vm.RETURN), + } + + target := thor.BytesToAddress([]byte("transient-target")) + + // PrepareClause is called per (clause × tx). Each call constructs a fresh + // stackedmap-backed statedb, so transient storage is naturally isolated. + // We exercise the eth-tx code path explicitly via Type=TypeEthDynamicFee. + mkExec := func(code []byte, txID thor.Bytes32) (*runtime.Output, error) { + require := assert.New(t) + require.NoError(st.SetCode(target, code)) + exec, _ := runtime.New( + repo.NewChain(repo.BestBlockSummary().Header.ID()), + st, + &xenv.BlockContext{ + Time: blockTime, + Number: repo.BestBlockSummary().Header.Number() + 1, + GasLimit: repo.BestBlockSummary().Header.GasLimit(), + BaseFee: baseFee, + Beneficiary: beneficiary, + }, + &thor.SoloFork, + ).PrepareClause( + tx.NewClause(&target), + 0, + gomath.MaxBig256.Uint64(), + &xenv.TransactionContext{ID: txID, Origin: origin.Address, Type: tx.TypeEthDynamicFee}, + ) + out, _, err := exec() + return out, err + } + + // tx 1 — TSTORE then TLOAD same key: returns 1153 from the in-flight slot. + out1, err := mkExec(codeStoreLoad, thor.BytesToBytes32([]byte("eth-tx-1"))) + assert.NoError(t, err) + assert.Nil(t, out1.VMErr) + assert.Equal(t, uint64(1153), new(big.Int).SetBytes(out1.Data).Uint64(), + "first eth tx must observe its own TSTORE") + + // tx 2 — TLOAD only (no TSTORE in this tx): must return 0, proving the + // previous tx's transient slot was discarded at tx boundary. + out2, err := mkExec(codeLoadOnly, thor.BytesToBytes32([]byte("eth-tx-2"))) + assert.NoError(t, err) + assert.Nil(t, out2.VMErr) + assert.Equal(t, uint64(0), new(big.Int).SetBytes(out2.Data).Uint64(), + "second eth tx must NOT see prior tx's transient storage") +} + +// TestEthDynFee_SelfdestructPreExisting verifies EIP-6780 dispatch on the +// eth tx path: a pre-existing contract (deployed before this tx, so +// IsNewContract→false at SUICIDE) must have its code preserved per EIP-6780, +// only its balance is transferred. This proves the eth tx code path goes +// through the same opSuicide6780 logic as VeChain-native txs. +// +// Constructor of the test contract is irrelevant — we install runtime +// bytecode directly via state.SetCode. Runtime: CALLER SELFDESTRUCT. +func TestEthDynFee_SelfdestructPreExisting(t *testing.T) { + repo, st, baseFee, blockTime := setupEthTxRuntime(t) + + origin := genesis.DevAccounts()[0] + beneficiary := thor.BytesToAddress([]byte("proposer")) + + // Pre-deploy a contract whose runtime code is `CALLER SELFDESTRUCT`. + // IsNewContract=false at the SUICIDE site → EIP-6780 shouldDestruct=false. + target := thor.BytesToAddress([]byte("preexisting-killable")) + runtimeCode := []byte{0x33, 0xff} // CALLER, SELFDESTRUCT + assert.NoError(t, st.SetCode(target, runtimeCode)) + assert.NoError(t, st.SetBalance(target, big.NewInt(2000))) + + codeBefore, err := st.GetCode(target) + assert.NoError(t, err) + assert.NotEmpty(t, codeBefore) + + maxFee := new(big.Int).Mul(baseFee, big.NewInt(2)) + maxPriority := new(big.Int).Set(baseFee) + + trx := tx.NewBuilder(tx.TypeEthDynamicFee). + Gas(100000). + MaxFeePerGas(maxFee). + MaxPriorityFeePerGas(maxPriority). + ChainID(0). + Nonce(0). + To(&target). // call into pre-existing contract + Build() + trx = tx.MustSign(trx, origin.PrivateKey) + + rt := runtime.New( + repo.NewChain(repo.BestBlockSummary().Header.ID()), + st, + &xenv.BlockContext{ + Time: blockTime, + Number: repo.BestBlockSummary().Header.Number() + 1, + GasLimit: repo.BestBlockSummary().Header.GasLimit(), + BaseFee: baseFee, + Beneficiary: beneficiary, + }, + &thor.SoloFork, + ) + + receipt, err := rt.ExecuteTransaction(trx) + assert.Nil(t, err) + assert.False(t, receipt.Reverted, "selfdestruct must not revert") + + // EIP-6780: pre-existing → shouldDestruct=false → Suicide() NOT called → + // code persists. + codeAfter, err := st.GetCode(target) + assert.NoError(t, err) + assert.Equal(t, codeBefore, codeAfter, + "EIP-6780 (eth tx path): pre-existing contract code must persist after SELFDESTRUCT") + + // Balance is moved to caller. + bal, err := st.GetBalance(target) + assert.NoError(t, err) + assert.Zero(t, bal.Sign(), "balance must be 0 after SELFDESTRUCT") +} + +// TestEthDynFee_SelfdestructSameTxEIP6780 verifies EIP-6780 same-tx detection +// on the eth tx path. Init code is `CALLER SELFDESTRUCT` (no RETURN); during +// init execution IsNewContract→true, so shouldDestruct=true and the +// pre-funded constructor value is forwarded to the caller. A successful +// selfdestruct emits a Transfer record from the deployed addr to origin. +func TestEthDynFee_SelfdestructSameTxEIP6780(t *testing.T) { + repo, st, baseFee, blockTime := setupEthTxRuntime(t) + + origin := genesis.DevAccounts()[0] + beneficiary := thor.BytesToAddress([]byte("proposer")) + + // Init code: CALLER SELFDESTRUCT. No RETURN → no runtime code stored; + // the SELFDESTRUCT fires while IsNewContract=true. + initcode := []byte{0x33, 0xff} + + maxFee := new(big.Int).Mul(baseFee, big.NewInt(2)) + maxPriority := new(big.Int).Set(baseFee) + + trx := tx.NewBuilder(tx.TypeEthDynamicFee). + Gas(200000). + MaxFeePerGas(maxFee). + MaxPriorityFeePerGas(maxPriority). + ChainID(0). + Nonce(0). + Value(big.NewInt(1234)).Data(initcode). + Build() + trx = tx.MustSign(trx, origin.PrivateKey) + + rt := runtime.New( + repo.NewChain(repo.BestBlockSummary().Header.ID()), + st, + &xenv.BlockContext{ + Time: blockTime, + Number: repo.BestBlockSummary().Header.Number() + 1, + GasLimit: repo.BestBlockSummary().Header.GasLimit(), + BaseFee: baseFee, + Beneficiary: beneficiary, + }, + &thor.SoloFork, + ) + + receipt, err := rt.ExecuteTransaction(trx) + assert.Nil(t, err) + assert.False(t, receipt.Reverted, "init+selfdestruct must not revert") + + // Eth tx CREATE address: keccak256(rlp([origin, nonce=0])). + deployedAddr := thor.Address(crypto.CreateAddress(common.Address(origin.Address), 0)) + + // EIP-6780 same-tx → Suicide() called. + // The deployed addr's balance was funded by msg.value at CREATE time, then + // transferred to origin by SELFDESTRUCT. Origin's balance net-delta is 0 + // (out then back), so observe via the deployed addr's terminal balance and + // the Transfer record emitted by the suicide. + deployedBal, err := st.GetBalance(deployedAddr) + assert.NoError(t, err) + assert.Zero(t, deployedBal.Sign(), "deployed contract must have 0 balance after self-destruct") + + // Two transfer records on the single clause: (origin → deployed) at CREATE + // pre-funding, then (deployed → origin) at SELFDESTRUCT. The second is the + // signature of EIP-6780 same-tx → Suicide() executing. + require.Len(t, receipt.Outputs, 1) + require.Len(t, receipt.Outputs[0].Transfers, 2) + suicide := receipt.Outputs[0].Transfers[1] + assert.Equal(t, deployedAddr, suicide.Sender) + assert.Equal(t, origin.Address, suicide.Recipient) + assert.Equal(t, big.NewInt(1234), suicide.Amount) +} diff --git a/runtime/runtime_multi_clause_revert_test.go b/runtime/runtime_multi_clause_revert_test.go new file mode 100644 index 000000000..c6e2e63c7 --- /dev/null +++ b/runtime/runtime_multi_clause_revert_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 The VeChainThor developers + +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package runtime_test + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/vechain/thor/v2/chain" + "github.com/vechain/thor/v2/genesis" + "github.com/vechain/thor/v2/muxdb" + "github.com/vechain/thor/v2/runtime" + "github.com/vechain/thor/v2/state" + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/trie" + "github.com/vechain/thor/v2/tx" + "github.com/vechain/thor/v2/xenv" +) + +// TestMultiClauseRevertPropagation guards the multi-clause all-or-nothing +// semantic: when any clause hits a VMErr, the whole tx must be marked reverted +// and prior clauses' state changes rolled back. Regression for a bug where the +// non-eth path returned without setting reverted=true / txOutputs=nil. +func TestMultiClauseRevertPropagation(t *testing.T) { + origin := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + db := muxdb.NewMem() + g, fc := genesis.NewDevnet() + b0, _, _, err := g.Build(state.NewStater(db)) + assert.Nil(t, err) + repo, _ := chain.NewRepository(db, b0) + st := state.New(db, trie.Root{Hash: b0.Header().StateRoot()}) + + prevBal, err := st.GetBalance(recipient.Address) + assert.Nil(t, err) + + transferValue := big.NewInt(1000) + to := recipient.Address + + // Clause 0: VET transfer (succeeds). Clause 1: deploy contract whose + // init code is a single INVALID opcode (0xfe) → ErrInvalidOpCode → VMErr. + trx := tx.NewBuilder(tx.TypeLegacy). + ChainTag(repo.ChainTag()). + Gas(200_000). + GasPriceCoef(0). + Expiration(32). + Clause(tx.NewClause(&to).WithValue(transferValue)). + Clause(tx.NewClause(nil).WithData([]byte{0xfe})). + Nonce(1). + Build() + trx = tx.MustSign(trx, origin.PrivateKey) + + rt := runtime.New(repo.NewChain(b0.Header().ID()), st, &xenv.BlockContext{ + GasLimit: b0.Header().GasLimit(), + BaseFee: big.NewInt(thor.InitialBaseFee), + }, fc) + + receipt, err := rt.ExecuteTransaction(trx) + assert.Nil(t, err, "ExecuteTransaction should not error on clause-level revert") + assert.NotNil(t, receipt) + assert.True(t, receipt.Reverted, "multi-clause tx must be marked reverted when any clause fails") + assert.Nil(t, receipt.Outputs, "outputs must be cleared on revert") + + // Recipient balance must be unchanged: clause 0's transfer was reverted. + currBal, err := st.GetBalance(recipient.Address) + assert.Nil(t, err) + assert.Equal(t, prevBal, currBal, "prior clause's state changes must be rolled back") +} diff --git a/runtime/statedb/statedb.go b/runtime/statedb/statedb.go index 23dde375c..2819301ac 100644 --- a/runtime/statedb/statedb.go +++ b/runtime/statedb/statedb.go @@ -21,11 +21,12 @@ import ( var codeSizeCache, _ = lru.New(32 * 1024) -// StateDB implements evm.StateDB, only adapt to evm. +// StateDB adapts thor's state to vm.StateDB and adds thor-specific helpers +// (GetLogs, AddTransfer). type StateDB struct { - state *state.State - repo *stackedmap.StackedMap - txType tx.Type + state *state.State + repo *stackedmap.StackedMap + nonceEnabled bool } type ( @@ -42,8 +43,10 @@ type ( } ) -// New create a statedb object. -func New(state *state.State, txType tx.Type) *StateDB { +// New creates a statedb. When enableNonce is true, Get/SetNonce read and +// persist the on-state account nonce (required for eth tx execution); +// otherwise GetNonce returns 0 and SetNonce is a no-op. +func New(state *state.State, enableNonce bool) *StateDB { getter := func(k any) (any, bool, error) { switch k.(type) { case suicideFlagKey: @@ -58,11 +61,10 @@ func New(state *state.State, txType tx.Type) *StateDB { panic(fmt.Sprintf("unknown type of key %+v", k)) } - repo := stackedmap.New(getter) return &StateDB{ - state: state, - repo: repo, - txType: txType, + state: state, + repo: stackedmap.New(getter), + nonceEnabled: enableNonce, } } @@ -138,12 +140,13 @@ func (s *StateDB) AddBalance(addr common.Address, amount *big.Int) { } } -// GetNonce returns the Ethereum nonce for the given address. -// Returns 0 for VeChain-native transactions (nonce is not used). +// GetNonce reads the on-state nonce when enabled; otherwise returns 0 +// because VeChain-native txs never observe a real nonce. func (s *StateDB) GetNonce(addr common.Address) uint64 { - if s.txType != tx.TypeEthDynamicFee { + if !s.nonceEnabled { return 0 } + n, err := s.state.GetNonce(thor.Address(addr)) if err != nil { panic(err) @@ -151,12 +154,12 @@ func (s *StateDB) GetNonce(addr common.Address) uint64 { return n } -// SetNonce sets the Ethereum nonce for the given address. -// No-op for VeChain-native transactions. +// SetNonce persists the nonce when enabled; otherwise it is a no-op. func (s *StateDB) SetNonce(addr common.Address, nonce uint64) { - if s.txType != tx.TypeEthDynamicFee { + if !s.nonceEnabled { return } + if err := s.state.SetNonce(thor.Address(addr), nonce); err != nil { panic(err) } diff --git a/runtime/statedb/statedb_test.go b/runtime/statedb/statedb_test.go index 7b794e856..86f94b808 100644 --- a/runtime/statedb/statedb_test.go +++ b/runtime/statedb/statedb_test.go @@ -24,10 +24,32 @@ import ( "github.com/vechain/thor/v2/muxdb" State "github.com/vechain/thor/v2/state" + "github.com/vechain/thor/v2/thor" "github.com/vechain/thor/v2/trie" - "github.com/vechain/thor/v2/tx" ) +// TestStateDB_NonceGated verifies that Get/SetNonce only touch state when +// enableNonce is true; otherwise GetNonce returns 0 and SetNonce is a no-op. +func TestStateDB_NonceGated(t *testing.T) { + st := State.New(muxdb.NewMem(), trie.Root{}) + addr := common.Address(thor.BytesToAddress([]byte("acc1"))) + + enabled := New(st, true) + enabled.SetNonce(addr, 7) + assert.Equal(t, uint64(7), enabled.GetNonce(addr), "enabled SetNonce must persist") + + n, err := st.GetNonce(thor.Address(addr)) + assert.Nil(t, err) + assert.Equal(t, uint64(7), n) + + disabled := New(st, false) + assert.Equal(t, uint64(0), disabled.GetNonce(addr), "disabled GetNonce must always return 0") + disabled.SetNonce(addr, 99) + n, err = st.GetNonce(thor.Address(addr)) + assert.Nil(t, err) + assert.Equal(t, uint64(7), n, "disabled SetNonce must not change state") +} + func TestSnapshotRandom(t *testing.T) { config := &quick.Config{MaxCount: 1000} err := quick.Check((*snapshotTest).run, config) @@ -189,7 +211,7 @@ func (test *snapshotTest) run() bool { var ( db = muxdb.NewMem() state = State.New(db, trie.Root{}) - stateDB = New(state, tx.TypeLegacy) + stateDB = New(state, false) snapshotRevs = make([]int, len(test.snapshots)) sindex = 0 ) @@ -204,7 +226,7 @@ func (test *snapshotTest) run() bool { // that is equivalent to fresh state with all actions up the snapshot applied. for sindex--; sindex >= 0; sindex-- { state := State.New(db, trie.Root{}) - checkStateDB := New(state, tx.TypeLegacy) + checkStateDB := New(state, false) for _, action := range test.actions[:test.snapshots[sindex]] { action.fn(action, checkStateDB) } @@ -258,7 +280,7 @@ func TestTransientState(t *testing.T) { db := muxdb.NewMem() state := State.NewStater(db).NewState(trie.Root{}) - stateDB := New(state, tx.TypeLegacy) + stateDB := New(state, false) val := stateDB.GetTransientState(addr, key) assert.Equal(t, common.Hash{}, val) @@ -282,7 +304,7 @@ func TestTransientState(t *testing.T) { func TestCreateContract(t *testing.T) { addr := common.Address{0x1} - stateDB := New(State.New(muxdb.NewMem(), trie.Root{}), tx.TypeLegacy) + stateDB := New(State.New(muxdb.NewMem(), trie.Root{}), false) assert.False(t, stateDB.IsNewContract(addr)) stateDB.CreateContract(addr) assert.True(t, stateDB.IsNewContract(addr)) diff --git a/state/account.go b/state/account.go index 80946536d..11b002a15 100644 --- a/state/account.go +++ b/state/account.go @@ -24,6 +24,10 @@ type AccountMetadata struct { // Account is the Thor consensus representation of an account. // RLP encoded objects are stored in main account trie. +// +// Nonce is optional: pre-Interstellar accounts encode 6 fields (Nonce==0 +// trailing) so state root stays unchanged byte-for-byte. Post-Interstellar +// 0x02 txs may bump it; once non-zero the encoding becomes 7 fields. type Account struct { Balance *big.Int Energy *big.Int @@ -31,20 +35,17 @@ type Account struct { Master []byte // master address CodeHash []byte // hash of code StorageRoot []byte // merkle root of the storage trie - // Nonce is the Ethereum-compatible transaction counter for TypeEthTyped1559 senders. - // Stored as a single-element slice so rlp:"tail" gives backward-compatible encoding: - // zero nonce → nil slice → same 6-field RLP as pre-INTERSTELLAR accounts. - Nonce []uint64 `rlp:"tail"` + Nonce uint64 `rlp:"optional"` } // IsEmpty returns if an account is empty. -// An empty account has zero balance and zero length code hash. +// EIP-161 alignment: zero balance, energy, master, codeHash, and nonce. func (a *Account) IsEmpty() bool { return a.Balance.Sign() == 0 && a.Energy.Sign() == 0 && len(a.Master) == 0 && len(a.CodeHash) == 0 && - len(a.Nonce) == 0 + a.Nonce == 0 } var bigE18 = big.NewInt(1e18) @@ -106,11 +107,6 @@ func loadAccount(trie *muxdb.Trie, addr thor.Address) (*Account, *AccountMetadat if err := rlp.DecodeBytes(data, &a); err != nil { return nil, nil, err } - // rlp:"tail" decodes absent elements as an empty (non-nil) slice; - // normalize to nil so zero-nonce accounts compare equal to emptyAccount(). - if len(a.Nonce) == 0 { - a.Nonce = nil - } var am AccountMetadata if len(meta) > 0 { diff --git a/state/account_test.go b/state/account_test.go index caa924e8c..124a7eb49 100644 --- a/state/account_test.go +++ b/state/account_test.go @@ -9,9 +9,10 @@ import ( "math/big" "testing" - "github.com/ethereum/go-ethereum/rlp" "github.com/stretchr/testify/assert" + "github.com/ethereum/go-ethereum/rlp" + "github.com/vechain/thor/v2/muxdb" "github.com/vechain/thor/v2/thor" "github.com/vechain/thor/v2/trie" @@ -78,19 +79,22 @@ func TestTrie(t *testing.T) { "should load an empty account") acc1 := Account{ - Balance: big.NewInt(1), - Energy: big.NewInt(0), - BlockTime: 0, - Master: []byte("master"), - CodeHash: []byte("code hash"), - StorageRoot: []byte("storage root"), + big.NewInt(1), + big.NewInt(0), + 0, + []byte("master"), + []byte("code hash"), + []byte("storage root"), + 0, } meta1 := AccountMetadata{ StorageID: []byte("sid"), StorageMajorVer: 1, StorageMinorVer: 2, } - saveAccount(tr, addr, &acc1, &meta1) + if err := saveAccount(tr, addr, &acc1, &meta1); err != nil { + t.Fatalf("saveAccount: %v", err) + } assert.Equal(t, M(loadAccount(tr, addr)), M(&acc1, &meta1, nil)) @@ -102,6 +106,73 @@ func TestTrie(t *testing.T) { "empty account should be deleted") } +// TestAccountRLPBackCompat pins the wire format: +// - Nonce==0 encodes as the legacy 6-field layout (state root unchanged for +// all historic accounts) via rlp:"optional". +// - Nonce>0 encodes as the 7-field layout. +// - Both decode back into a structurally equivalent Account. +func TestAccountRLPBackCompat(t *testing.T) { + common := Account{ + Balance: big.NewInt(1), + Energy: big.NewInt(2), + BlockTime: 3, + Master: []byte("master"), + CodeHash: []byte("code hash"), + StorageRoot: []byte("storage root"), + } + + // fieldCount returns the number of values in the encoded RLP list. + fieldCount := func(b []byte) int { + t.Helper() + content, _, err := rlp.SplitList(b) + assert.Nil(t, err) + n, err := rlp.CountValues(content) + assert.Nil(t, err) + return n + } + + // legacy 6-field reference encoding. + type legacy struct { + Balance *big.Int + Energy *big.Int + BlockTime uint64 + Master []byte + CodeHash []byte + StorageRoot []byte + } + legacyBytes, err := rlp.EncodeToBytes(&legacy{ + Balance: common.Balance, Energy: common.Energy, BlockTime: common.BlockTime, + Master: common.Master, CodeHash: common.CodeHash, StorageRoot: common.StorageRoot, + }) + assert.Nil(t, err) + assert.Equal(t, 6, fieldCount(legacyBytes)) + + // Nonce==0 → 6-field encoding, byte-for-byte equal to legacy reference. + zeroAcc := common + zeroBytes, err := rlp.EncodeToBytes(&zeroAcc) + assert.Nil(t, err) + assert.Equal(t, 6, fieldCount(zeroBytes), "Nonce==0 must encode as 6 fields") + assert.Equal(t, legacyBytes, zeroBytes, "Nonce==0 must encode bit-for-bit as legacy") + + // Nonce>0 → 7-field encoding. + noncedAcc := common + noncedAcc.Nonce = 42 + noncedBytes, err := rlp.EncodeToBytes(&noncedAcc) + assert.Nil(t, err) + assert.Equal(t, 7, fieldCount(noncedBytes), "Nonce>0 must encode as 7 fields") + assert.NotEqual(t, legacyBytes, noncedBytes) + + // Decode 6-field bytes → Account with Nonce=0. + var decodedZero Account + assert.Nil(t, rlp.DecodeBytes(legacyBytes, &decodedZero)) + assert.Equal(t, uint64(0), decodedZero.Nonce) + + // Decode 7-field bytes → Account with Nonce=42. + var decodedNonced Account + assert.Nil(t, rlp.DecodeBytes(noncedBytes, &decodedNonced)) + assert.Equal(t, uint64(42), decodedNonced.Nonce) +} + func TestStorageTrie(t *testing.T) { db := muxdb.NewMem() tr := db.NewTrie("", trie.Root{}) diff --git a/state/state.go b/state/state.go index 8966c6bab..598b06ec5 100644 --- a/state/state.go +++ b/state/state.go @@ -173,6 +173,26 @@ func (s *State) SetBalance(addr thor.Address, balance *big.Int) error { return nil } +// GetNonce returns the post-Interstellar 0x02 sequential nonce for the address. +func (s *State) GetNonce(addr thor.Address) (uint64, error) { + acc, err := s.getAccount(addr) + if err != nil { + return 0, &Error{err} + } + return acc.Nonce, nil +} + +// SetNonce sets the sequential nonce for the address. +func (s *State) SetNonce(addr thor.Address, nonce uint64) error { + cpy, err := s.getAccountCopy(addr) + if err != nil { + return &Error{err} + } + cpy.Nonce = nonce + s.updateAccount(addr, &cpy) + return nil +} + // GetEnergy get energy for the given address at block number specified. func (s *State) GetEnergy(addr thor.Address, blockTime uint64, stopTime uint64) (*big.Int, error) { acc, err := s.getAccount(addr) @@ -219,34 +239,6 @@ func (s *State) SetMaster(addr thor.Address, master thor.Address) error { return nil } -// GetNonce returns the Ethereum nonce for the given address. -// Returns 0 for addresses that have never sent an EthereumTx. -func (s *State) GetNonce(addr thor.Address) (uint64, error) { - acc, err := s.getAccount(addr) - if err != nil { - return 0, &Error{err} - } - if len(acc.Nonce) > 0 { - return acc.Nonce[0], nil - } - return 0, nil -} - -// SetNonce sets the Ethereum nonce for the given address. -func (s *State) SetNonce(addr thor.Address, nonce uint64) error { - cpy, err := s.getAccountCopy(addr) - if err != nil { - return &Error{err} - } - if nonce == 0 { - cpy.Nonce = nil - } else { - cpy.Nonce = []uint64{nonce} - } - s.updateAccount(addr, &cpy) - return nil -} - // GetStorage returns storage value for the given address and key. func (s *State) GetStorage(addr thor.Address, key thor.Bytes32) (thor.Bytes32, error) { raw, err := s.GetRawStorage(addr, key) diff --git a/state/state_test.go b/state/state_test.go index bd79edf41..b7905d342 100644 --- a/state/state_test.go +++ b/state/state_test.go @@ -11,9 +11,10 @@ import ( "math/big" "testing" - "github.com/ethereum/go-ethereum/rlp" "github.com/stretchr/testify/assert" + "github.com/ethereum/go-ethereum/rlp" + "github.com/vechain/thor/v2/muxdb" "github.com/vechain/thor/v2/thor" "github.com/vechain/thor/v2/trie" @@ -30,11 +31,15 @@ func TestStateReadWrite(t *testing.T) { assert.Equal(t, M([]byte(nil), nil), M(state.GetCode(addr))) assert.Equal(t, M(thor.Bytes32{}, nil), M(state.GetCodeHash(addr))) assert.Equal(t, M(thor.Bytes32{}, nil), M(state.GetStorage(addr, storageKey))) + assert.Equal(t, M(uint64(0), nil), M(state.GetNonce(addr))) // make account not empty state.SetBalance(addr, big.NewInt(1)) assert.Equal(t, M(big.NewInt(1), nil), M(state.GetBalance(addr))) + state.SetNonce(addr, 7) + assert.Equal(t, M(uint64(7), nil), M(state.GetNonce(addr))) + state.SetMaster(addr, thor.BytesToAddress([]byte("master"))) assert.Equal(t, M(thor.BytesToAddress([]byte("master")), nil), M(state.GetMaster(addr))) @@ -55,6 +60,7 @@ func TestStateReadWrite(t *testing.T) { assert.Equal(t, M(thor.Address{}, nil), M(state.GetMaster(addr))) assert.Equal(t, M([]byte(nil), nil), M(state.GetCode(addr))) assert.Equal(t, M(thor.Bytes32{}, nil), M(state.GetCodeHash(addr))) + assert.Equal(t, M(uint64(0), nil), M(state.GetNonce(addr))) } func TestStateRevert(t *testing.T) { @@ -389,14 +395,14 @@ func TestNonce(t *testing.T) { assert.Equal(t, uint64(42), n) }) - t.Run("zero is stored as nil in account", func(t *testing.T) { + t.Run("zero is stored as zero in account", func(t *testing.T) { st := New(muxdb.NewMem(), trie.Root{}) _ = st.SetNonce(addr, 5) _ = st.SetNonce(addr, 0) acc, err := st.getAccountCopy(addr) assert.NoError(t, err) - // nonce=0 must encode as nil so zero-nonce accounts are indistinguishable - // from pre-INTERSTELLAR accounts and IsEmpty() remains correct. - assert.Nil(t, acc.Nonce) + // rlp:"optional" elides zero-value trailing fields → on-trie encoding + // matches pre-INTERSTELLAR accounts; IsEmpty() remains correct. + assert.Equal(t, uint64(0), acc.Nonce) }) } diff --git a/test/testchain/eth_block_test.go b/test/testchain/eth_block_test.go index 7ec3a7371..210ddcb67 100644 --- a/test/testchain/eth_block_test.go +++ b/test/testchain/eth_block_test.go @@ -70,7 +70,7 @@ func TestMintBlock_MixedTxFamilies(t *testing.T) { // --- 2. Ethereum EthDynamicFee tx --- eth1559Tx := tx.MustSign(tx.NewBuilder(tx.TypeEthDynamicFee). ChainID(ethChainID). - Nonce(1). + Nonce(0). MaxPriorityFeePerGas(feeForPriority). MaxFeePerGas(feeAboveBase). Gas(21000). diff --git a/txpool/tx_object.go b/txpool/tx_object.go index 5c2fb5716..6f8b71ce5 100644 --- a/txpool/tx_object.go +++ b/txpool/tx_object.go @@ -116,6 +116,20 @@ func (o *TxObject) Executable(chain *chain.Chain, state *state.State, headBlock return false, nil } + // Eth tx requires linear nonce growth: equal → executable, greater → queued, lower → reject. + if o.Type() == tx.TypeEthDynamicFee { + accNonce, err := state.GetNonce(o.resolved.Origin) + if err != nil { + return false, err + } + if o.Nonce() < accNonce { + return false, errors.New("nonce too low") + } + if o.Nonce() > accNonce { + return false, nil + } + } + checkpoint := state.NewCheckpoint() defer state.RevertTo(checkpoint) diff --git a/txpool/tx_pool_test.go b/txpool/tx_pool_test.go index 550325379..3b87e6fba 100644 --- a/txpool/tx_pool_test.go +++ b/txpool/tx_pool_test.go @@ -2103,3 +2103,104 @@ func TestTxPool_Local_IncreasingPriority(t *testing.T) { assert.Greater(t, tx.MaxPriorityFeePerGas().Int64(), int64(5*multiplier)) } } + +func TestEthDynFee_AdmitAndExecutables(t *testing.T) { + pool := newPool(LIMIT, LIMIT_PER_ACCOUNT, &thor.SoloFork) + defer pool.Close() + + addr := devAccounts[1].Address + trx := tx.NewBuilder(tx.TypeEthDynamicFee). + Gas(21000). + MaxFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxPriorityFeePerGas(big.NewInt(0)). + ChainID(pool.repo.ChainID()). + Nonce(1). + To(&addr).Value(big.NewInt(1)). + Build() + trx = tx.MustSign(trx, devAccounts[0].PrivateKey) + + err := pool.Add(trx) + assert.Nil(t, err, "eth-tx with matching ChainID must be admitted") + + found := false + for _, t := range pool.Dump() { + if t.ID() == trx.ID() { + found = true + break + } + } + assert.True(t, found, "eth-tx must appear in pool.Dump()") +} + +func TestEthDynFee_NonceLinearGrowth(t *testing.T) { + pool := newPool(LIMIT, LIMIT_PER_ACCOUNT, &thor.SoloFork) + defer pool.Close() + + to := devAccounts[1].Address + signer := devAccounts[5] + + build := func(nonce uint64) *tx.Transaction { + trx := tx.NewBuilder(tx.TypeEthDynamicFee). + Gas(21000). + MaxFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxPriorityFeePerGas(big.NewInt(0)). + ChainID(pool.repo.ChainID()). + Nonce(nonce). + To(&to).Value(big.NewInt(1)). + Build() + return tx.MustSign(trx, signer.PrivateKey) + } + + // state.nonce(signer) starts at 0. + exec := build(0) + queued := build(5) + + assert.NoError(t, pool.Add(exec), "nonce==state.nonce must admit") + assert.NoError(t, pool.Add(queued), "nonce>state.nonce must admit (non-executable)") + + executables, _, _, err := pool.wash(pool.repo.BestBlockSummary(), false) + assert.Nil(t, err) + + execIDs := map[thor.Bytes32]bool{} + for _, e := range executables { + execIDs[e.ID()] = true + } + assert.True(t, execIDs[exec.ID()], "nonce==state.nonce must be in executables") + assert.False(t, execIDs[queued.ID()], "nonce>state.nonce must not be in executables") + + // After wash, both txs are still in pool.all (queued tx parked, not dropped). + assert.NotNil(t, pool.all.GetByID(queued.ID()), "queued tx must remain in pool") +} + +func TestEthDynFee_WashRemovesEthBucket(t *testing.T) { + // Use a pool with capacity for 2 txs so both can be admitted. + // Then shrink the limit to 1 before calling wash to force eviction. + pool := newPool(2, 10, &thor.SoloFork) + defer pool.Close() + + addr := devAccounts[1].Address + + // Use distinct signers so each tx is at the signer's current state nonce (0) + // and stays executable — strict nonce growth would otherwise queue them. + for i, signer := range []genesis.DevAccount{devAccounts[3], devAccounts[4]} { + trx := tx.NewBuilder(tx.TypeEthDynamicFee). + Gas(21000). + MaxFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxPriorityFeePerGas(big.NewInt(0)). + ChainID(pool.repo.ChainID()). + Nonce(0). + To(&addr).Value(big.NewInt(1)). + Build() + trx = tx.MustSign(trx, signer.PrivateKey) + if err := pool.Add(trx); err != nil { + t.Fatalf("admit signer %d: %v", i, err) + } + } + + // Shrink the limit so wash must evict exactly one tx. + pool.options.Limit = 1 + + _, _, _, err := pool.wash(pool.repo.BestBlockSummary(), false) + assert.Nil(t, err) + assert.Equal(t, 1, pool.all.Len(), "exactly one eth-tx remains after eviction") +} diff --git a/vm/errors.go b/vm/errors.go index ac074c4dc..466084c00 100644 --- a/vm/errors.go +++ b/vm/errors.go @@ -31,4 +31,5 @@ var ( ErrMaxCodeSizeExceeded = errors.New("max code size exceeded") ErrGasUintOverflow = errors.New("gas uint64 overflow") ErrInvalidCode = errors.New("invalid code: must not begin with 0xef") + ErrNonceUintOverflow = errors.New("nonce uint64 overflow") ) diff --git a/vm/evm.go b/vm/evm.go index f206f6244..23464d9e2 100644 --- a/vm/evm.go +++ b/vm/evm.go @@ -458,6 +458,9 @@ func (evm *EVM) create(caller ContractRef, code []byte, gas uint64, value *big.I return nil, common.Address{}, gas, ErrInsufficientBalance } nonce := evm.StateDB.GetNonce(caller.Address()) + if nonce+1 < nonce { + return nil, common.Address{}, gas, ErrNonceUintOverflow + } evm.StateDB.SetNonce(caller.Address(), nonce+1) // Increase counter, same behavior as Create() diff --git a/vm/instructions_test.go b/vm/instructions_test.go index 0a961a316..bb559320d 100644 --- a/vm/instructions_test.go +++ b/vm/instructions_test.go @@ -614,7 +614,7 @@ func TestOpTstore(t *testing.T) { var ( db = muxdb.NewMem() state = state.New(db, trie.Root{Hash: thor.Bytes32{}}) - stateDB = statedb.New(state, tx.TypeLegacy) + stateDB = statedb.New(state, false) env = NewEVM(Context{}, stateDB, &ChainConfig{ChainConfig: *params.TestChainConfig}, Config{}) stack = newstack() mem = NewMemory() @@ -746,7 +746,7 @@ func TestOpSuicide6780(t *testing.T) { tests := []testcase{} newEVMInstance := func(state *state.State) *EVM { - stateDB := statedb.New(state, tx.TypeLegacy) + stateDB := statedb.New(state, false) evm := NewEVM(Context{ BlockNumber: big.NewInt(1), GasPrice: big.NewInt(1), diff --git a/xenv/env.go b/xenv/env.go index 7f604e00e..a3faa7491 100644 --- a/xenv/env.go +++ b/xenv/env.go @@ -34,6 +34,7 @@ type BlockContext struct { // TransactionContext transaction context. type TransactionContext struct { + Type tx.Type ID thor.Bytes32 Origin thor.Address GasPayer thor.Address @@ -42,7 +43,6 @@ type TransactionContext struct { BlockRef tx.BlockRef Expiration uint32 ClauseCount uint32 - Type tx.Type } // Environment an env to execute native method.