Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions core/state/parallel_read_invariants_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,122 @@ func TestParallelReadRecordingCompleteness(t *testing.T) {
})
}
}

// TestParallelExactWriterAcrossDestruction pins the fix for value-equality
// masking a reordered writer across a destruction boundary. For a read resolved
// relative to a prior SELFDESTRUCT (writerIdx vs suicideIdx) and for the
// existence markers, the winning writer's *position* — not just its value —
// determines the result. So when the winning writer changes to one producing an
// EQUAL value, validation must still fail; accepting it by value equality
// settles a stale read and diverges from serial.
//
// Each case stages the metamorphic shape at the MVStore level: tx0 creates the
// account and SELFDESTRUCTs it (write at idx 0 + SuicidePath at idx 0), tx2
// re-creates it with an equal value, the read observes tx2's value, then tx2's
// write is withdrawn (re-execution) so the winner falls back to tx0 — which is
// at/below the destruction and therefore wiped. The recorded read must be
// rejected. The destructor sits at tx index 0 on purpose: that is the exact
// suicideIdx>=0 boundary, so a `>0` regression (which would drop ExactWriter
// when the destruction is the first tx) is caught here too.
func TestParallelExactWriterAcrossDestruction(t *testing.T) {
addr := common.HexToAddress("0x00000000000000000000000000000000000000e0")
slot := common.HexToHash("0x01")
codeKey := blockstm.NewSubpathKey(addr, CodePath)
nonceKey := blockstm.NewSubpathKey(addr, NoncePath)
stateKey := blockstm.NewStateKey(addr, slot)
createKey := blockstm.NewSubpathKey(addr, CreatePath)
suicideKey := blockstm.NewSubpathKey(addr, SuicidePath)
code := []byte{0x60, 0x00}
stateVal := common.HexToHash("0x2a")

cases := []struct {
name string
key blockstm.Key // the read's key, withdrawn after the read to move the winner
// pre stages tx0's create+suicide and tx2's equal-valued re-create.
pre func(s *blockstm.MVStore)
read func(p *ParallelStateDB)
}{
{
name: "GetState",
key: stateKey,
pre: func(s *blockstm.MVStore) {
s.WriteInc(suicideKey, 0, 0, true)
s.WriteInc(stateKey, 0, 0, stateVal)
s.WriteInc(stateKey, 2, 0, stateVal)
},
read: func(p *ParallelStateDB) { p.GetState(addr, slot) },
},
{
name: "GetCommittedState", // the diffguard-flagged site (parallel_statedb.go:947)
key: stateKey,
pre: func(s *blockstm.MVStore) {
s.WriteInc(suicideKey, 0, 0, true)
s.WriteInc(stateKey, 0, 0, stateVal)
s.WriteInc(stateKey, 2, 0, stateVal)
},
read: func(p *ParallelStateDB) { p.GetCommittedState(addr, slot) },
},
{
name: "GetCode",
key: codeKey,
pre: func(s *blockstm.MVStore) {
s.WriteInc(suicideKey, 0, 0, true)
s.WriteInc(codeKey, 0, 0, code)
s.WriteInc(codeKey, 2, 0, code)
},
read: func(p *ParallelStateDB) { p.GetCode(addr) },
},
{
name: "GetCodeHash",
key: codeKey,
pre: func(s *blockstm.MVStore) {
s.WriteInc(suicideKey, 0, 0, true)
s.WriteInc(codeKey, 0, 0, code)
s.WriteInc(codeKey, 2, 0, code)
},
read: func(p *ParallelStateDB) { p.GetCodeHash(addr) },
},
{
name: "GetNonce",
key: nonceKey,
pre: func(s *blockstm.MVStore) {
s.WriteInc(suicideKey, 0, 0, true)
s.WriteInc(nonceKey, 0, 0, uint64(7))
s.WriteInc(nonceKey, 2, 0, uint64(7))
},
read: func(p *ParallelStateDB) { p.GetNonce(addr) },
},
{
// Existence markers: their value is always true, so value equality is
// pure noise — only the writer's position carries information.
name: "Exist_CreatePathMarker",
key: createKey,
pre: func(s *blockstm.MVStore) {
s.WriteInc(suicideKey, 0, 0, true)
s.WriteInc(createKey, 0, 0, true)
s.WriteInc(createKey, 2, 0, true)
},
read: func(p *ParallelStateDB) { p.Exist(addr) },
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
pdb, store, _ := newTestPDB(t, 5)
pdb.EnableReadTracking()

tc.pre(store)
tc.read(pdb)
// Re-execution of tx2 withdraws its write; the winner falls back to
// tx0, which is wiped by the same-index destruction. The recorded read
// (writer=2, equal value) must no longer validate.
store.Delete(tc.key, 2)

if res := pdb.ValidateDetailed(); res.Valid {
t.Fatalf("%s: a reordered writer across the destruction boundary produced an "+
"equal value and was accepted by value equality; the read must require an "+
"exact writer match and invalidate the tx", tc.name)
}
})
}
}
36 changes: 26 additions & 10 deletions core/state/parallel_statedb.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ type StoreReadDesc struct {
WriterIdx int // txIdx of writer (-1 = base)
WriterInc int // incarnation of writer
StoreVal interface{} // actual value read (for value-based validation)
// ExactWriter disables the value-equality validation fallback for this
// read. Set when the winning writer's block-order position — not just its
// value — determined the read result: existence/ordering markers
// (CreatePath/SuicidePath) and any read resolved relative to a prior
// SELFDESTRUCT (writerIdx vs suicideIdx). For those reads a different
// writer producing an equal value can still flip the result (a reordered
// metamorphic CREATE2/SELFDESTRUCT moves the writer across the destruction
// boundary), so re-validation must require the same writer/incarnation.
ExactWriter bool
}

