diff --git a/core/state/database_history.go b/core/state/database_history.go index fbf4ab5f9c70..b4a9de429884 100644 --- a/core/state/database_history.go +++ b/core/state/database_history.go @@ -55,19 +55,7 @@ func (r *historicStateReader) Account(addr common.Address) (*types.StateAccount, if account == nil { return nil, nil } - acct := &types.StateAccount{ - Nonce: account.Nonce, - Balance: account.Balance, - CodeHash: account.CodeHash, - Root: common.BytesToHash(account.Root), - } - if len(acct.CodeHash) == 0 { - acct.CodeHash = types.EmptyCodeHash.Bytes() - } - if acct.Root == (common.Hash{}) { - acct.Root = types.EmptyRootHash - } - return acct, nil + return slimAccountToStateAccount(account) } // Storage implements StateReader, retrieving the storage slot specified by the diff --git a/core/state/journal.go b/core/state/journal.go index a79bd7331a06..e350da5bafb6 100644 --- a/core/state/journal.go +++ b/core/state/journal.go @@ -24,6 +24,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" + qkccommon "github.com/ethereum/go-ethereum/qkc/common" "github.com/holiman/uint256" ) @@ -229,6 +230,14 @@ func (j *journal) accessListAddSlot(addr common.Address, slot common.Hash) { }) } +func (j *journal) mntBalanceChange(addr common.Address, prev *qkccommon.TokenBalances) { + var snap *qkccommon.TokenBalances + if prev != nil { + snap = prev.Copy() + } + j.append(mntBalanceChange{addr: addr, prev: snap}) +} + type ( // Changes to the account trie. createObjectChange struct { @@ -289,6 +298,12 @@ type ( account common.Address key, prevalue common.Hash } + + // mntBalanceChange records a snapshot of MNT balances for revert support. + mntBalanceChange struct { + addr common.Address + prev *qkccommon.TokenBalances + } ) func (ch createObjectChange) revert(s *StateDB) { @@ -352,6 +367,8 @@ func (ch touchChange) copy() journalEntry { } func (ch balanceChange) revert(s *StateDB) { + // pyquarkchain restores the previous value by writing it back into the + // balance map. Even when the previous value is zero, the token entry remains. s.getStateObject(ch.account).setBalance(ch.prev) } @@ -500,3 +517,29 @@ func (ch accessListAddSlotChange) copy() journalEntry { slot: ch.slot, } } + +func (ch mntBalanceChange) revert(s *StateDB) { + obj := s.getStateObject(ch.addr) + if obj != nil { + if ch.prev == nil { + obj.data.MntBalances = nil + } else { + obj.data.MntBalances = ch.prev.Copy() + } + } +} + +func (ch mntBalanceChange) dirtied() (common.Address, bool) { + return ch.addr, true +} + +func (ch mntBalanceChange) copy() journalEntry { + var prev *qkccommon.TokenBalances + if ch.prev != nil { + prev = ch.prev.Copy() + } + return mntBalanceChange{ + addr: ch.addr, + prev: prev, + } +} diff --git a/core/state/mnt_test.go b/core/state/mnt_test.go new file mode 100644 index 000000000000..23a03232121c --- /dev/null +++ b/core/state/mnt_test.go @@ -0,0 +1,306 @@ +// Copyright 2024 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package state + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/tracing" + "github.com/ethereum/go-ethereum/core/types" + qkccommon "github.com/ethereum/go-ethereum/qkc/common" + "github.com/ethereum/go-ethereum/triedb" + "github.com/holiman/uint256" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newMntTestStateDB(t *testing.T) *StateDB { + t.Helper() + db := triedb.NewDatabase(rawdb.NewMemoryDatabase(), nil) + s, err := New(common.Hash{}, NewDatabase(db, nil)) + require.NoError(t, err) + return s +} + +func TestMntBalanceBasic(t *testing.T) { + s := newMntTestStateDB(t) + addr := common.HexToAddress("0x1234") + s.CreateAccount(addr) + + const tokenID = uint64(100) + s.AddMntBalance(addr, uint256.NewInt(500), tokenID) + assert.Equal(t, uint256.NewInt(500), s.GetMntBalance(addr, tokenID)) + + s.SubMntBalance(addr, uint256.NewInt(200), tokenID) + assert.Equal(t, uint256.NewInt(300), s.GetMntBalance(addr, tokenID)) +} + +func TestSubMntBalanceRejectsUnderflow(t *testing.T) { + s := newMntTestStateDB(t) + addr := common.HexToAddress("0x1235") + + s.SubMntBalance(addr, uint256.NewInt(1), 100) + + assert.False(t, s.Exist(addr), "underflow must not create an account") + assert.True(t, s.GetMntBalance(addr, 100).IsZero()) +} + +func TestAddMntBalanceRejectsOverflow(t *testing.T) { + s := newMntTestStateDB(t) + addr := common.HexToAddress("0x1236") + max := new(uint256.Int).Not(new(uint256.Int)) + s.SetMntBalance(addr, max, 100) + dirtyCount := s.journal.dirties[addr] + + s.AddMntBalance(addr, uint256.NewInt(1), 100) + + assert.Equal(t, max, s.GetMntBalance(addr, 100)) + assert.Equal(t, dirtyCount, s.journal.dirties[addr], "overflow must not add a dirty journal entry") +} + +func TestMntRejectsQKCTokenID(t *testing.T) { + s := newMntTestStateDB(t) + addr := common.HexToAddress("0x5678") + + // SetMntBalance with QKC tokenID (35760) must be a no-op + s.SetMntBalance(addr, uint256.NewInt(999), qkccommon.DefaultTokenID) + assert.True(t, s.GetMntBalance(addr, qkccommon.DefaultTokenID).IsZero()) + assert.True(t, s.GetBalance(addr).IsZero()) // QKC balance unchanged + assert.False(t, s.Exist(addr), "rejected update must not create an account") +} + +func TestMntRejectsTokenAboveListLimit(t *testing.T) { + s := newMntTestStateDB(t) + addr := common.HexToAddress("0x5679") + s.CreateAccount(addr) + + for tokenID := uint64(1); tokenID <= qkccommon.TokenTrieThreshold; tokenID++ { + s.SetMntBalance(addr, uint256.NewInt(tokenID), tokenID) + } + dirtyCount := s.journal.dirties[addr] + s.SetMntBalance(addr, uint256.NewInt(17), qkccommon.TokenTrieThreshold+1) + + assert.True(t, s.GetMntBalance(addr, qkccommon.TokenTrieThreshold+1).IsZero()) + assert.Equal(t, dirtyCount, s.journal.dirties[addr], "rejected update must not add a dirty journal entry") + _, err := s.Commit(0, false, false) + require.NoError(t, err) +} + +func TestMntTokenLimitIncludesQKC(t *testing.T) { + s := newMntTestStateDB(t) + addr := common.HexToAddress("0x5680") + s.CreateAccount(addr) + s.SetBalance(addr, uint256.NewInt(1), tracing.BalanceChangeUnspecified) + + for tokenID := uint64(1); tokenID < qkccommon.TokenTrieThreshold; tokenID++ { + s.SetMntBalance(addr, uint256.NewInt(tokenID), tokenID) + } + s.SetMntBalance(addr, uint256.NewInt(16), qkccommon.TokenTrieThreshold) + + assert.True(t, s.GetMntBalance(addr, qkccommon.TokenTrieThreshold).IsZero()) + _, err := s.Commit(0, false, false) + require.NoError(t, err) +} + +func TestQKCBalanceRejectsTokenAboveListLimit(t *testing.T) { + s := newMntTestStateDB(t) + addr := common.HexToAddress("0x5683") + s.CreateAccount(addr) + + for tokenID := uint64(1); tokenID <= qkccommon.TokenTrieThreshold; tokenID++ { + s.SetMntBalance(addr, uint256.NewInt(tokenID), tokenID) + } + dirtyCount := s.journal.dirties[addr] + s.SetBalance(addr, uint256.NewInt(1), tracing.BalanceChangeUnspecified) + + assert.True(t, s.GetBalance(addr).IsZero()) + assert.Equal(t, dirtyCount, s.journal.dirties[addr], "rejected update must not add a dirty journal entry") + _, err := s.Commit(0, false, false) + require.NoError(t, err) +} + +func TestMntTokenLimitIgnoresZeroEntries(t *testing.T) { + s := newMntTestStateDB(t) + addr := common.HexToAddress("0x5681") + s.CreateAccount(addr) + + for tokenID := uint64(1); tokenID <= qkccommon.TokenTrieThreshold; tokenID++ { + s.SetMntBalance(addr, uint256.NewInt(tokenID), tokenID) + } + s.SetMntBalance(addr, new(uint256.Int), 1) + s.SetMntBalance(addr, uint256.NewInt(17), qkccommon.TokenTrieThreshold+1) + + assert.Equal(t, uint256.NewInt(17), s.GetMntBalance(addr, qkccommon.TokenTrieThreshold+1)) + _, err := s.Commit(0, false, false) + require.NoError(t, err) +} + +func TestMntTokenLimitAllowsExistingTokenUpdates(t *testing.T) { + s := newMntTestStateDB(t) + addr := common.HexToAddress("0x5682") + s.CreateAccount(addr) + + for tokenID := uint64(1); tokenID <= qkccommon.TokenTrieThreshold; tokenID++ { + s.SetMntBalance(addr, uint256.NewInt(tokenID), tokenID) + } + s.SetMntBalance(addr, uint256.NewInt(1000), 1) + s.SetMntBalance(addr, new(uint256.Int), 2) + + assert.Equal(t, uint256.NewInt(1000), s.GetMntBalance(addr, 1)) + assert.True(t, s.GetMntBalance(addr, 2).IsZero()) + _, err := s.Commit(0, false, false) + require.NoError(t, err) +} + +func TestMntJournalRevert(t *testing.T) { + s := newMntTestStateDB(t) + addr := common.HexToAddress("0xABCD") + s.CreateAccount(addr) + + const tokenID = uint64(200) + snap := s.Snapshot() + s.AddMntBalance(addr, uint256.NewInt(1000), tokenID) + assert.Equal(t, uint256.NewInt(1000), s.GetMntBalance(addr, tokenID)) + + s.RevertToSnapshot(snap) + assert.True(t, s.GetMntBalance(addr, tokenID).IsZero(), "revert should clear MNT balance") +} + +func TestEncodeDecodeRoundTrip(t *testing.T) { + s := newMntTestStateDB(t) + addr := common.HexToAddress("0x2222") + s.CreateAccount(addr) + s.AddBalance(addr, uint256.NewInt(1e18), tracing.BalanceChangeUnspecified) // QKC balance + s.AddMntBalance(addr, uint256.NewInt(500), uint64(100)) // MNT token + + root, err := s.Commit(0, false, false) + require.NoError(t, err) + + // Re-open state at the committed root and verify balances survive + s2, err := New(root, s.Database()) + require.NoError(t, err) + + assert.Equal(t, uint256.NewInt(1e18), s2.GetBalance(addr), "QKC balance") + assert.Equal(t, uint256.NewInt(500), s2.GetMntBalance(addr, 100), "MNT balance") +} + +func TestSlimAccountToStateAccountPreservesQKCFields(t *testing.T) { + mnt := qkccommon.NewTokenBalancesWithMap(map[uint64]*uint256.Int{ + 100: uint256.NewInt(500), + }) + mntBal, err := mnt.SerializeToBytes() + require.NoError(t, err) + + acct, err := slimAccountToStateAccount(&types.SlimAccount{ + Balance: uint256.NewInt(1000), + FullShardKey: 0x12345678, + MntBal: mntBal, + }) + require.NoError(t, err) + assert.Equal(t, uint256.NewInt(1000), acct.Balance) + assert.Equal(t, uint32(0x12345678), acct.FullShardKey) + require.NotNil(t, acct.MntBalances) + assert.Equal(t, uint256.NewInt(500), acct.MntBalances.GetTokenBalance(100)) + assert.Equal(t, types.EmptyRootHash, acct.Root) + assert.Equal(t, types.EmptyCodeHash.Bytes(), acct.CodeHash) +} + +// TestEmptyAccountWithMntNotPruned guards the EIP-158 divergence: an account +// with nonce==0 / QKC==0 / MNT!=0 / no code must NOT be treated as empty, since +// pyquarkchain's is_blank spans all tokens and keeps it. Pruning it here would +// diverge the state root. Removing the MNT balance must flip it back to empty. +func TestEmptyAccountWithMntNotPruned(t *testing.T) { + s := newMntTestStateDB(t) + addr := common.HexToAddress("0xBEEF") + s.CreateAccount(addr) + + const tokenID = uint64(100) + s.AddMntBalance(addr, uint256.NewInt(500), tokenID) + + // nonce==0, QKC==0, MNT!=0, no code → not empty. + require.True(t, s.GetBalance(addr).IsZero(), "QKC balance must be zero for this case") + require.Zero(t, s.GetNonce(addr), "nonce must be zero for this case") + assert.False(t, s.Empty(addr), "account with non-zero MNT balance must not be empty") + + // Finalise with deleteEmptyObjects=true must keep the account. + s.Finalise(true) + assert.True(t, s.Exist(addr), "MNT-only account must survive empty-object pruning") + assert.Equal(t, uint256.NewInt(500), s.GetMntBalance(addr, tokenID), "MNT balance must survive") + + // Draining the last MNT balance makes the account empty/prunable again. + s.SubMntBalance(addr, uint256.NewInt(500), tokenID) + assert.True(t, s.Empty(addr), "account with no QKC, no MNT, no code, nonce 0 must be empty") +} + +// TestCopyDoesNotAliasMntBalances guards the deepCopy aliasing bug: StateDB.Copy() +// must deep-copy the MntBalances map, or a mutation on the copy corrupts the +// original (and vice versa), diverging the state root. +func TestCopyDoesNotAliasMntBalances(t *testing.T) { + s := newMntTestStateDB(t) + addr := common.HexToAddress("0xA11A5") + s.CreateAccount(addr) + const tokenID = uint64(777) + s.AddMntBalance(addr, uint256.NewInt(1000), tokenID) + + cp := s.Copy() + // Mutate the copy; the original must be unaffected. + cp.AddMntBalance(addr, uint256.NewInt(500), tokenID) + + assert.Equal(t, uint256.NewInt(1000), s.GetMntBalance(addr, tokenID), "original must not see copy's MNT mutation") + assert.Equal(t, uint256.NewInt(1500), cp.GetMntBalance(addr, tokenID), "copy must reflect its own mutation") + + // And the reverse direction. + s.AddMntBalance(addr, uint256.NewInt(1), tokenID) + assert.Equal(t, uint256.NewInt(1001), s.GetMntBalance(addr, tokenID), "original reflects its own mutation") + assert.Equal(t, uint256.NewInt(1500), cp.GetMntBalance(addr, tokenID), "copy must not see original's later mutation") +} + +// TestLoadedObjectDoesNotAliasOriginMnt verifies that when a state object is +// loaded from an existing account (newObject with a non-nil origin holding MNT +// balances), mutating the MNT balance does not corrupt s.origin. Without the +// deep-copy in newObject, SetValue mutates the map shared by data and origin, +// so commit() would record the post-mutation balance as the rollback baseline. +func TestLoadedObjectDoesNotAliasOriginMnt(t *testing.T) { + db := triedb.NewDatabase(rawdb.NewMemoryDatabase(), nil) + sdb := NewDatabase(db, nil) + s, err := New(common.Hash{}, sdb) + require.NoError(t, err) + + addr := common.HexToAddress("0xB0B") + const tokenID = uint64(888) + s.CreateAccount(addr) + s.AddMntBalance(addr, uint256.NewInt(1000), tokenID) + root, err := s.Commit(0, false, false) + require.NoError(t, err) + + // Reload from the committed state so the object is built via newObject with + // a non-nil origin carrying MntBalances. + s2, err := New(root, sdb) + require.NoError(t, err) + + obj := s2.getStateObject(addr) + require.NotNil(t, obj) + require.NotNil(t, obj.origin) + + s2.AddMntBalance(addr, uint256.NewInt(500), tokenID) + + // The mutation must land on data, not on origin. + assert.Equal(t, uint256.NewInt(1500), obj.GetMntBalance(tokenID), "live data reflects the mutation") + assert.Equal(t, uint256.NewInt(1000), obj.origin.MntBalances.GetTokenBalance(tokenID), "origin must retain the pre-mutation balance") +} diff --git a/core/state/reader.go b/core/state/reader.go index be07cec0f97f..5b313b4a29fc 100644 --- a/core/state/reader.go +++ b/core/state/reader.go @@ -25,6 +25,7 @@ import ( "github.com/ethereum/go-ethereum/core/overlay" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" + qkccommon "github.com/ethereum/go-ethereum/qkc/common" "github.com/ethereum/go-ethereum/rlp" "github.com/ethereum/go-ethereum/trie" "github.com/ethereum/go-ethereum/trie/bintrie" @@ -105,11 +106,23 @@ func (r *flatReader) Account(addr common.Address) (*types.StateAccount, error) { if account == nil { return nil, nil } + return slimAccountToStateAccount(account) +} + +func slimAccountToStateAccount(account *types.SlimAccount) (*types.StateAccount, error) { acct := &types.StateAccount{ - Nonce: account.Nonce, - Balance: account.Balance, - CodeHash: account.CodeHash, - Root: common.BytesToHash(account.Root), + Nonce: account.Nonce, + Balance: account.Balance, + CodeHash: account.CodeHash, + Root: common.BytesToHash(account.Root), + FullShardKey: account.FullShardKey, + } + if len(account.MntBal) > 0 { + mnt, err := qkccommon.NewTokenBalances(account.MntBal) + if err != nil { + return nil, err + } + acct.MntBalances = mnt } if len(acct.CodeHash) == 0 { acct.CodeHash = types.EmptyCodeHash.Bytes() diff --git a/core/state/state_object.go b/core/state/state_object.go index 8e72486825e2..fd6043ff3fa2 100644 --- a/core/state/state_object.go +++ b/core/state/state_object.go @@ -27,6 +27,7 @@ import ( "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/log" + qkccommon "github.com/ethereum/go-ethereum/qkc/common" "github.com/ethereum/go-ethereum/trie" "github.com/ethereum/go-ethereum/trie/bintrie" "github.com/ethereum/go-ethereum/trie/transitiontrie" @@ -88,8 +89,14 @@ type stateObject struct { } // empty returns whether the account is considered empty. +// +// The QKC fork also requires the account to hold no MNT (non-QKC) token +// balances (IsBlankMnt). This mirrors pyquarkchain's _Account.is_blank, whose +// token_balances.is_blank() spans every token; checking only the QKC balance +// would prune nonce0/QKC0/MNT-nonzero/no-code accounts that pyquarkchain keeps, +// diverging the state root. See state_object_qkc.go. func (s *stateObject) empty() bool { - return s.data.Nonce == 0 && s.data.Balance.IsZero() && bytes.Equal(s.data.CodeHash, types.EmptyCodeHash.Bytes()) + return s.data.Nonce == 0 && s.data.Balance.IsZero() && s.IsBlankMnt() && bytes.Equal(s.data.CodeHash, types.EmptyCodeHash.Bytes()) } // newObject creates a state object. @@ -98,7 +105,7 @@ func newObject(db *StateDB, address common.Address, acct *types.StateAccount) *s if acct == nil { acct = types.NewEmptyStateAccount() } - return &stateObject{ + obj := &stateObject{ db: db, address: address, origin: origin, @@ -108,6 +115,16 @@ func newObject(db *StateDB, address common.Address, acct *types.StateAccount) *s pendingStorage: make(Storage), uncommittedStorage: make(Storage), } + // data is a shallow value copy of *acct, so data.MntBalances aliases the + // same map as origin.MntBalances. SetMntBalance mutates that map in place + // (TokenBalances.SetValue), so without this copy an MNT mutation would also + // rewrite s.origin, and commit() would record the post-mutation balance as + // the "origin" — corrupting the pathdb rollback baseline. Deep-copy so data + // and origin own independent maps. Mirrors the deepCopy() guard below. + if origin != nil && origin.MntBalances != nil { + obj.data.MntBalances = origin.MntBalances.Copy() + } + return obj } func (s *stateObject) addrHash() common.Hash { @@ -490,7 +507,15 @@ func (s *stateObject) AddBalance(amount *uint256.Int) uint256.Int { // SetBalance sets the balance for the object, and returns the previous balance. func (s *stateObject) SetBalance(amount *uint256.Int) uint256.Int { prev := *s.data.Balance + if s.data.Balance.IsZero() && amount != nil && !amount.IsZero() && s.nonZeroMntBalanceCount() >= qkccommon.TokenTrieThreshold { + log.Error("SetBalance exceeds supported token limit", "addr", s.address, "limit", qkccommon.TokenTrieThreshold) + return prev + } + if s.data.Balance.Eq(amount) { + return prev + } s.db.journal.balanceChange(s.address, s.data.Balance) + s.data.MarkBalanceUpdated() s.setBalance(amount) return prev } @@ -515,6 +540,14 @@ func (s *stateObject) deepCopy(db *StateDB) *stateObject { selfDestructed: s.selfDestructed, newContract: s.newContract, } + // data above is a shallow value copy of StateAccount; MntBalances is a + // pointer whose underlying map is mutated in place by SetMntBalance, so it + // must be deep-copied. Otherwise the copy and the original alias the same + // balances map and an MNT mutation on one StateDB silently corrupts the + // other (e.g. after StateDB.Copy()), diverging the state root. + if s.data.MntBalances != nil { + obj.data.MntBalances = s.data.MntBalances.Copy() + } switch s.trie.(type) { case *bintrie.BinaryTrie: diff --git a/core/state/state_object_qkc.go b/core/state/state_object_qkc.go new file mode 100644 index 000000000000..6888aa3c5745 --- /dev/null +++ b/core/state/state_object_qkc.go @@ -0,0 +1,109 @@ +// Copyright 2026-2027, QuarkChain. +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package state + +import ( + "github.com/ethereum/go-ethereum/log" + qkccommon "github.com/ethereum/go-ethereum/qkc/common" + "github.com/holiman/uint256" +) + +func (s *stateObject) SetMntBalance(amount *uint256.Int, tokenID uint64) { + if !s.canSetMntBalance(amount, tokenID) { + return + } + s.setMntBalance(amount, tokenID) +} + +func (s *stateObject) canSetMntBalance(amount *uint256.Int, tokenID uint64) bool { + if tokenID == qkccommon.DefaultTokenID { + log.Error("SetMntBalance called with QKC tokenID; use SetBalance", "addr", s.address) + return false + } + if amount != nil && !amount.IsZero() && s.GetMntBalance(tokenID).IsZero() { + cnt := s.nonZeroMntBalanceCount() + if !s.Balance().IsZero() { + cnt++ + } + if cnt >= qkccommon.TokenTrieThreshold { + log.Error("SetMntBalance exceeds supported token limit", "addr", s.address, "limit", qkccommon.TokenTrieThreshold) + return false + } + } + return true +} + +func (s *stateObject) nonZeroMntBalanceCount() int { + if s.data.MntBalances == nil { + return 0 + } + cnt := 0 + for _, balance := range s.data.MntBalances.GetBalanceMap() { + if !balance.IsZero() { + cnt++ + } + } + return cnt +} + +func (s *stateObject) setMntBalance(amount *uint256.Int, tokenID uint64) { + if s.data.MntBalances == nil { + s.data.MntBalances = qkccommon.NewEmptyTokenBalances() + } + s.data.MntBalances.SetValue(amount, tokenID) +} + +func (s *stateObject) AddMntBalance(amount *uint256.Int, tokenID uint64) { + if amount.IsZero() { + return + } + if tokenID == qkccommon.DefaultTokenID { + log.Error("AddMntBalance called with QKC tokenID; use AddBalance", "addr", s.address) + return + } + cur := s.GetMntBalance(tokenID) + s.SetMntBalance(new(uint256.Int).Add(cur, amount), tokenID) +} + +func (s *stateObject) SubMntBalance(amount *uint256.Int, tokenID uint64) { + if amount.IsZero() { + return + } + if tokenID == qkccommon.DefaultTokenID { + log.Error("SubMntBalance called with QKC tokenID; use SubBalance", "addr", s.address) + return + } + cur := s.GetMntBalance(tokenID) + s.SetMntBalance(new(uint256.Int).Sub(cur, amount), tokenID) +} + +func (s *stateObject) GetMntBalance(tokenID uint64) *uint256.Int { + if s.data.MntBalances == nil { + return new(uint256.Int) + } + return s.data.MntBalances.GetTokenBalance(tokenID) +} + +// IsBlankMnt reports whether the account holds no non-QKC (MNT) token balances. +// It is consumed by empty() so that the EIP-158 empty-account check spans every +// token, matching pyquarkchain's _Account.is_blank (which evaluates +// token_balances.is_blank() across all tokens). Without this, an account with +// nonce==0 / QKC==0 / MNT!=0 / no code would be pruned here but kept by +// pyquarkchain, producing a divergent state root. +func (s *stateObject) IsBlankMnt() bool { + return s.data.MntBalances == nil || s.data.MntBalances.IsBlank() +} diff --git a/core/state/state_test.go b/core/state/state_test.go index eeeb7fa2df87..2b36c64d1e2c 100644 --- a/core/state/state_test.go +++ b/core/state/state_test.go @@ -25,6 +25,7 @@ import ( "github.com/ethereum/go-ethereum/core/rawdb" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rlp" "github.com/ethereum/go-ethereum/triedb" "github.com/holiman/uint256" ) @@ -60,7 +61,7 @@ func TestDump(t *testing.T) { s.state, _ = New(root, tdb) got := string(s.state.Dump(nil)) want := `{ - "root": "71edff0130dd2385947095001c73d9e28d862fc286fca2b922ca6f6f3cddfdd2", + "root": "c35a032efd1d99e0348bc4e29ff402133c643fb3c4550e377ab351ca57de387a", "accounts": { "0x0000000000000000000000000000000000000001": { "balance": "22", @@ -119,7 +120,7 @@ func TestIterativeDump(t *testing.T) { s.state.IterativeDump(nil, json.NewEncoder(b)) // check that DumpToCollector contains the state objects that are in trie got := b.String() - want := `{"root":"0xd5710ea8166b7b04bc2bfb129d7db12931cee82f75ca8e2d075b4884322bf3de"} + want := `{"root":"0x7b190b66d76c5b2d5bd1bf02f1918437d910a2e825a16465cf57d1bd1c2433fe"} {"balance":"22","nonce":0,"root":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","codeHash":"0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470","address":"0x0000000000000000000000000000000000000001","key":"0x1468288056310c82aa4c01a7e12a10f8111a0560e72b700555479031b86c357d"} {"balance":"1337","nonce":0,"root":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","codeHash":"0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470","address":"0x0000000000000000000000000000000000000000","key":"0x5380c7b7ae81a58eb98d9c78de4a1fd7fd9535fc953ed2be602daaa41767312a"} {"balance":"0","nonce":0,"root":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","codeHash":"0x87874902497a5bb968da31a2998d8f22e949d1ef6214bcdedd8bae24cca4b9e3","code":"0x03030303030303","address":"0x0000000000000000000000000000000000000102","key":"0xa17eacbc25cda025e81db9c5c62868822c73ce097cee2a63e33a2e41268358a1"} @@ -188,6 +189,84 @@ func TestSnapshotEmpty(t *testing.T) { s.state.RevertToSnapshot(s.state.Snapshot()) } +func TestBalanceChangeTracksQKCPresence(t *testing.T) { + state, _ := New(types.EmptyRootHash, NewDatabaseForTesting()) + addr := common.BytesToAddress([]byte("qkc-presence")) + obj := state.getOrNewStateObject(addr) + + obj.SetBalance(new(uint256.Int)) + if obj.data.IsBalanceUpdated() { + t.Fatal("unchanged zero balance created a QKC presence marker") + } + assertAccountTokenBalance(t, &obj.data, nil) + + snapshot := state.Snapshot() + obj.SetBalance(uint256.NewInt(1000)) + if !obj.data.IsBalanceUpdated() { + t.Fatal("balance change did not create a QKC presence marker") + } + obj.SetBalance(new(uint256.Int)) + if !obj.data.IsBalanceUpdated() { + t.Fatal("draining QKC balance removed its presence marker") + } + assertAccountTokenBalance(t, &obj.data, []byte{0x00, 0xc0}) + nestedSnapshot := state.Snapshot() + obj.SetBalance(uint256.NewInt(10)) + state.RevertToSnapshot(nestedSnapshot) + if !obj.data.IsBalanceUpdated() { + t.Fatal("reverting a later balance change removed earlier update history") + } + assertAccountTokenBalance(t, &obj.data, []byte{0x00, 0xc0}) + + state.RevertToSnapshot(snapshot) + obj = state.getStateObject(addr) + if !obj.data.IsBalanceUpdated() { + t.Fatal("snapshot revert removed the QKC presence marker") + } + if !obj.Balance().IsZero() { + t.Fatal("snapshot revert did not restore the zero QKC balance") + } + assertAccountTokenBalance(t, &obj.data, []byte{0x00, 0xc0}) +} + +func TestBalanceChangeRevertKeepsExistingQKCPresence(t *testing.T) { + state, _ := New(types.EmptyRootHash, NewDatabaseForTesting()) + addr := common.BytesToAddress([]byte("qkc-existing")) + obj := state.getOrNewStateObject(addr) + obj.SetBalance(uint256.NewInt(100)) + obj.SetBalance(new(uint256.Int)) + state.Finalise(false) + if !obj.data.IsBalanceUpdated() { + t.Fatal("finalising state removed an existing QKC balance update") + } + + snapshot := state.Snapshot() + obj.SetBalance(uint256.NewInt(1000)) + state.RevertToSnapshot(snapshot) + if !state.getStateObject(addr).data.IsBalanceUpdated() { + t.Fatal("snapshot revert removed an existing QKC presence marker") + } +} + +func assertAccountTokenBalance(t *testing.T, account *types.StateAccount, want []byte) { + t.Helper() + encoded, err := rlp.EncodeToBytes(account) + if err != nil { + t.Fatalf("failed to encode account: %v", err) + } + var fields []rlp.RawValue + if err := rlp.DecodeBytes(encoded, &fields); err != nil { + t.Fatalf("failed to decode account fields: %v", err) + } + var tokenBalance []byte + if err := rlp.DecodeBytes(fields[1], &tokenBalance); err != nil { + t.Fatalf("failed to decode token balance: %v", err) + } + if !bytes.Equal(tokenBalance, want) { + t.Fatalf("unexpected token balance encoding: have %x, want %x", tokenBalance, want) + } +} + func TestCreateObjectRevert(t *testing.T) { state, _ := New(types.EmptyRootHash, NewDatabaseForTesting()) addr := common.BytesToAddress([]byte("so0")) diff --git a/core/state/statedb.go b/core/state/statedb.go index e6d8b5bffc94..0addc5618c82 100644 --- a/core/state/statedb.go +++ b/core/state/statedb.go @@ -137,6 +137,11 @@ type StateDB struct { // Snapshot and RevertToSnapshot. journal *journal + // fullShardKey is the QuarkChain shard key of the current transaction's + // destination. It is set once per transaction via SetFullShardKey and + // assigned to any newly created accounts that have no prior state. + fullShardKey uint32 + // State witness if cross validation is needed witness *stateless.Witness @@ -292,6 +297,13 @@ func (s *StateDB) Preimages() map[common.Hash][]byte { return s.preimages } +// SetFullShardKey sets the QuarkChain shard key for the current transaction +// context. It is called once per transaction before account operations, and +// any newly created accounts (with no prior state) inherit this value. +func (s *StateDB) SetFullShardKey(fullShardKey uint32) { + s.fullShardKey = fullShardKey +} + // AddRefund adds gas to the refund counter func (s *StateDB) AddRefund(gas uint64) { s.journal.refundChange(s.refund) @@ -638,10 +650,23 @@ func (s *StateDB) getOrNewStateObject(addr common.Address) *stateObject { return obj } -// createObject creates a new state object. The assumption is held there is no -// existing account with the given address, otherwise it will be silently overwritten. +// createObject creates a new state object, replacing any object currently live +// at the address. The prior object's storage/balance/nonce are dropped (the +// caller is responsible for having deleted them where required), but its +// QuarkChain FullShardKey is preserved: the shard key is assigned on first +// creation and must remain stable across a resurrection, so it is carried over +// from the existing account rather than reset to the current transaction's key. func (s *StateDB) createObject(addr common.Address) *stateObject { + // Check for an existing account so we can preserve its FullShardKey. + prev := s.getStateObject(addr) + obj := newObject(s, addr, nil) + // New accounts inherit the current transaction's destination shard key. + obj.data.FullShardKey = s.fullShardKey + // If the account previously existed, keep its original shard key unchanged. + if prev != nil { + obj.data.FullShardKey = prev.data.FullShardKey + } s.journal.createObject(addr) s.setStateObject(obj) return obj @@ -696,6 +721,7 @@ func (s *StateDB) Copy() *StateDB { logs: make(map[common.Hash][]*types.Log, len(s.logs)), logSize: s.logSize, preimages: maps.Clone(s.preimages), + fullShardKey: s.fullShardKey, // Do we need to copy the access list and transient storage? // In practice: No. At the start of a transaction, these two lists are empty. diff --git a/core/state/statedb_fuzz_test.go b/core/state/statedb_fuzz_test.go index c796b416a32b..91b741f6a6b1 100644 --- a/core/state/statedb_fuzz_test.go +++ b/core/state/statedb_fuzz_test.go @@ -276,19 +276,21 @@ func (test *stateTest) verifyAccountCreation(next common.Hash, db *triedb.Databa if len(nBlob) == 0 { return fmt.Errorf("missing account in new trie, %x", addrHash) } - full, err := types.FullAccountRLP(account) + // Decode the expected account from slim-RLP and the trie account from QKC format, + // then compare logical fields (not raw bytes, since QKC adds extra fields). + wantAcct, err := types.FullAccount(account) if err != nil { return err } - if !bytes.Equal(nBlob, full) { - return fmt.Errorf("unexpected account data, want: %v, got: %v", full, nBlob) + nAcct := new(types.StateAccount) + if err := rlp.DecodeBytes(nBlob, nAcct); err != nil { + return fmt.Errorf("unexpected account data, want: %v, got: %v", wantAcct, nBlob) } - - // Verify storage changes - var nAcct types.StateAccount - if err := rlp.DecodeBytes(nBlob, &nAcct); err != nil { - return err + if wantAcct.Nonce != nAcct.Nonce || wantAcct.Balance.Cmp(nAcct.Balance) != 0 || + wantAcct.Root != nAcct.Root || !bytes.Equal(wantAcct.CodeHash, nAcct.CodeHash) { + return fmt.Errorf("unexpected account data, want: %v, got: %v", wantAcct, nAcct) } + // Account has no slot, empty slot set is expected if nAcct.Root == types.EmptyRootHash { if len(storagesOrigin) != 0 { @@ -347,37 +349,39 @@ func (test *stateTest) verifyAccountUpdate(next common.Hash, db *triedb.Database if len(oBlob) == 0 { return fmt.Errorf("missing account in old trie, %x", addrHash) } - full, err := types.FullAccountRLP(accountOrigin) + // Decode old and new trie blobs using QKC-aware decoder. + // Compare logical fields against the slim-RLP account from the diff, + // not raw bytes (QKC format adds extra fields). + wantOriginAcct, err := types.FullAccount(accountOrigin) if err != nil { return err } - if !bytes.Equal(full, oBlob) { + oAcct := new(types.StateAccount) + if err := rlp.DecodeBytes(oBlob, oAcct); err != nil { + return fmt.Errorf("failed to decode old trie account %x: %v", addrHash, err) + } + if wantOriginAcct.Nonce != oAcct.Nonce || wantOriginAcct.Balance.Cmp(oAcct.Balance) != 0 || + wantOriginAcct.Root != oAcct.Root || !bytes.Equal(wantOriginAcct.CodeHash, oAcct.CodeHash) { return fmt.Errorf("account value is not matched, %x", addrHash) } + var nRoot common.Hash if len(nBlob) == 0 { if len(account) != 0 { return errors.New("unexpected account data") } - } else { - full, _ = types.FullAccountRLP(account) - if !bytes.Equal(full, nBlob) { - return fmt.Errorf("unexpected account data, %x, want %v, got: %v", addrHash, full, nBlob) - } - } - // Decode accounts - var ( - oAcct types.StateAccount - nAcct types.StateAccount - nRoot common.Hash - ) - if err := rlp.DecodeBytes(oBlob, &oAcct); err != nil { - return err - } - if len(nBlob) == 0 { nRoot = types.EmptyRootHash } else { - if err := rlp.DecodeBytes(nBlob, &nAcct); err != nil { - return err + wantAcct, wantErr := types.FullAccount(account) + if wantErr != nil { + return wantErr + } + nAcct := new(types.StateAccount) + if decErr := rlp.DecodeBytes(nBlob, nAcct); decErr != nil { + return fmt.Errorf("failed to decode new trie account %x: %v", addrHash, decErr) + } + if wantAcct.Nonce != nAcct.Nonce || wantAcct.Balance.Cmp(nAcct.Balance) != 0 || + wantAcct.Root != nAcct.Root || !bytes.Equal(wantAcct.CodeHash, nAcct.CodeHash) { + return fmt.Errorf("unexpected account data, %x, want %v, got: %v", addrHash, wantAcct, nAcct) } nRoot = nAcct.Root } diff --git a/core/state/statedb_hooked.go b/core/state/statedb_hooked.go index c5faa7c98eb8..f0e0e277441c 100644 --- a/core/state/statedb_hooked.go +++ b/core/state/statedb_hooked.go @@ -63,6 +63,18 @@ func (s *hookedStateDB) GetBalance(addr common.Address) *uint256.Int { return s.inner.GetBalance(addr) } +func (s *hookedStateDB) GetMntBalance(addr common.Address, tokenID uint64) *uint256.Int { + return s.inner.GetMntBalance(addr, tokenID) +} + +func (s *hookedStateDB) AddMntBalance(addr common.Address, amount *uint256.Int, tokenID uint64) { + s.inner.AddMntBalance(addr, amount, tokenID) +} + +func (s *hookedStateDB) SubMntBalance(addr common.Address, amount *uint256.Int, tokenID uint64) { + s.inner.SubMntBalance(addr, amount, tokenID) +} + func (s *hookedStateDB) GetNonce(addr common.Address) uint64 { return s.inner.GetNonce(addr) } diff --git a/core/state/statedb_qkc.go b/core/state/statedb_qkc.go new file mode 100644 index 000000000000..ad3b0041db3c --- /dev/null +++ b/core/state/statedb_qkc.go @@ -0,0 +1,85 @@ +// Copyright 2026-2027, QuarkChain. +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package state + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/tracing" + qkccommon "github.com/ethereum/go-ethereum/qkc/common" + "github.com/holiman/uint256" +) + +// ===== StateDB MNT methods ===== + +func (s *StateDB) SetMntBalance(addr common.Address, amount *uint256.Int, tokenID uint64) { + if tokenID == qkccommon.DefaultTokenID { + return + } + obj := s.getOrNewStateObject(addr) + if obj == nil || !obj.canSetMntBalance(amount, tokenID) { + return + } + s.journal.mntBalanceChange(addr, obj.data.MntBalances) + obj.setMntBalance(amount, tokenID) +} + +func (s *StateDB) AddMntBalance(addr common.Address, amount *uint256.Int, tokenID uint64) { + if amount.IsZero() { + return + } + updated, overflow := new(uint256.Int).AddOverflow(s.GetMntBalance(addr, tokenID), amount) + if overflow { + return + } + s.SetMntBalance(addr, updated, tokenID) +} + +func (s *StateDB) SubMntBalance(addr common.Address, amount *uint256.Int, tokenID uint64) { + if amount.IsZero() { + return + } + updated, underflow := new(uint256.Int).SubOverflow(s.GetMntBalance(addr, tokenID), amount) + if underflow { + return + } + s.SetMntBalance(addr, updated, tokenID) +} + +func (s *StateDB) GetMntBalance(addr common.Address, tokenID uint64) *uint256.Int { + obj := s.getStateObject(addr) + if obj == nil { + return new(uint256.Int) + } + return obj.GetMntBalance(tokenID) +} + +// SubBalanceByTokenID subtracts QKC or MNT balance based on tokenID. +func (s *StateDB) SubBalanceByTokenID(addr common.Address, amount *uint256.Int, tokenID uint64, reason tracing.BalanceChangeReason) { + if tokenID == qkccommon.DefaultTokenID { + s.SubBalance(addr, amount, reason) + } else { + s.SubMntBalance(addr, amount, tokenID) + } +} + +// GetBalanceByTokenID returns QKC or MNT balance for the given tokenID. +func (s *StateDB) GetBalanceByTokenID(addr common.Address, tokenID uint64) *uint256.Int { + if tokenID == qkccommon.DefaultTokenID { + return s.GetBalance(addr) + } + return s.GetMntBalance(addr, tokenID) +} diff --git a/triedb/pathdb/execute.go b/triedb/pathdb/execute.go index 4c1cafec12aa..6f1b7947fb7d 100644 --- a/triedb/pathdb/execute.go +++ b/triedb/pathdb/execute.go @@ -90,8 +90,7 @@ func apply(db database.NodeDatabase, prevRoot common.Hash, postRoot common.Hash, // existent in post-state. Apply the reverse diff and verify if the storage // root matches the one in prev-state account. func updateAccount(ctx *context, db database.NodeDatabase, addr common.Address) error { - // The account was present in prev-state, decode it from the - // 'slim-rlp' format bytes. + // The account was present in prev-state, decode it from the slim-RLP format. addrHash := crypto.Keccak256Hash(addr.Bytes()) prev, err := types.FullAccount(ctx.accounts[addr]) if err != nil {