// BalReadDesc tracks a balance delta read for validation.
Expand Down Expand Up @@ -387,10 +396,17 @@ func (s *ParallelStateDB) EnableReadTracking() {
}

func (s *ParallelStateDB) recordStoreRead(key blockstm.Key, writerIdx, writerInc int, val interface{}) {
s.recordStoreReadEx(key, writerIdx, writerInc, val, false)
}

// recordStoreReadEx records a read, optionally marking it ExactWriter so
// validation cannot accept a different writer by value equality. See
// StoreReadDesc.ExactWriter.
func (s *ParallelStateDB) recordStoreReadEx(key blockstm.Key, writerIdx, writerInc int, val interface{}, exact bool) {
if !s.trackReads {
return
}
s.StoreReads = append(s.StoreReads, StoreReadDesc{Key: key, WriterIdx: writerIdx, WriterInc: writerInc, StoreVal: val})
s.StoreReads = append(s.StoreReads, StoreReadDesc{Key: key, WriterIdx: writerIdx, WriterInc: writerInc, StoreVal: val, ExactWriter: exact})
}

func (s *ParallelStateDB) recordBalanceRead(addr common.Address, add, sub uint256.Int) {
Expand Down Expand Up @@ -548,12 +564,12 @@ func (s *ParallelStateDB) priorDestructedAt(addr common.Address) int {
}
}
suicideKey := blockstm.NewSubpathKey(addr, SuicidePath)
val, writerIdx, _, found := s.readStoreWait(suicideKey)
val, writerIdx, writerInc, found := s.readStoreWait(suicideKey)
idx := -1
if found {
idx = writerIdx
if _, seen := s.destructedSeen[addr]; !seen {
s.recordStoreRead(suicideKey, writerIdx, 0, val)
s.recordStoreReadEx(suicideKey, writerIdx, writerInc, val, true)
}
} else if _, seen := s.destructedSeen[addr]; !seen {
s.recordStoreRead(suicideKey, -1, 0, nil)
Expand All @@ -573,9 +589,9 @@ func (s *ParallelStateDB) priorDestructedAt(addr common.Address) int {
// SELFDESTRUCT was followed by recreation.
func (s *ParallelStateDB) priorCreatedAt(addr common.Address) int {
createKey := blockstm.NewSubpathKey(addr, CreatePath)
val, writerIdx, _, found := s.readStoreWait(createKey)
val, writerIdx, writerInc, found := s.readStoreWait(createKey)
if found {
s.recordStoreRead(createKey, writerIdx, 0, val)
s.recordStoreReadEx(createKey, writerIdx, writerInc, val, true)
return writerIdx
}
s.recordStoreRead(createKey, -1, 0, nil)
Expand Down Expand Up @@ -735,7 +751,7 @@ func (s *ParallelStateDB) GetNonce(addr common.Address) uint64 {
suicideIdx := s.priorDestructedAt(addr)
nonceKey := blockstm.NewSubpathKey(addr, NoncePath)
if val, writerIdx, writerInc, found := s.readStoreWait(nonceKey); found {
s.recordStoreRead(nonceKey, writerIdx, writerInc, val)
s.recordStoreReadEx(nonceKey, writerIdx, writerInc, val, suicideIdx >= 0)
// Only honor the nonce write if it landed AFTER the destruction.
// Otherwise the destruction wiped it.
if writerIdx > suicideIdx {
Expand Down Expand Up @@ -777,7 +793,7 @@ func (s *ParallelStateDB) GetCode(addr common.Address) []byte {
suicideIdx := s.priorDestructedAt(addr)
codeKey := blockstm.NewSubpathKey(addr, CodePath)
if val, writerIdx, writerInc, found := s.readCodeKey(addr, codeKey); found {
s.recordStoreRead(codeKey, writerIdx, writerInc, val)
s.recordStoreReadEx(codeKey, writerIdx, writerInc, val, suicideIdx >= 0)
if writerIdx > suicideIdx {
return val.([]byte)
}
Expand Down Expand Up @@ -813,7 +829,7 @@ func (s *ParallelStateDB) GetCodeHash(addr common.Address) common.Hash {
// value when the writer is later invalidated.
codeKey := blockstm.NewSubpathKey(addr, CodePath)
if val, writerIdx, writerInc, found := s.readCodeKey(addr, codeKey); found {
s.recordStoreRead(codeKey, writerIdx, writerInc, val)
s.recordStoreReadEx(codeKey, writerIdx, writerInc, val, suicideIdx >= 0)
// Honor the code write only if it happened after the destruction
// (otherwise the destruction wiped it).
if writerIdx > suicideIdx {
Expand Down Expand Up @@ -895,7 +911,7 @@ func (s *ParallelStateDB) GetState(addr common.Address, key common.Hash) common.
suicideIdx := s.priorDestructedAt(addr)
stateKey := blockstm.NewStateKey(addr, key)
if val, writerIdx, writerInc, found := s.readStoreWait(stateKey); found {
s.recordStoreRead(stateKey, writerIdx, writerInc, val)
s.recordStoreReadEx(stateKey, writerIdx, writerInc, val, suicideIdx >= 0)
// Honor the slot write only if it landed AFTER the destruction.
// Otherwise the destruction wiped storage and recreation alone
// doesn't restore old slots.
Expand Down Expand Up @@ -928,7 +944,7 @@ func (s *ParallelStateDB) GetCommittedState(addr common.Address, key common.Hash
mvKey := blockstm.NewStateKey(addr, key)
var result common.Hash
if val, writerIdx, writerInc, found := s.readStoreWait(mvKey); found {
s.recordStoreRead(mvKey, writerIdx, writerInc, val)
s.recordStoreReadEx(mvKey, writerIdx, writerInc, val, suicideIdx >= 0)
if writerIdx > suicideIdx {
result = val.(common.Hash)
}
Expand Down
6 changes: 5 additions & 1 deletion core/state/parallel_statedb_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,11 @@ func storeReadMatches(rd *StoreReadDesc, curVal any, writer, inc int, found, isE
if writer == rd.WriterIdx && inc == rd.WriterInc {
return true
}
if found && rd.StoreVal != nil && valuesEqual(curVal, rd.StoreVal) {
// Value equality is unsound when the winning writer's position is
// load-bearing (rd.ExactWriter): a different writer with an equal value can
// still change the result, e.g. a reordered metamorphic CREATE2/SELFDESTRUCT
// that moves the code/storage/marker writer across the destruction boundary.
if !rd.ExactWriter && found && rd.StoreVal != nil && valuesEqual(curVal, rd.StoreVal) {
return true
}
if !found && rd.StoreVal == nil {
Expand Down
144 changes: 144 additions & 0 deletions core/v2_metamorphic_parity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package core

import (
"context"
"crypto/ecdsa"
"math/big"
"testing"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/blockstm"
"github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/state"
"github.com/ethereum/go-ethereum/core/tracing"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/core/vm"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/params"
"github.com/ethereum/go-ethereum/triedb"
"github.com/holiman/uint256"
)

// A metamorphic contract — created, destroyed, and conditionally re-created at
// the same CREATE2 address within one block — must produce the same state root
// under V2 BlockSTM as under serial execution. The conditional re-create makes a
// later EXTCODEHASH read's winning CodePath writer depend on a value an earlier
// (deliberately heavy) tx writes, so V2 speculates the re-create, then withdraws
// it on re-execution; validation must not accept the now-stale code read by value
// equality.
//
// Unlike the fuzzer's light-tx metamorphic seeds (which reproduce this only when
// the scheduler happens to interleave the speculative deploy ahead of the heavy
// tx — empirically a few percent of runs), the heavy gas-burn flag tx here forces
// that interleaving, so this is a reliable, deterministic CI regression guard.
// The loop hedges against the residual scheduling nondeterminism of BlockSTM.
func TestV2SerialParity_MetamorphicCreate2(t *testing.T) {

Check failure on line 35 in core/v2_metamorphic_parity_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=0xPolygon_bor&issues=AZ8A0CsVpqeD2E2GvtUI&open=AZ8A0CsVpqeD2E2GvtUI&pullRequest=2290
// f: factory + victim.
// empty calldata : CREATE2-deploy tgt then CALL it so tgt SELFDESTRUCTs in
// the same tx (true EIP-6780 deletion).
// calldata[0]==1 : STATICCALL the flag holder; if flag==0, CREATE2-redeploy tgt.
// calldata[0]==2 : f.slot0 = EXTCODEHASH(tgt) (the victim read)
f := common.HexToAddress("0x000000000000000000000000000000000000aaaa")
fCode := common.FromHex("36156100835760003560f81c806002146100e85750602060006000600061bbbb5afa506000511561002c57005b6060600053600260015360616002536000600353600d600453606060055360006006536039600753606060085360026009536060600a536000600b5360f3600c536033600d5360ff600e536000600f60006000f550005b6060600053600260015360616002536000600353600d600453606060055360006006536039600753606060085360026009536060600a536000600b5360f3600c536033600d5360ff600e536000600f60006000f560006000600060006000855af15050005b5073fd0b03f591d1562409b6137e14ff608420f4e9463f60005500")

// c: flag holder. Empty calldata returns slot0; non-empty gas-burns (so it
// finishes last) and sets slot0 = 1.
c := common.HexToAddress("0x000000000000000000000000000000000000bbbb")
cCode := common.FromHex("366100105760005460005260206000f35b620138805b600190038060155750600160005500")

cfg := *params.MergedTestChainConfig
signer := types.NewLondonSigner(cfg.ChainID)
blockCtx := vm.BlockContext{
CanTransfer: CanTransfer,
Transfer: Transfer,
GetHash: func(uint64) common.Hash { return common.Hash{} },
Coinbase: common.HexToAddress("0x00000000000000000000000000000000c0ffee00"),
GasLimit: 30_000_000,
BlockNumber: big.NewInt(1),
Time: 1,
BaseFee: big.NewInt(7),
Random: &common.Hash{},
}

buildRoot := func(tdb *triedb.Database, keys []*ecdsa.PrivateKey) common.Hash {
gen, _ := state.New(common.Hash{}, state.NewDatabase(tdb, nil))
gen.SetCode(f, fCode, tracing.CodeChangeUnspecified)
gen.SetCode(c, cCode, tracing.CodeChangeUnspecified)
for _, k := range keys {
gen.AddBalance(crypto.PubkeyToAddress(k.PublicKey), uint256.NewInt(1e18), tracing.BalanceChangeUnspecified)
}
root, _ := gen.Commit(0, false, false)
tdb.Commit(root, false)
return root
}

mkTxs := func(keys []*ecdsa.PrivateKey) ([]*types.Transaction, []*Message) {
mk := func(keyIdx int, to common.Address, data []byte) (*types.Transaction, *Message) {
tx, err := types.SignTx(types.NewTx(&types.DynamicFeeTx{
ChainID: cfg.ChainID, Nonce: 0, GasTipCap: big.NewInt(0),
GasFeeCap: big.NewInt(7), Gas: 5_000_000, To: &to, Value: big.NewInt(0), Data: data,
}), signer, keys[keyIdx])
if err != nil {
t.Fatal(err)
}
msg, err := TransactionToMessage(tx, signer, blockCtx.BaseFee)
if err != nil {
t.Fatal(err)
}
return tx, msg
}
// One sender per tx: a shared sender would re-execute every later tx on
// each re-exec (nonce/balance dependency), masking the bug.
txs := make([]*types.Transaction, 4)
msgs := make([]*Message, 4)
txs[0], msgs[0] = mk(0, f, nil) // create + selfdestruct tgt
txs[1], msgs[1] = mk(1, c, []byte{1}) // heavy: set flag = 1 (finishes last)
txs[2], msgs[2] = mk(2, f, []byte{1}) // redeploy tgt iff flag still reads 0
txs[3], msgs[3] = mk(3, f, []byte{2}) // victim: f.slot0 = EXTCODEHASH(tgt)
return txs, msgs
}

// BlockSTM scheduling is nondeterministic; iterate so a regression is caught
// with overwhelming probability rather than relying on a single schedule.
const iters = 8
for i := range iters {
memdb := rawdb.NewMemoryDatabase()
tdb := triedb.NewDatabase(memdb, triedb.HashDefaults)
keys := make([]*ecdsa.PrivateKey, 4)
for j := range keys {
keys[j], _ = crypto.GenerateKey()
}
root := buildRoot(tdb, keys)
txs, msgs := mkTxs(keys)

// Serial.
serialDB, _ := state.New(root, state.NewDatabase(tdb, nil))
gp := new(GasPool).AddGas(blockCtx.GasLimit)
var usedGas uint64
serialEVM := vm.NewEVM(blockCtx, serialDB, &cfg, vm.Config{})
for j, tx := range txs {
serialDB.SetTxContext(tx.Hash(), j)
if _, err := ApplyTransactionWithEVM(msgs[j], gp, serialDB, blockCtx.BlockNumber,
common.Hash{}, blockCtx.Time, tx, &usedGas, serialEVM); err != nil {
t.Fatalf("iter %d serial tx %d: %v", i, j, err)
}
}

// V2 BlockSTM.
v2DB, _ := state.New(root, state.NewDatabase(tdb, nil))
base := v2DB.Copy()
base.EnableConcurrentReads()
tasks := make([]V2Task, len(txs))
for j := range txs {
tasks[j] = V2Task{Index: j, Tx: txs[j], Msg: msgs[j]}
}
ExecuteV2BlockSTM(context.Background(), tasks, base,
blockstm.NewMVStore(), blockstm.NewMVBalanceStore(),
blockCtx, common.Hash{}, vm.Config{}, &cfg, blockCtx.GasLimit, 4, v2DB, nil)
v2DB.Finalise(true)

if s, v := serialDB.IntermediateRoot(true), v2DB.IntermediateRoot(true); s != v {
t.Fatalf("iter %d: V2 state root %s diverges from serial %s for metamorphic CREATE2 block", i, v.Hex(), s.Hex())
}
}
}
Loading
Loading