diff --git a/giga/evmonly/giga_store.go b/giga/evmonly/giga_store.go index 188e01e32c..7eeb6e1b73 100644 --- a/giga/evmonly/giga_store.go +++ b/giga/evmonly/giga_store.go @@ -82,23 +82,28 @@ func (e *Executor) executePreparedBlockWithStore(ctx context.Context, req Prepar return result, nil } +// gigaSnapshotStateReader adapts a giga state view to StateReader, which has no way to report that a +// value is absent, so every read here answers a missing entry with the zero value. type gigaSnapshotStateReader struct { snapshot gigastore.EVMStateView } func (r gigaSnapshotStateReader) GetBalance(addr common.Address) *big.Int { - balance := r.snapshot.GetBalance(addr) + balance, _ := r.snapshot.GetBalance(addr) return new(big.Int).SetBytes(balance[:]) } func (r gigaSnapshotStateReader) GetNonce(addr common.Address) uint64 { - return r.snapshot.GetNonce(addr) + nonce, _ := r.snapshot.GetNonce(addr) + return nonce } func (r gigaSnapshotStateReader) GetCode(addr common.Address) []byte { - return cloneBytes(r.snapshot.GetCode(addr)) + code, _ := r.snapshot.GetCode(addr) + return cloneBytes(code) } func (r gigaSnapshotStateReader) GetState(addr common.Address, key common.Hash) common.Hash { - return r.snapshot.GetStorage(addr, key) + value, _ := r.snapshot.GetStorage(addr, key) + return value } diff --git a/giga/evmonly/giga_store_test.go b/giga/evmonly/giga_store_test.go index e0440c4b41..6059b3b943 100644 --- a/giga/evmonly/giga_store_test.go +++ b/giga/evmonly/giga_store_test.go @@ -73,31 +73,37 @@ func (s *memoryGigaSnapshot) AccountExists(address gigastore.Address) bool { return false } -func (s *memoryGigaSnapshot) GetStorage(address gigastore.Address, slot gigastore.Hash) gigastore.Hash { - return s.storage[gigaStorageKey{address: address, key: slot}] +func (s *memoryGigaSnapshot) GetStorage(address gigastore.Address, slot gigastore.Hash) (gigastore.Hash, bool) { + value, ok := s.storage[gigaStorageKey{address: address, key: slot}] + return value, ok } -func (s *memoryGigaSnapshot) GetBalance(address gigastore.Address) gigastore.Hash { - return s.balances[address] +func (s *memoryGigaSnapshot) GetBalance(address gigastore.Address) (gigastore.Hash, bool) { + value, ok := s.balances[address] + return value, ok } -func (s *memoryGigaSnapshot) GetNonce(address gigastore.Address) uint64 { - return s.nonces[address] +func (s *memoryGigaSnapshot) GetNonce(address gigastore.Address) (uint64, bool) { + value, ok := s.nonces[address] + return value, ok } -func (s *memoryGigaSnapshot) GetCodeSize(address gigastore.Address) int { - return len(s.code[address]) +func (s *memoryGigaSnapshot) GetCodeSize(address gigastore.Address) (int, bool) { + code, ok := s.GetCode(address) + return len(code), ok } -func (s *memoryGigaSnapshot) GetCodeHash(address gigastore.Address) gigastore.Hash { - if !s.AccountExists(address) { - return gigastore.Hash{} +func (s *memoryGigaSnapshot) GetCodeHash(address gigastore.Address) (gigastore.Hash, bool) { + code, ok := s.GetCode(address) + if !ok { + return gigastore.Hash{}, false } - return crypto.Keccak256Hash(s.code[address]) + return crypto.Keccak256Hash(code), true } -func (s *memoryGigaSnapshot) GetCode(address gigastore.Address) []byte { - return s.code[address] +func (s *memoryGigaSnapshot) GetCode(address gigastore.Address) ([]byte, bool) { + code, ok := s.code[address] + return code, ok } func (s *memoryGigaSnapshot) GetBlockHeight() int64 { diff --git a/giga/evmonly/memory_store.go b/giga/evmonly/memory_store.go index 15ea2e14c5..02a3bbf67a 100644 --- a/giga/evmonly/memory_store.go +++ b/giga/evmonly/memory_store.go @@ -391,7 +391,7 @@ func (s *memoryStoreSnapshot) AccountExists(address gigastore.Address) bool { return balance != nil && balance.Sign() != 0 || s.store.base.GetNonce(address) != 0 || len(s.store.base.GetCode(address)) != 0 } -func (s *memoryStoreSnapshot) GetStorage(address gigastore.Address, slot gigastore.Hash) gigastore.Hash { +func (s *memoryStoreSnapshot) GetStorage(address gigastore.Address, slot gigastore.Hash) (gigastore.Hash, bool) { s.requireOpen() key := memoryStoreStorageKey{address: address, slot: slot} s.store.mu.RLock() @@ -400,70 +400,83 @@ func (s *memoryStoreSnapshot) GetStorage(address gigastore.Address, slot gigasto s.store.mu.RUnlock() if valueOK && (!clearOK || value.height >= clearHeight) { if value.delete { - return gigastore.Hash{} + return gigastore.Hash{}, false } - return value.value + return value.value, true } if clearOK { - return gigastore.Hash{} + return gigastore.Hash{}, false } - return s.store.base.GetState(address, slot) + baseValue := s.store.base.GetState(address, slot) + // The base reader reports no presence of its own, so an unset slot is indistinguishable from one + // holding zero. + return baseValue, baseValue != (gigastore.Hash{}) } -func (s *memoryStoreSnapshot) GetBalance(address gigastore.Address) gigastore.Hash { +func (s *memoryStoreSnapshot) GetBalance(address gigastore.Address) (gigastore.Hash, bool) { s.requireOpen() s.store.mu.RLock() value, ok := latestMemoryStoreValue(s.store.balances[address], s.height) s.store.mu.RUnlock() if ok { - return value.value + return value.value, true } - var balance common.Hash baseBalance := s.store.base.GetBalance(address) - if baseBalance != nil { - if err := validateMemoryStoreBalance(baseBalance); err != nil { - panic(err) - } - baseBalance.FillBytes(balance[:]) + if baseBalance == nil { + return gigastore.Hash{}, false } - return balance + if err := validateMemoryStoreBalance(baseBalance); err != nil { + panic(err) + } + var balance common.Hash + baseBalance.FillBytes(balance[:]) + // The base reader reports no presence of its own, so an account with no balance is + // indistinguishable from one holding zero. + return balance, baseBalance.Sign() != 0 } -func (s *memoryStoreSnapshot) GetNonce(address gigastore.Address) uint64 { +func (s *memoryStoreSnapshot) GetNonce(address gigastore.Address) (uint64, bool) { s.requireOpen() s.store.mu.RLock() value, ok := latestMemoryStoreValue(s.store.nonces[address], s.height) s.store.mu.RUnlock() if ok { - return value.value + return value.value, true } - return s.store.base.GetNonce(address) + baseNonce := s.store.base.GetNonce(address) + // The base reader reports no presence of its own, so a missing account is indistinguishable from + // one whose nonce is zero. + return baseNonce, baseNonce != 0 } -func (s *memoryStoreSnapshot) GetCodeSize(address gigastore.Address) int { - return len(s.GetCode(address)) +func (s *memoryStoreSnapshot) GetCodeSize(address gigastore.Address) (int, bool) { + code, ok := s.GetCode(address) + return len(code), ok } -func (s *memoryStoreSnapshot) GetCodeHash(address gigastore.Address) gigastore.Hash { - s.requireOpen() - if !s.AccountExists(address) { - return gigastore.Hash{} +func (s *memoryStoreSnapshot) GetCodeHash(address gigastore.Address) (gigastore.Hash, bool) { + code, ok := s.GetCode(address) + if !ok { + return gigastore.Hash{}, false } - return crypto.Keccak256Hash(s.GetCode(address)) + return crypto.Keccak256Hash(code), true } -func (s *memoryStoreSnapshot) GetCode(address gigastore.Address) []byte { +func (s *memoryStoreSnapshot) GetCode(address gigastore.Address) ([]byte, bool) { s.requireOpen() s.store.mu.RLock() value, ok := latestMemoryStoreValue(s.store.code[address], s.height) s.store.mu.RUnlock() if ok { if value.delete { - return nil + return nil, false } - return cloneBytes(value.value) + return cloneBytes(value.value), true } - return cloneBytes(s.store.base.GetCode(address)) + baseCode := s.store.base.GetCode(address) + // The base reader reports no presence of its own, so an account with no code is indistinguishable + // from one holding empty code. + return cloneBytes(baseCode), len(baseCode) != 0 } func (s *memoryStoreSnapshot) GetBlockHeight() int64 { diff --git a/giga/evmonly/memory_store_test.go b/giga/evmonly/memory_store_test.go index f43c0c7c0e..6705d107a6 100644 --- a/giga/evmonly/memory_store_test.go +++ b/giga/evmonly/memory_store_test.go @@ -104,17 +104,41 @@ func TestMemoryStoreSnapshotsRemainVersionedAcrossCommits(t *testing.T) { require.False(t, ok) require.Equal(t, int64(0), initial.GetBlockHeight()) - require.Equal(t, big.NewInt(10), gigaHashToBig(initial.GetBalance(address))) - require.Equal(t, uint64(1), initial.GetNonce(address)) - require.Equal(t, common.HexToHash("0xaa"), initial.GetStorage(address, baseSlot)) + + initialBalance, ok := initial.GetBalance(address) + require.True(t, ok) + require.Equal(t, big.NewInt(10), gigaHashToBig(initialBalance)) + + initialNonce, ok := initial.GetNonce(address) + require.True(t, ok) + require.Equal(t, uint64(1), initialNonce) + + initialSlot, ok := initial.GetStorage(address, baseSlot) + require.True(t, ok) + require.Equal(t, common.HexToHash("0xaa"), initialSlot) for _, snapshot := range []gigastore.StateView{current, historical} { require.Equal(t, int64(7), snapshot.GetBlockHeight()) - require.Equal(t, big.NewInt(20), gigaHashToBig(snapshot.GetBalance(address))) - require.Equal(t, uint64(2), snapshot.GetNonce(address)) - require.Equal(t, []byte{0x60, 0x01}, snapshot.GetCode(address)) - require.Equal(t, gigastore.Hash{}, snapshot.GetStorage(address, baseSlot)) - require.Equal(t, common.HexToHash("0xbb"), snapshot.GetStorage(address, newSlot)) + + balance, ok := snapshot.GetBalance(address) + require.True(t, ok) + require.Equal(t, big.NewInt(20), gigaHashToBig(balance)) + + nonce, ok := snapshot.GetNonce(address) + require.True(t, ok) + require.Equal(t, uint64(2), nonce) + + code, ok := snapshot.GetCode(address) + require.True(t, ok) + require.Equal(t, []byte{0x60, 0x01}, code) + + clearedSlot, ok := snapshot.GetStorage(address, baseSlot) + require.False(t, ok, "a storage clear leaves the slot unset, not set to zero") + require.Equal(t, gigastore.Hash{}, clearedSlot) + + value, ok := snapshot.GetStorage(address, newSlot) + require.True(t, ok) + require.Equal(t, common.HexToHash("0xbb"), value) } deleteChanges, err := store.EncodeChangeSet(StateChangeSet{ @@ -128,10 +152,22 @@ func TestMemoryStoreSnapshotsRemainVersionedAcrossCommits(t *testing.T) { require.NoError(t, err) require.NoError(t, store.CommitStateChanges(8, deleteChanges)) afterDelete := store.OpenView() - require.Empty(t, afterDelete.GetCode(address)) - require.Equal(t, gigastore.Hash{}, afterDelete.GetStorage(address, newSlot)) - require.Equal(t, []byte{0x60, 0x01}, historical.GetCode(address)) - require.Equal(t, common.HexToHash("0xbb"), historical.GetStorage(address, newSlot)) + + deletedCode, ok := afterDelete.GetCode(address) + require.False(t, ok, "a deleted entry reads as missing, not as empty") + require.Empty(t, deletedCode) + + deletedSlot, ok := afterDelete.GetStorage(address, newSlot) + require.False(t, ok) + require.Equal(t, gigastore.Hash{}, deletedSlot) + + historicalCode, ok := historical.GetCode(address) + require.True(t, ok) + require.Equal(t, []byte{0x60, 0x01}, historicalCode) + + historicalSlot, ok := historical.GetStorage(address, newSlot) + require.True(t, ok) + require.Equal(t, common.HexToHash("0xbb"), historicalSlot) initial.Close() current.Close() @@ -215,8 +251,14 @@ func TestExecutorCommitsConsecutiveBlocksThroughMemoryStore(t *testing.T) { snapshot := store.OpenView() defer snapshot.Close() require.Equal(t, int64(2), snapshot.GetBlockHeight()) - require.Equal(t, uint64(2), snapshot.GetNonce(sender)) - require.Equal(t, big.NewInt(2), gigaHashToBig(snapshot.GetBalance(recipient))) + + nonce, ok := snapshot.GetNonce(sender) + require.True(t, ok) + require.Equal(t, uint64(2), nonce) + + balance, ok := snapshot.GetBalance(recipient) + require.True(t, ok) + require.Equal(t, big.NewInt(2), gigaHashToBig(balance)) } func gigaHashToBig(value gigastore.Hash) *big.Int { diff --git a/sei-db/common/keys/evm.go b/sei-db/common/keys/evm.go index 660dcca8a0..0e40d0b789 100644 --- a/sei-db/common/keys/evm.go +++ b/sei-db/common/keys/evm.go @@ -25,6 +25,7 @@ var ( codeKeyPrefix = []byte{0x07} codeHashKeyPrefix = []byte{0x08} nonceKeyPrefix = []byte{0x0a} + balanceKeyPrefix = []byte{0x21} ) // StateKeyPrefix returns the storage state key prefix (0x03). @@ -34,10 +35,13 @@ func StateKeyPrefix() []byte { return stateKeyPrefix } // EVMKeyKind identifies an EVM key family. type EVMKeyKind uint8 +// These values are in-memory routing tags, renumbered whenever a kind is added. Writing one into a +// key, a value, or any other stored or wire format is forbidden. const ( EVMKeyEmpty EVMKeyKind = iota // Returned only for zero-length keys EVMKeyNonce // Stripped key: 20-byte address EVMKeyCodeHash // Stripped key: 20-byte address + EVMKeyBalance // Stripped key: 20-byte address EVMKeyCode // Stripped key: 20-byte address EVMKeyStorage // Stripped key: addr||slot (20+32 bytes) EVMKeyMisc // Full original key preserved (address mappings, codesize, etc.) @@ -45,7 +49,7 @@ const ( // ParseEVMKey parses an EVM key from the x/evm store keyspace. // -// For optimized keys (nonce, code, codehash, storage), keyBytes is the stripped key. +// For optimized keys (nonce, code, codehash, storage, balance), keyBytes is the stripped key. // For misc keys (all other EVM data including codesize), keyBytes is the full original key. // Only returns EVMKeyEmpty for zero-length keys. func ParseEVMKey(key []byte) (kind EVMKeyKind, keyBytes []byte) { @@ -77,6 +81,12 @@ func ParseEVMKey(key []byte) (kind EVMKeyKind, keyBytes []byte) { return EVMKeyMisc, key } return EVMKeyStorage, key[len(stateKeyPrefix):] + + case bytes.HasPrefix(key, balanceKeyPrefix): + if len(key) != len(balanceKeyPrefix)+AddressLen { + return EVMKeyMisc, key + } + return EVMKeyBalance, key[len(balanceKeyPrefix):] } // All other EVM keys go to the misc store (address mappings, codesize, etc.) @@ -95,6 +105,8 @@ func EVMKeyPrefixByte(kind EVMKeyKind) (byte, bool) { return codeHashKeyPrefix[0], true case EVMKeyCode: return codeKeyPrefix[0], true + case EVMKeyBalance: + return balanceKeyPrefix[0], true default: return 0, false } @@ -123,7 +135,7 @@ func InternalKeyLen(kind EVMKeyKind) int { switch kind { case EVMKeyStorage: return AddressLen + slotLen // 52 bytes - case EVMKeyNonce, EVMKeyCodeHash, EVMKeyCode: + case EVMKeyNonce, EVMKeyCodeHash, EVMKeyCode, EVMKeyBalance: return AddressLen // 20 bytes default: return 0 diff --git a/sei-db/common/keys/evm_test.go b/sei-db/common/keys/evm_test.go index 9f011e8efe..da156f27e7 100644 --- a/sei-db/common/keys/evm_test.go +++ b/sei-db/common/keys/evm_test.go @@ -64,6 +64,12 @@ func TestParseEVMKey(t *testing.T) { wantKind: EVMKeyStorage, wantBytes: concat(addr, slot), }, + { + name: "Balance", + key: concat(balanceKeyPrefix, addr), + wantKind: EVMKeyBalance, + wantBytes: addr, + }, // Legacy keys - keep full key (address mappings, unknown prefix, malformed, etc.) { name: "EVMAddressToSeiAddress goes to Legacy", @@ -119,6 +125,18 @@ func TestParseEVMKey(t *testing.T) { wantKind: EVMKeyMisc, wantBytes: concat(concat(concat(stateKeyPrefix, addr), slot), []byte{0x00}), }, + { + name: "BalanceTooShort goes to Legacy", + key: balanceKeyPrefix, + wantKind: EVMKeyMisc, + wantBytes: balanceKeyPrefix, + }, + { + name: "BalanceWrongLenLong goes to Legacy", + key: concat(balanceKeyPrefix, concat(addr, []byte{0x00})), + wantKind: EVMKeyMisc, + wantBytes: concat(balanceKeyPrefix, concat(addr, []byte{0x00})), + }, } for _, tc := range tests { @@ -177,6 +195,12 @@ func TestBuildMemIAVLEVMKey(t *testing.T) { keyBytes: concat(addr, slot), want: concat(stateKeyPrefix, concat(addr, slot)), }, + { + name: "Balance", + kind: EVMKeyBalance, + keyBytes: addr, + want: concat(balanceKeyPrefix, addr), + }, } for _, tc := range tests { @@ -192,4 +216,18 @@ func TestInternalKeyLen(t *testing.T) { require.Equal(t, AddressLen, InternalKeyLen(EVMKeyNonce)) require.Equal(t, AddressLen, InternalKeyLen(EVMKeyCodeHash)) require.Equal(t, AddressLen, InternalKeyLen(EVMKeyCode)) + require.Equal(t, AddressLen, InternalKeyLen(EVMKeyBalance)) +} + +// The prefix bytes this package mirrors from x/evm/types must stay distinct: ParseEVMKey classifies on +// the first byte alone, so two families sharing one would silently reparse each other's rows. +func TestEVMKeyPrefixesAreDistinct(t *testing.T) { + seen := map[byte]EVMKeyKind{} + for _, kind := range []EVMKeyKind{EVMKeyNonce, EVMKeyCodeHash, EVMKeyCode, EVMKeyStorage, EVMKeyBalance} { + prefix, ok := EVMKeyPrefixByte(kind) + require.True(t, ok, "kind %v has no prefix byte", kind) + previous, duplicate := seen[prefix] + require.False(t, duplicate, "kinds %v and %v both use prefix 0x%02x", previous, kind, prefix) + seen[prefix] = kind + } } diff --git a/sei-db/state_db/giga/state_view.go b/sei-db/state_db/giga/state_view.go index c99192a33b..ec7c026706 100644 --- a/sei-db/state_db/giga/state_view.go +++ b/sei-db/state_db/giga/state_view.go @@ -37,9 +37,6 @@ type StateView interface { // Get returns the value stored under key in this view, and whether it // was found. It never observes writes made after the view was opened. - // - // Get reports what is stored, not what EVM semantics substitute for it: a code-hash key for an - // account that exists with no code is not found, where GetCodeHash reports EmptyCodeHash. Get(module string, key []byte) ([]byte, bool) // Close releases the view's underlying ref counting. @@ -49,34 +46,35 @@ type StateView interface { } // EVMStateView is the EVM-specific read surface embedded by StateView, and carries the same contract. +// +// Every getter reports whether the state entry it reads was found. The bool speaks to that entry's +// existence and not to its contents: an account that exists with a nonce of 0 reads as (0, true). +// A getter that reports false returns the zero value, so a caller with no use for the distinction can +// discard the bool and read a missing entry as zero. type EVMStateView interface { // AccountExists reports whether addr has an account in state, // including accounts that have self-destructed in the current block. AccountExists(addr Address) bool - // GetStorage returns the value stored at key in addr's storage. - // Returns the zero Hash if the slot is unset. - GetStorage(addr Address, key Hash) Hash + // GetStorage returns the value stored at key in addr's storage, and whether that slot is set. + GetStorage(addr Address, key Hash) (Hash, bool) - // GetBalance returns addr's balance, as a 256-bit big-endian value. - GetBalance(addr Address) Hash + // GetBalance returns addr's balance as a 256-bit big-endian value, and whether a balance is + // stored for addr. + GetBalance(addr Address) (Hash, bool) - // GetNonce returns addr's account nonce. Returns 0 if unset / the - // account does not exist. - GetNonce(addr Address) uint64 + // GetNonce returns addr's account nonce, and whether addr has an account. + GetNonce(addr Address) (uint64, bool) - // GetCodeSize returns the length in bytes of addr's contract code. - // Returns 0 for accounts with no code. - GetCodeSize(addr Address) int + // GetCodeSize returns the length in bytes of addr's contract code, and whether addr has code. + GetCodeSize(addr Address) (int, bool) - // GetCodeHash returns the hash of addr's contract code. - // Returns EmptyCodeHash for an account that exists with no code, and the zero - // Hash for an account that does not exist or has been deleted. - // Matches EXTCODEHASH / keeper.GetCodeHash. - GetCodeHash(addr Address) Hash + // GetCodeHash returns the hash of addr's contract code, and whether a code hash is stored for + // addr. An account that exists and holds no code stores no code hash; EVM semantics answer + // EmptyCodeHash for that case, which the caller must substitute for itself. + GetCodeHash(addr Address) (Hash, bool) - // GetCode returns addr's contract code. Returns nil/empty for - // accounts with no code. - GetCode(addr Address) []byte + // GetCode returns addr's contract code, and whether addr has code. + GetCode(addr Address) ([]byte, bool) } diff --git a/sei-db/state_db/sc/composite/random_test_framework_test.go b/sei-db/state_db/sc/composite/random_test_framework_test.go index ea6f3450c6..df7ac60664 100644 --- a/sei-db/state_db/sc/composite/random_test_framework_test.go +++ b/sei-db/state_db/sc/composite/random_test_framework_test.go @@ -244,6 +244,10 @@ func randomCodeHashValue(rng *testutil.TestRandom) []byte { return ensureNonZero(randomTestBytes(rng, vtype.CodeHashLen)) } +func randomBalanceValue(rng *testutil.TestRandom) []byte { + return ensureNonZero(randomTestBytes(rng, vtype.BalanceLen)) +} + func randomStorageValue(rng *testutil.TestRandom) []byte { return ensureNonZero(randomTestBytes(rng, vtype.SlotLen)) } @@ -284,16 +288,17 @@ func randomLegacyEVMKey(rng *testutil.TestRandom) []byte { // - storage: one storageDB row (0x03 || addr || slot) // - code: one codeDB row (0x07 || addr) // - account: one accountDB row (0x0a || addr) — a nonce is always written -// (a zero nonce reads back as absent), and with ~50% probability a code -// hash is written for the SAME address. That second case is the account-map -// "collision": the nonce and code hash merge into one physical account row, -// exercising the merged-account read / iterate / migrate paths. +// (a zero nonce reads back as absent), and with ~50% probability each, a code +// hash and a balance are written for the SAME address. Those cases are the +// account-map "collision": the nonce, code hash and balance merge into one +// physical account row, exercising the merged-account read / iterate / +// migrate paths. // - legacy: one legacyDB row (0x01 || suffix) — a non-optimized EVM key // (address mappings, codesize, etc.) with a variable-length, occasionally // empty value, populating flatkv's EVM legacy lane. // // Returning a slice (rather than one pair) is what lets a single logical -// account own both a nonce and a code hash within one block. +// account own a nonce, a code hash and a balance within one block. func newRandomEVMEntry(rng *testutil.TestRandom) []*proto.KVPair { switch rng.Intn(4) { case 0: @@ -307,6 +312,12 @@ func newRandomEVMEntry(rng *testutil.TestRandom) []*proto.KVPair { Value: randomCodeHashValue(rng), }) } + if rng.Intn(2) == 0 { + pairs = append(pairs, &proto.KVPair{ + Key: keys.BuildEVMKey(keys.EVMKeyBalance, addr), + Value: randomBalanceValue(rng), + }) + } return pairs case 1: addr := randomTestBytes(rng, keys.AddressLen) @@ -329,6 +340,8 @@ func freshEVMValue(rng *testutil.TestRandom, key []byte) []byte { return randomNonceValue(rng) case keys.EVMKeyCodeHash: return randomCodeHashValue(rng) + case keys.EVMKeyBalance: + return randomBalanceValue(rng) case keys.EVMKeyCode: return randomCodeValue(rng) case keys.EVMKeyStorage: @@ -494,12 +507,11 @@ func simulateBlocks( &proto.KVPair{Key: []byte(kp.key), Value: value}) } - // Delete existing keys. Deleting an account's nonce must also delete - // that address's code hash in the same block: flatkv merges both into a - // single physical account row, so dropping only the nonce would leave a - // live (now nonce-zero) row whose nonce reads back via the phantom-nonce - // path — present in flatkv but absent from the oracle. Expanding the - // delete set deterministically (in sample order, deduped) keeps the + // Delete existing keys. Deleting an account's nonce must also delete that address's other + // merged fields — code hash and balance — in the same block: flatkv merges all three into a + // single physical account row, so dropping only the nonce would leave a live (now nonce-zero) + // row whose nonce reads back via the phantom-nonce path — present in flatkv but absent from + // the oracle. Expanding the delete set deterministically (in sample order, deduped) keeps the // generated changeset byte-identical for a given seed. toDelete := make([]keyPair, 0, p.deletesPerBlock) inDelete := make(map[keyPair]struct{}, p.deletesPerBlock) @@ -515,8 +527,12 @@ func simulateBlocks( if kp.store != keys.EVMStoreKey { continue } - if kind, stripped := keys.ParseEVMKey([]byte(kp.key)); kind == keys.EVMKeyNonce { - sibling := keyPair{store: kp.store, key: string(keys.BuildEVMKey(keys.EVMKeyCodeHash, stripped))} + kind, stripped := keys.ParseEVMKey([]byte(kp.key)) + if kind != keys.EVMKeyNonce { + continue + } + for _, siblingKind := range []keys.EVMKeyKind{keys.EVMKeyCodeHash, keys.EVMKeyBalance} { + sibling := keyPair{store: kp.store, key: string(keys.BuildEVMKey(siblingKind, stripped))} if keysInUse.Contains(sibling) { addDelete(sibling) } @@ -1002,21 +1018,23 @@ type flatKVExpectedRow struct { storageValue [32]byte // rowStorage nonce uint64 // rowAccount codeHash [32]byte // rowAccount + balance [32]byte // rowAccount code []byte // rowCode legacyValue []byte // rowLegacy } // oracleToFlatKVRows projects the oracle into the physical row layout flatkv // uses internally, keyed by the physical (module-prefixed) key. Only stores the -// placement model routes to flatkv are included. The EVM nonce and code hash -// for a single address are merged into one account row, exactly as flatkv's -// accountDB stores them — this is what makes the row-by-row check sensitive to -// the account-merge logic. Valid only for steady-state placement. +// placement model routes to flatkv are included. The EVM nonce, code hash and +// balance for a single address are merged into one account row, exactly as +// flatkv's accountDB stores them — this is what makes the row-by-row check +// sensitive to the account-merge logic. Valid only for steady-state placement. func oracleToFlatKVRows( oracle *storeOracle, placement func(store string) backendPlacement) map[string]flatKVExpectedRow { type acct struct { nonce uint64 codeHash [32]byte + balance [32]byte } accounts := map[string]*acct{} getAcct := func(addr string) *acct { @@ -1057,6 +1075,8 @@ func oracleToFlatKVRows( getAcct(string(stripped)).nonce = binary.BigEndian.Uint64(v) case keys.EVMKeyCodeHash: copy(getAcct(string(stripped)).codeHash[:], v) + case keys.EVMKeyBalance: + copy(getAcct(string(stripped)).balance[:], v) default: // EVMKeyMisc: identity-mapped under the "evm/" prefix rows[string(ktype.ModulePhysicalKey(keys.EVMStoreKey, []byte(k)))] = flatKVExpectedRow{kind: rowLegacy, legacyValue: append([]byte(nil), v...)} @@ -1066,7 +1086,7 @@ func oracleToFlatKVRows( for addr, a := range accounts { rows[string(ktype.EVMPhysicalKey(ktype.EVMKeyAccount, []byte(addr)))] = - flatKVExpectedRow{kind: rowAccount, nonce: a.nonce, codeHash: a.codeHash} + flatKVExpectedRow{kind: rowAccount, nonce: a.nonce, codeHash: a.codeHash, balance: a.balance} } return rows } @@ -1134,9 +1154,7 @@ func assertFlatKVRowMatches(t *testing.T, physKey, rawVal []byte, exp flatKVExpe require.NoError(t, err, "decode account row %x", physKey) require.Equal(t, exp.nonce, ad.GetNonce(), "account nonce mismatch for %x", physKey) require.Equal(t, exp.codeHash[:], ad.GetCodeHash()[:], "account code hash mismatch for %x", physKey) - var zeroBalance vtype.Balance - require.Equal(t, zeroBalance[:], ad.GetBalance()[:], - "account balance must be zero (balances are not stored in flatkv yet) for %x", physKey) + require.Equal(t, exp.balance[:], ad.GetBalance()[:], "account balance mismatch for %x", physKey) case rowLegacy: ld, err := vtype.DeserializeMiscData(rawVal) require.NoError(t, err, "decode legacy row %x", physKey) @@ -1153,7 +1171,11 @@ func assertFlatKVRowMatches(t *testing.T, physKey, rawVal []byte, exp flatKVExpe func assertFlatKVMapsExercised(t *testing.T, oracle *storeOracle, placement func(store string) backendPlacement) { t.Helper() var storageRows, codeRows, legacyRows int - type acctFlags struct{ nonce, codeHash bool } + type acctFlags struct { + nonce bool + codeHash bool + balance bool + } accounts := map[string]*acctFlags{} flag := func(addr string) *acctFlags { a, ok := accounts[addr] @@ -1185,18 +1207,23 @@ func assertFlatKVMapsExercised(t *testing.T, oracle *storeOracle, placement func flag(string(stripped)).nonce = true case keys.EVMKeyCodeHash: flag(string(stripped)).codeHash = true + case keys.EVMKeyBalance: + flag(string(stripped)).balance = true default: legacyRows++ } } } - var accountRows, collisions int + var accountRows, collisions, balanceAccounts int for _, af := range accounts { accountRows++ if af.nonce && af.codeHash { collisions++ } + if af.balance { + balanceAccounts++ + } } evmPlacement := placement(keys.EVMStoreKey) @@ -1206,6 +1233,7 @@ func assertFlatKVMapsExercised(t *testing.T, oracle *storeOracle, placement func require.Positive(t, accountRows, "expected account-map rows in flatkv") require.Positive(t, collisions, "expected at least one account with both a nonce and a code hash (account-map collision)") + require.Positive(t, balanceAccounts, "expected at least one account holding a balance") } legacyExpected := false diff --git a/sei-db/state_db/sc/flatkv/import_export_test.go b/sei-db/state_db/sc/flatkv/import_export_test.go index c0192a7085..9f02a5b443 100644 --- a/sei-db/state_db/sc/flatkv/import_export_test.go +++ b/sei-db/state_db/sc/flatkv/import_export_test.go @@ -107,10 +107,14 @@ func TestExporterAccountKeys(t *testing.T) { codeHashVal := make([]byte, vtype.CodeHashLen) codeHashVal[0] = 0xDE + balanceKey := keys.BuildEVMKey(keys.EVMKeyBalance, addr[:]) + balanceVal := balanceN(0x5C) + require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ {Name: "evm", Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ {Key: nonceKey, Value: nonceVal}, {Key: codeHashKey, Value: codeHashVal}, + {Key: balanceKey, Value: balanceVal[:]}, }}}, })) commitAndCheck(t, s) @@ -120,7 +124,7 @@ func TestExporterAccountKeys(t *testing.T) { nodes := drainExporter(t, exp) require.NoError(t, exp.Close()) - // nonce + codehash merge into a single account row in accountDB + // nonce + codehash + balance merge into a single account row in accountDB require.Len(t, nodes, 1) n := nodes[0] @@ -132,6 +136,7 @@ func TestExporterAccountKeys(t *testing.T) { require.NoError(t, err) require.Equal(t, uint64(42), acct.GetNonce()) require.Equal(t, byte(0xDE), acct.GetCodeHash()[0]) + require.Equal(t, &balanceVal, acct.GetBalance()) } func TestExporterCodeKeys(t *testing.T) { @@ -180,6 +185,8 @@ func TestExporterRoundTrip(t *testing.T) { codeHashKey := keys.BuildEVMKey(keys.EVMKeyCodeHash, addr[:]) codeHashVal := make([]byte, vtype.CodeHashLen) codeHashVal[31] = 0xAB + balanceKey := keys.BuildEVMKey(keys.EVMKeyBalance, addr[:]) + balanceVal := balanceN(0x2B) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ {Name: "evm", Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ @@ -187,6 +194,7 @@ func TestExporterRoundTrip(t *testing.T) { {Key: nonceKey, Value: nonceVal}, {Key: codeKey, Value: codeVal}, {Key: codeHashKey, Value: codeHashVal}, + {Key: balanceKey, Value: balanceVal[:]}, }}}, })) commitAndCheck(t, s) @@ -230,6 +238,10 @@ func TestExporterRoundTrip(t *testing.T) { require.True(t, found, "codehash key should exist after import") require.Equal(t, codeHashVal, got) + got, found = s2.Get(keys.EVMStoreKey, balanceKey) + require.True(t, found, "balance key should exist after import") + require.Equal(t, balanceVal[:], got) + // LtHash should match because import recomputes it from the same physical key/value pairs require.Equal(t, srcHash, rootHash(s2)) @@ -257,11 +269,13 @@ func TestExporterEOAAccountOmitsCodeHash(t *testing.T) { addr := ktype.Address{0xAA} nonceKey := keys.BuildEVMKey(keys.EVMKeyNonce, addr[:]) nonceVal := []byte{0, 0, 0, 0, 0, 0, 0, 1} + balanceVal := balanceN(0x99) - // EOA: only nonce, no codehash + // EOA: nonce and balance, no codehash require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ {Name: "evm", Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ {Key: nonceKey, Value: nonceVal}, + {Key: keys.BuildEVMKey(keys.EVMKeyBalance, addr[:]), Value: balanceVal[:]}, }}}, })) commitAndCheck(t, s) @@ -271,8 +285,11 @@ func TestExporterEOAAccountOmitsCodeHash(t *testing.T) { nodes := drainExporter(t, exp) require.NoError(t, exp.Close()) - // EOA produces a single account node with zero codehash (compact form) + // EOA produces a single account node with zero codehash, so the row travels in the compact form and + // the balance rides inside that prefix. require.Len(t, nodes, 1) + require.Len(t, nodes[0].Value, vtype.VersionLength+vtype.BlockHeightLength+ + vtype.BalanceLength+vtype.NonceLength) kind, _, err := ktype.StripEVMPhysicalKey(nodes[0].Key) require.NoError(t, err) require.Equal(t, ktype.EVMKeyAccount, kind) @@ -280,6 +297,7 @@ func TestExporterEOAAccountOmitsCodeHash(t *testing.T) { acct, err := vtype.DeserializeAccountData(nodes[0].Value) require.NoError(t, err) require.Equal(t, uint64(1), acct.GetNonce()) + require.Equal(t, &balanceVal, acct.GetBalance()) var zeroHash vtype.CodeHash require.Equal(t, &zeroHash, acct.GetCodeHash()) } @@ -295,11 +313,14 @@ func TestImportSurvivesReopen(t *testing.T) { storageVal := padLeft32(0xFF) nonceKey := keys.BuildEVMKey(keys.EVMKeyNonce, addr[:]) nonceVal := []byte{0, 0, 0, 0, 0, 0, 0, 7} + balanceKey := keys.BuildEVMKey(keys.EVMKeyBalance, addr[:]) + balanceVal := balanceN(0x3F) require.NoError(t, src.ApplyChangeSets(src.Version()+1, []*proto.NamedChangeSet{ {Name: "evm", Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ {Key: storageKey, Value: storageVal}, {Key: nonceKey, Value: nonceVal}, + {Key: balanceKey, Value: balanceVal[:]}, }}}, })) commitAndCheck(t, src) @@ -350,6 +371,10 @@ func TestImportSurvivesReopen(t *testing.T) { require.True(t, found, "nonce key must survive reopen") require.Equal(t, nonceVal, got) + got, found = s2.Get(keys.EVMStoreKey, balanceKey) + require.True(t, found, "balance key must survive reopen") + require.Equal(t, balanceVal[:], got) + require.Equal(t, srcHash, rootHash(s2)) } @@ -384,6 +409,8 @@ func TestImportPurgesStaleData(t *testing.T) { nonceStale := keys.BuildEVMKey(keys.EVMKeyNonce, addrStale[:]) codeHashB := keys.BuildEVMKey(keys.EVMKeyCodeHash, addrB[:]) codeHashStale := keys.BuildEVMKey(keys.EVMKeyCodeHash, addrStale[:]) + balanceA := keys.BuildEVMKey(keys.EVMKeyBalance, addrA[:]) + balanceStale := keys.BuildEVMKey(keys.EVMKeyBalance, addrStale[:]) // Code key codeB := keys.BuildEVMKey(keys.EVMKeyCode, addrB[:]) codeStale := keys.BuildEVMKey(keys.EVMKeyCode, addrStale[:]) @@ -392,6 +419,7 @@ func TestImportPurgesStaleData(t *testing.T) { codeHashVal := make([]byte, vtype.CodeHashLen) codeHashVal[31] = 0xAB codeVal := []byte{0x60, 0x80} + balanceVal := balanceN(0x0B) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ {Name: "evm", Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ @@ -403,11 +431,13 @@ func TestImportPurgesStaleData(t *testing.T) { {Key: codeHashStale, Value: codeHashVal}, {Key: codeB, Value: codeVal}, {Key: codeStale, Value: codeVal}, + {Key: balanceA, Value: balanceVal[:]}, + {Key: balanceStale, Value: balanceVal[:]}, }}}, })) commitAndCheck(t, s) - staleKeys := [][]byte{storageStale, nonceStale, codeHashStale, codeStale} + staleKeys := [][]byte{storageStale, nonceStale, codeHashStale, codeStale, balanceStale} var found bool for _, k := range staleKeys { @@ -424,6 +454,7 @@ func TestImportPurgesStaleData(t *testing.T) { newCodeHashVal := make([]byte, vtype.CodeHashLen) newCodeHashVal[31] = 0xCD newCodeVal := []byte{0x60, 0x40, 0x52} + newBalanceVal := balanceN(0xB1) require.NoError(t, src.ApplyChangeSets(src.Version()+1, []*proto.NamedChangeSet{ {Name: "evm", Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ @@ -431,6 +462,7 @@ func TestImportPurgesStaleData(t *testing.T) { {Key: nonceA, Value: newNonceVal}, {Key: codeHashB, Value: newCodeHashVal}, {Key: codeB, Value: newCodeVal}, + {Key: balanceA, Value: newBalanceVal[:]}, }}}, })) commitAndCheck(t, src) @@ -475,6 +507,10 @@ func TestImportPurgesStaleData(t *testing.T) { require.True(t, found, "codehash key B should exist") require.Equal(t, newCodeHashVal, got) + got, found = s.Get(keys.EVMStoreKey, balanceA) + require.True(t, found, "balance key A should exist") + require.Equal(t, newBalanceVal[:], got) + for _, k := range staleKeys { _, found = s.Get(keys.EVMStoreKey, k) require.False(t, found, "stale key should NOT exist after import") diff --git a/sei-db/state_db/sc/flatkv/import_translator.go b/sei-db/state_db/sc/flatkv/import_translator.go index adf5be786b..e5b0862732 100644 --- a/sei-db/state_db/sc/flatkv/import_translator.go +++ b/sei-db/state_db/sc/flatkv/import_translator.go @@ -33,7 +33,7 @@ type PhysicalKVPair struct { // is empty so it does not merge with prior DB values. // // Storage / code / misc / non-EVM pairs are emitted directly from each -// Translate call. Account-related entries (nonce, codehash) are buffered +// Translate call. Account-related entries (nonce, codehash, balance) are buffered // across all Translate calls so that each address is written exactly once // with its fully-merged AccountData; flush them by calling Finalize. // @@ -57,7 +57,7 @@ func NewImportTranslator(blockHeight int64) *ImportTranslator { } // Translate returns the storage / code / misc / non-EVM physical pairs -// encoded from cs. Account fragments (nonce, codehash) are buffered +// encoded from cs. Account fragments (nonce, codehash, balance) are buffered // internally; flush them via Finalize after all changesets have been fed in. // // nil or empty changesets return (nil, nil). @@ -112,7 +112,7 @@ func (t *ImportTranslator) Translate(cs *proto.NamedChangeSet) ([]PhysicalKVPair } out = appendNonDeletes(out, miscChanges) - // Accumulate nonce + codeHash entries from this batch into the + // Accumulate nonce + codeHash + balance entries from this batch into the // translator-level pending account map. Multiple Translate calls // naturally fold updates for the same address together: the SetXxx // methods on PendingAccountWrite mutate the pointer in place when the @@ -120,7 +120,7 @@ func (t *ImportTranslator) Translate(cs *proto.NamedChangeSet) ([]PhysicalKVPair batchAccts, err := mergeAccountUpdates( changesByType[keys.EVMKeyNonce], changesByType[keys.EVMKeyCodeHash], - nil, // TODO: balance, when balance key kind is introduced + changesByType[keys.EVMKeyBalance], ) if err != nil { return nil, fmt.Errorf("failed to merge account changes: %w", err) diff --git a/sei-db/state_db/sc/flatkv/import_translator_test.go b/sei-db/state_db/sc/flatkv/import_translator_test.go index 072f4ffe44..429b3e260e 100644 --- a/sei-db/state_db/sc/flatkv/import_translator_test.go +++ b/sei-db/state_db/sc/flatkv/import_translator_test.go @@ -215,6 +215,51 @@ func TestImportTranslator_NonceAndCodeHashCrossCallMerge(t *testing.T) { require.Equal(t, ch, *got.GetCodeHash()) } +func TestImportTranslator_BalanceOnlyAccountEmittedByFinalize(t *testing.T) { + addr := addrN(0x42) + bal := balanceN(0x5F) + + tr := NewImportTranslator(importBlockHeight) + pairs, err := tr.Translate(namedCS(balancePair(addr, bal))) + require.NoError(t, err) + require.Empty(t, pairs, "account fragments are buffered until Finalize") + + finalized := tr.Finalize() + require.Len(t, finalized, 1) + require.Equal(t, accountPhysKey(addr), finalized[0].Key) + + got, err := vtype.DeserializeAccountData(finalized[0].Value) + require.NoError(t, err) + require.Equal(t, importBlockHeight, got.GetBlockHeight()) + require.Equal(t, bal, *got.GetBalance()) + require.Zero(t, got.GetNonce()) +} + +func TestImportTranslator_AllAccountFieldsCrossCallMerge(t *testing.T) { + addr := addrN(0x42) + ch := codeHashN(0xAB) + bal := balanceN(0x11) + + tr := NewImportTranslator(importBlockHeight) + _, err := tr.Translate(namedCS(noncePair(addr, 9))) + require.NoError(t, err) + + _, err = tr.Translate(namedCS(codeHashPair(addr, ch))) + require.NoError(t, err) + + _, err = tr.Translate(namedCS(balancePair(addr, bal))) + require.NoError(t, err) + + finalized := tr.Finalize() + require.Len(t, finalized, 1, "fragments split across calls must merge into one account") + + got, err := vtype.DeserializeAccountData(finalized[0].Value) + require.NoError(t, err) + require.Equal(t, uint64(9), got.GetNonce()) + require.Equal(t, ch, *got.GetCodeHash()) + require.Equal(t, bal, *got.GetBalance()) +} + func TestImportTranslator_DropsDeletes(t *testing.T) { addr := addrN(0x42) slot := slotN(0x01) diff --git a/sei-db/state_db/sc/flatkv/ktype/ktype.go b/sei-db/state_db/sc/flatkv/ktype/ktype.go index 3d5e4553e9..a4589065a8 100644 --- a/sei-db/state_db/sc/flatkv/ktype/ktype.go +++ b/sei-db/state_db/sc/flatkv/ktype/ktype.go @@ -42,7 +42,7 @@ func StorageKey(addr Address, slot Slot) []byte { // --------------------------------------------------------------------------- // EVMKeyAccount is the canonical EVMKeyKind for the merged account row in -// accountDB. FlatKV merges nonce (0x0a), codehash (0x08), and future balance +// accountDB. FlatKV merges nonce (0x0a), codehash (0x08), and balance (0x21) // into one physical row. The nonce prefix byte (0x0a) is reused as the // canonical type byte so the physical key is "evm/" + 0x0a + addr. // @@ -79,10 +79,10 @@ func StripModulePrefix(physicalKey []byte) (moduleName string, originalKey []byt // EVMPhysicalKey returns the physical DB key for an EVM key kind. // Format: "evm/" + type_prefix_byte + stripped_key. -// For account keys (nonce, codehash), canonicalizes to EVMKeyAccount (0x0a) -// because these fields are merged into one physical row. +// For account keys (nonce, codehash, balance), canonicalizes to EVMKeyAccount +// (0x0a) because these fields are merged into one physical row. func EVMPhysicalKey(kind keys.EVMKeyKind, strippedKey []byte) []byte { - if kind == keys.EVMKeyCodeHash { + if kind == keys.EVMKeyCodeHash || kind == keys.EVMKeyBalance { kind = EVMKeyAccount } prefixByte, ok := keys.EVMKeyPrefixByte(kind) diff --git a/sei-db/state_db/sc/flatkv/snapshot_test.go b/sei-db/state_db/sc/flatkv/snapshot_test.go index 33be1184fe..85ffa1c85d 100644 --- a/sei-db/state_db/sc/flatkv/snapshot_test.go +++ b/sei-db/state_db/sc/flatkv/snapshot_test.go @@ -1938,11 +1938,15 @@ func TestAccountRowDeleteAfterSnapshotRollback(t *testing.T) { addr := ktype.Address{0xE3} nonceKey := keys.BuildEVMKey(keys.EVMKeyNonce, addr[:]) + balanceKey := keys.BuildEVMKey(keys.EVMKeyBalance, addr[:]) + balanceVal := balanceN(0xE7) + // Both fields live in one row, so both have to be cleared at v2 for the row to go away. cs1 := &proto.NamedChangeSet{ Name: "evm", Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ - {Key: keys.BuildEVMKey(keys.EVMKeyNonce, addr[:]), Value: []byte{0, 0, 0, 0, 0, 0, 0, 3}}, + {Key: nonceKey, Value: []byte{0, 0, 0, 0, 0, 0, 0, 3}}, + {Key: balanceKey, Value: balanceVal[:]}, }}, } require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs1})) @@ -1956,7 +1960,8 @@ func TestAccountRowDeleteAfterSnapshotRollback(t *testing.T) { cs2 := &proto.NamedChangeSet{ Name: "evm", Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ - {Key: keys.BuildEVMKey(keys.EVMKeyNonce, addr[:]), Delete: true}, + {Key: nonceKey, Delete: true}, + {Key: balanceKey, Delete: true}, }}, } require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs2})) @@ -1965,6 +1970,8 @@ func TestAccountRowDeleteAfterSnapshotRollback(t *testing.T) { _, found = s.Get(keys.EVMStoreKey, nonceKey) require.False(t, found, "nonce should be gone at v2") + _, found = s.Get(keys.EVMStoreKey, balanceKey) + require.False(t, found, "balance should be gone at v2") // Rollback to v1: row should be restored require.NoError(t, s.Rollback(1)) @@ -1974,6 +1981,10 @@ func TestAccountRowDeleteAfterSnapshotRollback(t *testing.T) { require.True(t, found, "nonce should be restored after rollback to v1") require.Equal(t, []byte{0, 0, 0, 0, 0, 0, 0, 3}, nonceVal) + got, found := s.Get(keys.EVMStoreKey, balanceKey) + require.True(t, found, "balance should be restored after rollback to v1") + require.Equal(t, balanceVal[:], got) + require.NoError(t, s.Close()) } diff --git a/sei-db/state_db/sc/flatkv/state_view.go b/sei-db/state_db/sc/flatkv/state_view.go index 6e4b3791a3..df52e72139 100644 --- a/sei-db/state_db/sc/flatkv/state_view.go +++ b/sei-db/state_db/sc/flatkv/state_view.go @@ -1,7 +1,6 @@ package flatkv import ( - "encoding/binary" "fmt" "sync" @@ -48,22 +47,12 @@ func (v *flatKVStateView) Get(module string, key []byte) ([]byte, bool) { case keys.EVMKeyEmpty: return nil, false - case keys.EVMKeyNonce, keys.EVMKeyCodeHash: + case keys.EVMKeyNonce, keys.EVMKeyCodeHash, keys.EVMKeyBalance: account := v.accountData(keyBytes) if account == nil { return nil, false } - if kind == keys.EVMKeyNonce { - nonceBytes := make([]byte, vtype.NonceLen) - binary.BigEndian.PutUint64(nonceBytes, account.GetNonce()) - return nonceBytes, true - } - codeHash := account.GetCodeHash() - var zeroCodeHash vtype.CodeHash - if *codeHash == zeroCodeHash { - return nil, false - } - return codeHash[:], true + return accountFieldValue(kind, account) case keys.EVMKeyStorage: storage := v.storageData(keyBytes) @@ -93,59 +82,69 @@ func (v *flatKVStateView) AccountExists(addr giga.Address) bool { return v.accountData(addr[:]) != nil } -// GetNonce returns addr's account nonce, or 0 when the account does not exist. -func (v *flatKVStateView) GetNonce(addr giga.Address) uint64 { +// GetNonce returns addr's account nonce, and whether addr has an account. +func (v *flatKVStateView) GetNonce(addr giga.Address) (uint64, bool) { account := v.accountData(addr[:]) if account == nil { - return 0 + return 0, false } - return account.GetNonce() + return account.GetNonce(), true } -// GetBalance panics. FlatKV has no balance key, so every account row carries a zero balance and -// there is nothing to read; answering zero would be indistinguishable from a real balance of zero. -func (v *flatKVStateView) GetBalance(giga.Address) giga.Hash { - panic("flatkv: GetBalance is unimplemented; FlatKV does not store balances") +// GetBalance returns addr's balance as a 256-bit big-endian value, and whether a balance is stored for +// addr. An account that exists and holds nothing stores no balance. +func (v *flatKVStateView) GetBalance(addr giga.Address) (giga.Hash, bool) { + account := v.accountData(addr[:]) + if account == nil { + return giga.Hash{}, false + } + balance := giga.Hash(*account.GetBalance()) + if balance == (giga.Hash{}) { + // A row only exists while some field is non-zero (see AccountData.IsDelete), and the balance + // is not that field here, so this account has a nonce or a code hash and holds nothing. + return giga.Hash{}, false + } + return balance, true } -// GetCodeHash returns the hash of addr's contract code, giga.EmptyCodeHash when the account exists -// and holds no code, or the zero hash when it does not exist. -func (v *flatKVStateView) GetCodeHash(addr giga.Address) giga.Hash { +// GetCodeHash returns the hash of addr's contract code, and whether a code hash is stored for addr. +// An account that exists and holds no code stores no code hash. +func (v *flatKVStateView) GetCodeHash(addr giga.Address) (giga.Hash, bool) { account := v.accountData(addr[:]) if account == nil { - return giga.Hash{} + return giga.Hash{}, false } codeHash := giga.Hash(*account.GetCodeHash()) if codeHash == (giga.Hash{}) { // A row only exists while some field is non-zero (see AccountData.IsDelete), and the code hash - // is not that field here, so this account has a nonce or a balance and no code — the case EVM - // semantics answer with the empty-code hash rather than with zero. - return giga.EmptyCodeHash + // is not that field here, so this account has a nonce or a balance and no code at all. + return giga.Hash{}, false } - return codeHash + return codeHash, true } -// GetStorage returns the value at key in addr's storage, or the zero hash when the slot is unset. -func (v *flatKVStateView) GetStorage(addr giga.Address, key giga.Hash) giga.Hash { +// GetStorage returns the value at key in addr's storage, and whether that slot is set. +func (v *flatKVStateView) GetStorage(addr giga.Address, key giga.Hash) (giga.Hash, bool) { storage := v.storageData(ktype.StorageKey(ktype.Address(addr), ktype.Slot(key))) if storage == nil { - return giga.Hash{} + return giga.Hash{}, false } - return giga.Hash(*storage.GetValue()) + return giga.Hash(*storage.GetValue()), true } -// GetCode returns addr's contract code, or nil when it has none. -func (v *flatKVStateView) GetCode(addr giga.Address) []byte { +// GetCode returns addr's contract code, and whether addr has code. +func (v *flatKVStateView) GetCode(addr giga.Address) ([]byte, bool) { code := v.codeData(addr[:]) if code == nil { - return nil + return nil, false } - return code.GetBytecode() + return code.GetBytecode(), true } -// GetCodeSize returns the length of addr's contract code in bytes, or 0 when it has none. -func (v *flatKVStateView) GetCodeSize(addr giga.Address) int { - return len(v.GetCode(addr)) +// GetCodeSize returns the length of addr's contract code in bytes, and whether addr has code. +func (v *flatKVStateView) GetCodeSize(addr giga.Address) (int, bool) { + code, ok := v.GetCode(addr) + return len(code), ok } // accountData returns the account row for the 20-byte address in keyBytes, or nil when no account diff --git a/sei-db/state_db/sc/flatkv/state_view_test.go b/sei-db/state_db/sc/flatkv/state_view_test.go index 11f7885ea9..0289fea77b 100644 --- a/sei-db/state_db/sc/flatkv/state_view_test.go +++ b/sei-db/state_db/sc/flatkv/state_view_test.go @@ -61,7 +61,8 @@ func TestOpenViewReadsCommittedBlock(t *testing.T) { defer stateView.Close() require.Equal(t, int64(1), stateView.GetBlockHeight()) - require.Equal(t, uint64(7), stateView.GetNonce(gigaAddr(addr))) + nonce, _ := stateView.GetNonce(gigaAddr(addr)) + require.Equal(t, uint64(7), nonce) } // The point of a view is that it is pinned: it keeps answering for the block it was opened on however @@ -81,13 +82,15 @@ func TestOpenViewIsIsolatedFromLaterCommits(t *testing.T) { } require.Equal(t, int64(1), stateView.GetBlockHeight(), "a view must not follow the store forward") - require.Equal(t, uint64(7), stateView.GetNonce(gigaAddr(addr)), + pinnedNonce, _ := stateView.GetNonce(gigaAddr(addr)) + require.Equal(t, uint64(7), pinnedNonce, "a view must not observe writes committed after it was opened") latest := s.OpenView() defer latest.Close() require.Equal(t, int64(4), latest.GetBlockHeight()) - require.Equal(t, uint64(14), latest.GetNonce(gigaAddr(addr))) + latestNonce, _ := latest.GetNonce(gigaAddr(addr)) + require.Equal(t, uint64(14), latestNonce) } // Every OpenView takes a reservation that only Close hands back, and an unreleased view stalls its @@ -101,7 +104,8 @@ func TestOpenViewCloseReturnsReservation(t *testing.T) { for i := 0; i < 50; i++ { stateView := s.OpenView() - require.Equal(t, uint64(7), stateView.GetNonce(gigaAddr(addr))) + nonce, _ := stateView.GetNonce(gigaAddr(addr)) + require.Equal(t, uint64(7), nonce) stateView.Close() } @@ -134,7 +138,8 @@ func TestOpenViewCloseIsIdempotent(t *testing.T) { commitNonce(t, s, 1, addr, 7) stateView := s.OpenView() - require.Equal(t, uint64(7), stateView.GetNonce(gigaAddr(addr))) + nonce, _ := stateView.GetNonce(gigaAddr(addr)) + require.Equal(t, uint64(7), nonce) stateView.Close() stateView.Close() @@ -144,7 +149,8 @@ func TestOpenViewCloseIsIdempotent(t *testing.T) { latest := s.OpenView() defer latest.Close() - require.Equal(t, uint64(9), latest.GetNonce(gigaAddr(addr))) + latestNonce, _ := latest.GetNonce(gigaAddr(addr)) + require.Equal(t, uint64(9), latestNonce) } // The EVM accessors are the read surface the executor actually uses, so each one is pinned against a @@ -172,53 +178,147 @@ func TestStateViewEVMAccessors(t *testing.T) { t.Run("contract", func(t *testing.T) { addr := gigaAddr(contract) require.True(t, stateView.AccountExists(addr)) - require.Equal(t, uint64(3), stateView.GetNonce(addr)) - require.Equal(t, giga.Hash(codeHash), stateView.GetCodeHash(addr)) - require.Equal(t, bytecode, stateView.GetCode(addr)) - require.Equal(t, len(bytecode), stateView.GetCodeSize(addr)) - require.Equal(t, giga.Hash(padLeft32(0xEE)[0:32]), stateView.GetStorage(addr, giga.Hash(slot))) - require.Equal(t, giga.Hash{}, stateView.GetStorage(addr, giga.Hash(slotN(9))), - "an unset slot reads as zero, not as missing") + + nonce, ok := stateView.GetNonce(addr) + require.True(t, ok) + require.Equal(t, uint64(3), nonce) + + hash, ok := stateView.GetCodeHash(addr) + require.True(t, ok) + require.Equal(t, giga.Hash(codeHash), hash) + + code, ok := stateView.GetCode(addr) + require.True(t, ok) + require.Equal(t, bytecode, code) + + size, ok := stateView.GetCodeSize(addr) + require.True(t, ok) + require.Equal(t, len(bytecode), size) + + value, ok := stateView.GetStorage(addr, giga.Hash(slot)) + require.True(t, ok) + require.Equal(t, giga.Hash(padLeft32(0xEE)[0:32]), value) + + value, ok = stateView.GetStorage(addr, giga.Hash(slotN(9))) + require.False(t, ok, "an unset slot reads as missing") + require.Equal(t, giga.Hash{}, value) }) t.Run("account without code", func(t *testing.T) { addr := gigaAddr(eoa) require.True(t, stateView.AccountExists(addr)) - require.Equal(t, uint64(9), stateView.GetNonce(addr)) - require.Equal(t, giga.EmptyCodeHash, stateView.GetCodeHash(addr), - "an account that exists with no code hashes as keccak256(\"\"), not as zero") - require.Nil(t, stateView.GetCode(addr)) - require.Zero(t, stateView.GetCodeSize(addr)) + + nonce, ok := stateView.GetNonce(addr) + require.True(t, ok) + require.Equal(t, uint64(9), nonce) + + hash, ok := stateView.GetCodeHash(addr) + require.False(t, ok, "an account that exists with no code stores no code hash") + require.Equal(t, giga.Hash{}, hash) + + code, ok := stateView.GetCode(addr) + require.False(t, ok) + require.Nil(t, code) + + size, ok := stateView.GetCodeSize(addr) + require.False(t, ok) + require.Zero(t, size) }) t.Run("account that does not exist", func(t *testing.T) { addr := gigaAddr(missing) require.False(t, stateView.AccountExists(addr)) - require.Zero(t, stateView.GetNonce(addr)) - require.Equal(t, giga.Hash{}, stateView.GetCodeHash(addr), - "an account that does not exist hashes as zero, not as keccak256(\"\")") - require.Nil(t, stateView.GetCode(addr)) - require.Zero(t, stateView.GetCodeSize(addr)) - require.Equal(t, giga.Hash{}, stateView.GetStorage(addr, giga.Hash(slot))) + + nonce, ok := stateView.GetNonce(addr) + require.False(t, ok) + require.Zero(t, nonce) + + hash, ok := stateView.GetCodeHash(addr) + require.False(t, ok) + require.Equal(t, giga.Hash{}, hash) + + code, ok := stateView.GetCode(addr) + require.False(t, ok) + require.Nil(t, code) + + size, ok := stateView.GetCodeSize(addr) + require.False(t, ok) + require.Zero(t, size) + + value, ok := stateView.GetStorage(addr, giga.Hash(slot)) + require.False(t, ok) + require.Equal(t, giga.Hash{}, value) }) } -// Balance has no key kind yet, so nothing can write one (store_apply.go passes nil balance changes). -// Refusing is the only honest answer: zero would be indistinguishable from a real zero balance, and -// the caller has no way to tell the two apart. The account below has a nonce, so its row does exist. -func TestStateViewBalancePanicsUntilWritable(t *testing.T) { +// GetBalance reports a balance only where one is stored. The three cases that have to stay apart are an +// account holding a balance, an account whose row exists for some other field, and no account at all — +// the middle one is the case a zero balance and an absent row would otherwise collapse into. +func TestStateViewGetBalance(t *testing.T) { + s := setupTestStore(t) + defer func() { require.NoError(t, s.Close()) }() + + funded := addrN(1) + nonceOnly := addrN(2) + absent := addrN(3) + balance := balanceN(42) + + require.NoError(t, s.CommitStateChanges(1, []*proto.NamedChangeSet{ + namedCS(balancePair(funded, balance), noncePair(nonceOnly, 7)), + })) + + stateView := s.OpenView() + defer stateView.Close() + + got, found := stateView.GetBalance(gigaAddr(funded)) + require.True(t, found) + require.Equal(t, giga.Hash(balance), got) + + got, found = stateView.GetBalance(gigaAddr(nonceOnly)) + require.False(t, found) + require.Equal(t, giga.Hash{}, got) + + got, found = stateView.GetBalance(gigaAddr(absent)) + require.False(t, found) + require.Equal(t, giga.Hash{}, got) +} + +// A balance write alone brings an account into existence, so the other account-level getters have to +// answer for it: the row is real even though no nonce or code was ever written to it. +func TestStateViewBalanceCreatesAccount(t *testing.T) { s := setupTestStore(t) defer func() { require.NoError(t, s.Close()) }() addr := addrN(1) - commitNonce(t, s, 1, addr, 7) + require.NoError(t, s.CommitStateChanges(1, []*proto.NamedChangeSet{namedCS(balancePair(addr, balanceN(9)))})) stateView := s.OpenView() defer stateView.Close() - require.PanicsWithValue(t, - "flatkv: GetBalance is unimplemented; FlatKV does not store balances", - func() { stateView.GetBalance(gigaAddr(addr)) }) + require.True(t, stateView.AccountExists(gigaAddr(addr))) + + nonce, found := stateView.GetNonce(gigaAddr(addr)) + require.True(t, found) + require.Zero(t, nonce) +} + +// Zeroing a balance is how a balance is deleted, and the row goes with it when nothing else holds it up. +func TestStateViewBalanceDeletionRemovesAccount(t *testing.T) { + s := setupTestStore(t) + defer func() { require.NoError(t, s.Close()) }() + + addr := addrN(1) + require.NoError(t, s.CommitStateChanges(1, []*proto.NamedChangeSet{namedCS(balancePair(addr, balanceN(9)))})) + require.NoError(t, s.CommitStateChanges(2, []*proto.NamedChangeSet{namedCS(balanceDeletePair(addr))})) + + stateView := s.OpenView() + defer stateView.Close() + + require.False(t, stateView.AccountExists(gigaAddr(addr))) + + got, found := stateView.GetBalance(gigaAddr(addr)) + require.False(t, found) + require.Equal(t, giga.Hash{}, got) } // Get answers with the value alone. Each row is stored as version||blockHeight||value, so returning @@ -234,6 +334,7 @@ func TestStateViewGetReturnsValues(t *testing.T) { slot := slotN(1) bytecode := []byte{0x60, 0x80} codeHash := codeHashN(0xAB) + balance := balanceN(0x77) require.NoError(t, s.CommitStateChanges(1, []*proto.NamedChangeSet{ namedCS( @@ -241,6 +342,7 @@ func TestStateViewGetReturnsValues(t *testing.T) { codeHashPair(addr, codeHash), codePair(addr, bytecode), storagePair(addr, slot, []byte{0xEE}), + balancePair(addr, balance), noncePair(eoa, 9), ), { @@ -266,6 +368,13 @@ func TestStateViewGetReturnsValues(t *testing.T) { "a code-hash key reads the account row but answers with that one field") }) + t.Run("balance", func(t *testing.T) { + value, found := stateView.Get(keys.EVMStoreKey, keys.BuildEVMKey(keys.EVMKeyBalance, addr[:])) + require.True(t, found) + require.Equal(t, balance[:], value, + "a balance key reads the account row but answers with that one field") + }) + t.Run("storage", func(t *testing.T) { value, found := stateView.Get(keys.EVMStoreKey, keys.BuildEVMKey(keys.EVMKeyStorage, ktype.StorageKey(addr, slot))) @@ -294,7 +403,6 @@ func TestStateViewGetReturnsValues(t *testing.T) { t.Run("code hash of an account with no code", func(t *testing.T) { _, found := stateView.Get(keys.EVMStoreKey, keys.BuildEVMKey(keys.EVMKeyCodeHash, eoa[:])) - require.False(t, found, - "Get reports what is stored; substituting EmptyCodeHash here is GetCodeHash's job") + require.False(t, found, "an account that exists with no code stores no code hash") }) } diff --git a/sei-db/state_db/sc/flatkv/store.go b/sei-db/state_db/sc/flatkv/store.go index 7cb1a8ad55..804c28511c 100644 --- a/sei-db/state_db/sc/flatkv/store.go +++ b/sei-db/state_db/sc/flatkv/store.go @@ -225,7 +225,7 @@ func routePhysicalKey(physicalKey []byte) (string, error) { } kind, _ := keys.ParseEVMKey(innerKey) switch kind { - case ktype.EVMKeyAccount, keys.EVMKeyCodeHash: + case ktype.EVMKeyAccount, keys.EVMKeyCodeHash, keys.EVMKeyBalance: return accountDBDir, nil case keys.EVMKeyCode: return codeDBDir, nil diff --git a/sei-db/state_db/sc/flatkv/store_apply.go b/sei-db/state_db/sc/flatkv/store_apply.go index 361e1a9f4a..73d4841830 100644 --- a/sei-db/state_db/sc/flatkv/store_apply.go +++ b/sei-db/state_db/sc/flatkv/store_apply.go @@ -109,8 +109,9 @@ func (s *CommitStore) prepareWrites( ) (preparedWrites, error) { var out preparedWrites - // A nonce or codehash change carries only its own field, so it has to be merged onto the account as - // it stands right now — a live read, since anything an earlier call at this height wrote counts. + // A nonce, codehash or balance change carries only its own field, so it has to be merged onto the + // account as it stands right now — a live read, since anything an earlier call at this height wrote + // counts. s.phaseTimer.SetPhase("apply_change_sets_read_accounts") readStart := time.Now() accountOld, err := s.readAccountsForMerge(changesByType) @@ -125,7 +126,7 @@ func (s *CommitStore) prepareWrites( accountUpdates, err := mergeAccountUpdates( changesByType[keys.EVMKeyNonce], changesByType[keys.EVMKeyCodeHash], - nil, // TODO: update this when we add a balance key! + changesByType[keys.EVMKeyBalance], ) if err != nil { return out, fmt.Errorf("failed to gather account updates: %w", err) @@ -154,15 +155,20 @@ func (s *CommitStore) prepareWrites( return out, nil } -// readAccountsForMerge reads the accounts that this batch's nonce and codehash changes touch, so those -// partial updates can be merged onto whole accounts. Keys come from both kinds, since either can name -// an account the other does not. +// readAccountsForMerge reads the accounts that this batch's nonce, codehash and balance changes touch, +// so those partial updates can be merged onto whole accounts. Keys come from all three kinds, since any +// one of them can name an account the others do not. func (s *CommitStore) readAccountsForMerge( changesByType map[keys.EVMKeyKind]map[string][]byte, ) (map[string]*vtype.AccountData, error) { - touched := make(map[string]struct{}, - len(changesByType[keys.EVMKeyNonce])+len(changesByType[keys.EVMKeyCodeHash])) - for _, kind := range []keys.EVMKeyKind{keys.EVMKeyNonce, keys.EVMKeyCodeHash} { + accountKinds := []keys.EVMKeyKind{keys.EVMKeyNonce, keys.EVMKeyCodeHash, keys.EVMKeyBalance} + + size := 0 + for _, kind := range accountKinds { + size += len(changesByType[kind]) + } + touched := make(map[string]struct{}, size) + for _, kind := range accountKinds { for key := range changesByType[kind] { touched[key] = struct{}{} } @@ -440,7 +446,8 @@ func mergeAccountUpdates( balanceChanges map[string][]byte, ) (map[string]*vtype.PendingAccountWrite, error) { - updates := make(map[string]*vtype.PendingAccountWrite, len(nonceChanges)+len(codeHashChanges)) + updates := make(map[string]*vtype.PendingAccountWrite, + len(nonceChanges)+len(codeHashChanges)+len(balanceChanges)) for key, nonceChange := range nonceChanges { if nonceChange == nil { diff --git a/sei-db/state_db/sc/flatkv/store_iteration.go b/sei-db/state_db/sc/flatkv/store_iteration.go index d0a5f38247..7431ca6561 100644 --- a/sei-db/state_db/sc/flatkv/store_iteration.go +++ b/sei-db/state_db/sc/flatkv/store_iteration.go @@ -96,20 +96,20 @@ func (s *CommitStore) Iterator(store string, start []byte, end []byte, ascending return iterators.NewDomainIterator(iter, start, end) } -// buildEvmIterator merges the five EVM lanes — code, storage, misc under the evm/ module, account -// nonce and account codehash — into one iterator over logical memiavl keys. Balance is not among them: -// FlatKV does not store it yet. +// buildEvmIterator merges the six EVM lanes — code, storage, misc under the evm/ module, account +// nonce, account codehash and account balance — into one iterator over logical memiavl keys. func (s *CommitStore) buildEvmIterator( start []byte, end []byte, ascending bool, ) (dbm.Iterator, error) { - lanes := make([]dbm.Iterator, 0, 5) + lanes := make([]dbm.Iterator, 0, 6) // Each optimized lane scans its own physical keyspace and re-labels rows to - // a logical key. The codehash lane is the only one whose logical type byte - // (0x08) differs from the physical byte it scans (account rows live under - // 0x0a), so its bounds must be translated against the account keyspace. + // a logical key. The codehash and balance lanes have logical type bytes + // (0x08, 0x21) that differ from the physical byte they scan (account rows + // live under 0x0a), so their bounds must be translated against the account + // keyspace. for _, laneSpec := range s.evmLaneSpecs() { lower, upper, empty, err := laneSpec.bounds(start, end) if err != nil { @@ -137,8 +137,6 @@ func (s *CommitStore) buildEvmIterator( } lanes = append(lanes, miscLane) - // TODO: once we move account balances to FlatKV, we need to add a lane for them here. - // NewMergingIterator takes ownership of the lanes and closes all of them if // construction fails, so we must not close them again here (Pebble's Close is // not idempotent and a double close could corrupt its iterator pool). @@ -154,8 +152,8 @@ type evmLaneSpec struct { // logical is the type byte callers query with. logical keys.EVMKeyKind // physical is the type byte the lane's rows are stored under; equal to - // logical for every lane except codehash, whose rows live in the account DB - // under 0x0a. + // logical for every lane except codehash and balance, whose rows live in the + // account DB under 0x0a. physical keys.EVMKeyKind // build constructs the iterator that scans the lane's physical keyspace. build func(lower []byte, upper []byte, ascending bool) (dbm.Iterator, error) @@ -190,6 +188,7 @@ func (s *CommitStore) evmLaneSpecs() []evmLaneSpec { {keys.EVMKeyCode, keys.EVMKeyCode, s.buildCodeLane}, {keys.EVMKeyCodeHash, ktype.EVMKeyAccount, s.buildAccountCodehashLane}, {keys.EVMKeyNonce, ktype.EVMKeyAccount, s.buildAccountNonceLane}, + {keys.EVMKeyBalance, ktype.EVMKeyAccount, s.buildAccountBalanceLane}, } } @@ -206,7 +205,7 @@ func evmLaneBounds( // logicalPrefix is the lane's logical type byte (the prefix callers use, e.g. 0x08 for codehash). logicalPrefix byte, // physByte is the physical type byte the rows are stored under. It equals logicalPrefix for every - // lane except codehash, whose rows live in the account DB under 0x0a. + // lane except codehash and balance, whose rows live in the account DB under 0x0a. physByte byte, ) ( // lower is the physical inclusive lower bound for the lane. @@ -414,6 +413,34 @@ func (s *CommitStore) buildAccountCodehashLane( return buildLane(s.accountStore, lowerBound, upperBound, ascending, transform) } +// buildAccountBalanceLane iterates the account store, emitting the EVM balance key and the 32-byte +// balance. It walks the same values as buildAccountNonceLane and projects a different field. An account +// whose balance is zero holds nothing, so it is skipped rather than emitted as a zero balance. +func (s *CommitStore) buildAccountBalanceLane( + lowerBound, upperBound []byte, + ascending bool, +) (dbm.Iterator, error) { + transform := func(key []byte, value []byte) ([]byte, []byte, bool, error) { + if len(value) == 0 { + return nil, nil, true, nil + } + _, addrBytes, err := ktype.StripEVMPhysicalKey(key) + if err != nil { + return nil, nil, false, err + } + ad, err := vtype.DeserializeAccountData(value) + if err != nil { + return nil, nil, false, err + } + balance := ad.GetBalance() + if *balance == (vtype.Balance{}) { + return nil, nil, true, nil + } + return keys.BuildEVMKey(keys.EVMKeyBalance, addrBytes), balance[:], false, nil + } + return buildLane(s.accountStore, lowerBound, upperBound, ascending, transform) +} + func closeIterators(iters []dbm.Iterator) { for _, it := range iters { if it != nil { diff --git a/sei-db/state_db/sc/flatkv/store_iteration_test.go b/sei-db/state_db/sc/flatkv/store_iteration_test.go index bdfe0b4c00..9e4f50dfab 100644 --- a/sei-db/state_db/sc/flatkv/store_iteration_test.go +++ b/sei-db/state_db/sc/flatkv/store_iteration_test.go @@ -72,9 +72,12 @@ func TestEvmIterator(t *testing.T) { miscEnd := ktype.PrefixEnd(miscStart) nonceStart := []byte{0x0a} nonceEnd := ktype.PrefixEnd(nonceStart) + balanceStart := []byte{0x21} + balanceEnd := ktype.PrefixEnd(balanceStart) midAddr := addrN(0x80) crossSpanStart := keys.BuildEVMKey(keys.EVMKeyCodeHash, midAddr[:]) // 0x08 || addr crossSpanEnd := keys.BuildEVMKey(keys.EVMKeyNonce, midAddr[:]) // 0x0a || addr + balanceMid := keys.BuildEVMKey(keys.EVMKeyBalance, midAddr[:]) // 0x21 || addr storageResumeStart := evmStorageKey(addrN(0x40), slotN(0x10)) // 0x03 || addr || slot cases := []struct { @@ -94,6 +97,10 @@ func TestEvmIterator(t *testing.T) { {name: "nonce prefix range descending", start: nonceStart, end: nonceEnd, ascending: false}, {name: "cross span codehash to nonce ascending", start: crossSpanStart, end: crossSpanEnd, ascending: true}, {name: "cross span codehash to nonce descending", start: crossSpanStart, end: crossSpanEnd, ascending: false}, + {name: "balance prefix range ascending", start: balanceStart, end: balanceEnd, ascending: true}, + {name: "balance prefix range descending", start: balanceStart, end: balanceEnd, ascending: false}, + {name: "cross span nonce to balance ascending", start: nonceStart, end: balanceMid, ascending: true}, + {name: "cross span nonce to balance descending", start: nonceStart, end: balanceMid, ascending: false}, {name: "storage resume ascending", start: storageResumeStart, end: nil, ascending: true}, } @@ -452,7 +459,7 @@ func TestEvmIteratorDifferential(t *testing.T) { for _, e := range fixture.Sorted { pool = append(pool, bytes.Clone(e.Key)) } - for _, p := range [][]byte{{0x03}, {0x07}, {0x08}, {0x09}, {0x0a}} { + for _, p := range [][]byte{{0x03}, {0x07}, {0x08}, {0x09}, {0x0a}, {0x21}} { pool = append(pool, bytes.Clone(p), ktype.PrefixEnd(p)) } @@ -599,9 +606,12 @@ func buildEvmIteratorFixture(t *testing.T, seed int64) *evmIteratorFixture { gen.addAccount(disp) } - // Nonce-only account (no codehash key in iterator output). + // Nonce-only account (no codehash or balance key in iterator output). gen.addNonceOnlyAccount() + // Balance-only account: the row exists because of its balance alone. + gen.addBalanceOnlyAccount() + // Malformed account-prefixed misc key: lands in the account physical // region (evm/0x0a...) but is routed to miscDB, exercising the overlap // between the misc lane and the account-derived lanes. @@ -728,6 +738,15 @@ func (g *evmIteratorGenerator) rngCodeHash() vtype.CodeHash { return h } +func (g *evmIteratorGenerator) rngBalance() vtype.Balance { + var b vtype.Balance + g.rng.Read(b[:]) + if b == (vtype.Balance{}) { + b[0] = 1 + } + return b +} + func (g *evmIteratorGenerator) recordOverlap(key, value []byte) { *g.overlaps = append(*g.overlaps, evmIteratorEntry{ Key: bytes.Clone(key), @@ -839,32 +858,44 @@ func (g *evmIteratorGenerator) addAccount(disp evmIteratorDisposition) { for ch1 == ch2 { ch2 = g.rngCodeHash() } + bal1 := g.rngBalance() + bal2 := g.rngBalance() + for bal1 == bal2 { + bal2 = g.rngBalance() + } nonceKey := keys.BuildEVMKey(keys.EVMKeyNonce, addr[:]) codeHashKey := keys.BuildEVMKey(keys.EVMKeyCodeHash, addr[:]) + balanceKey := keys.BuildEVMKey(keys.EVMKeyBalance, addr[:]) switch disp { case dispositionPebbleOnly: - *g.batch1 = append(*g.batch1, noncePair(addr, n1), codeHashPair(addr, ch1)) + *g.batch1 = append(*g.batch1, noncePair(addr, n1), codeHashPair(addr, ch1), balancePair(addr, bal1)) recordNonceLatest(g.latest, addr, n1) recordCodeHashLatest(g.latest, addr, ch1) + recordBalanceLatest(g.latest, addr, bal1) case dispositionPendingOnly: - *g.batch2 = append(*g.batch2, noncePair(addr, n2), codeHashPair(addr, ch2)) + *g.batch2 = append(*g.batch2, noncePair(addr, n2), codeHashPair(addr, ch2), balancePair(addr, bal2)) recordNonceLatest(g.latest, addr, n2) recordCodeHashLatest(g.latest, addr, ch2) + recordBalanceLatest(g.latest, addr, bal2) case dispositionOverlap: - *g.batch1 = append(*g.batch1, noncePair(addr, n1), codeHashPair(addr, ch1)) - *g.batch2 = append(*g.batch2, noncePair(addr, n2), codeHashPair(addr, ch2)) + *g.batch1 = append(*g.batch1, noncePair(addr, n1), codeHashPair(addr, ch1), balancePair(addr, bal1)) + *g.batch2 = append(*g.batch2, noncePair(addr, n2), codeHashPair(addr, ch2), balancePair(addr, bal2)) recordNonceLatest(g.latest, addr, n2) recordCodeHashLatest(g.latest, addr, ch2) + recordBalanceLatest(g.latest, addr, bal2) g.recordOverlap(nonceKey, nonceBytes(n2)) g.recordOverlap(codeHashKey, ch2[:]) + g.recordOverlap(balanceKey, bal2[:]) case dispositionTombstone: - *g.batch1 = append(*g.batch1, noncePair(addr, n1), codeHashPair(addr, ch1)) - *g.batch2 = append(*g.batch2, nonceDeletePair(addr), codeHashDeletePair(addr)) + *g.batch1 = append(*g.batch1, noncePair(addr, n1), codeHashPair(addr, ch1), balancePair(addr, bal1)) + *g.batch2 = append(*g.batch2, + nonceDeletePair(addr), codeHashDeletePair(addr), balanceDeletePair(addr)) removeAccountLatest(g.latest, addr) g.recordTombstone(nonceKey) g.recordTombstone(codeHashKey) + g.recordTombstone(balanceKey) } } @@ -875,6 +906,16 @@ func (g *evmIteratorGenerator) addNonceOnlyAccount() { recordNonceLatest(g.latest, addr, n) } +// addBalanceOnlyAccount writes an account held up by its balance alone. The balance lane emits it, the +// codehash lane skips it, and the nonce lane emits the zero nonce every existing row carries. +func (g *evmIteratorGenerator) addBalanceOnlyAccount() { + addr := g.uniqueAddr() + bal := g.rngBalance() + *g.batch1 = append(*g.batch1, balancePair(addr, bal)) + recordBalanceLatest(g.latest, addr, bal) + recordNonceLatest(g.latest, addr, 0) +} + func bankNamedCS(pairs ...*proto.KVPair) *proto.NamedChangeSet { return &proto.NamedChangeSet{ Name: "bank", @@ -947,9 +988,19 @@ func recordCodeHashLatest(latest map[string]evmIteratorEntry, addr ktype.Address setEvmLatest(latest, key, ch[:]) } +func recordBalanceLatest(latest map[string]evmIteratorEntry, addr ktype.Address, bal vtype.Balance) { + key := keys.BuildEVMKey(keys.EVMKeyBalance, addr[:]) + if bal == (vtype.Balance{}) { + removeEvmLatest(latest, key) + return + } + setEvmLatest(latest, key, bal[:]) +} + func removeAccountLatest(latest map[string]evmIteratorEntry, addr ktype.Address) { removeEvmLatest(latest, keys.BuildEVMKey(keys.EVMKeyNonce, addr[:])) removeEvmLatest(latest, keys.BuildEVMKey(keys.EVMKeyCodeHash, addr[:])) + removeEvmLatest(latest, keys.BuildEVMKey(keys.EVMKeyBalance, addr[:])) } func sortedEvmEntries(latest map[string]evmIteratorEntry) []evmIteratorEntry { diff --git a/sei-db/state_db/sc/flatkv/store_read.go b/sei-db/state_db/sc/flatkv/store_read.go index 266d58e9a7..cda9a0525f 100644 --- a/sei-db/state_db/sc/flatkv/store_read.go +++ b/sei-db/state_db/sc/flatkv/store_read.go @@ -55,7 +55,7 @@ func (s *CommitStore) Get(moduleName string, key []byte) ([]byte, bool) { } return value, value != nil - case keys.EVMKeyNonce, keys.EVMKeyCodeHash: + case keys.EVMKeyNonce, keys.EVMKeyCodeHash, keys.EVMKeyBalance: accountData, err := s.getAccountData(keyBytes) if err != nil { panic(fmt.Sprintf("flatkv: Get account key %x: %v", key, err)) @@ -63,19 +63,7 @@ func (s *CommitStore) Get(moduleName string, key []byte) ([]byte, bool) { if accountData == nil || accountData.IsDelete() { return nil, false } - - if kind == keys.EVMKeyNonce { - nonceBytes := make([]byte, vtype.NonceLen) - binary.BigEndian.PutUint64(nonceBytes, accountData.GetNonce()) - return nonceBytes, true - } - // CodeHash - codeHash := accountData.GetCodeHash() - var zeroCodeHash vtype.CodeHash - if *codeHash == zeroCodeHash { - return nil, false - } - return codeHash[:], true + return accountFieldValue(kind, accountData) case keys.EVMKeyCode: value, err := s.getCodeValue(keyBytes) @@ -122,7 +110,7 @@ func (s *CommitStore) GetBlockHeightModified(moduleName string, key []byte) (int } return sd.GetBlockHeight(), true, nil - case keys.EVMKeyNonce, keys.EVMKeyCodeHash: + case keys.EVMKeyNonce, keys.EVMKeyCodeHash, keys.EVMKeyBalance: accountData, err := s.getAccountData(keyBytes) if err != nil { return -1, false, err @@ -176,6 +164,39 @@ func parseRow[T vtype.VType](raw []byte, found bool, parse func([]byte) (T, erro return parse(raw) } +// accountFieldValue projects the field that kind names out of an account row, encoded the way the +// logical EVM key for that field carries it: eight big-endian bytes for a nonce, thirty-two for a code +// hash or a balance. The second return reports whether that field is set. +// +// A zero code hash and a zero balance both report false. A deletion is stored by zeroing the field +// rather than by removing anything (see mergeAccountUpdates), so answering "present" for a zero would +// hand back a key the block deleted. The nonce is the exception: it answers for every row that exists. +func accountFieldValue(kind keys.EVMKeyKind, account *vtype.AccountData) ([]byte, bool) { + switch kind { + case keys.EVMKeyNonce: + nonceBytes := make([]byte, vtype.NonceLen) + binary.BigEndian.PutUint64(nonceBytes, account.GetNonce()) + return nonceBytes, true + + case keys.EVMKeyCodeHash: + codeHash := account.GetCodeHash() + if *codeHash == (vtype.CodeHash{}) { + return nil, false + } + return codeHash[:], true + + case keys.EVMKeyBalance: + balance := account.GetBalance() + if *balance == (vtype.Balance{}) { + return nil, false + } + return balance[:], true + + default: + panic(fmt.Sprintf("flatkv: %v does not name an account field", kind)) + } +} + func (s *CommitStore) getAccountData(keyBytes []byte) (*vtype.AccountData, error) { if len(keyBytes) != ktype.AddressLen { return nil, fmt.Errorf("accountDB: expected key length %d, got %d", ktype.AddressLen, len(keyBytes)) diff --git a/sei-db/state_db/sc/flatkv/store_read_test.go b/sei-db/state_db/sc/flatkv/store_read_test.go index 3c7d822f4b..3a749ac91f 100644 --- a/sei-db/state_db/sc/flatkv/store_read_test.go +++ b/sei-db/state_db/sc/flatkv/store_read_test.go @@ -235,6 +235,7 @@ func TestGetAllKeyTypesFromCommittedDB(t *testing.T) { storageVal := []byte{0x42} miscKey := append([]byte{0x09}, addr[:]...) miscVal := []byte{0x99, 0x88} + balance := balanceN(0x33) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ namedCS( @@ -242,6 +243,7 @@ func TestGetAllKeyTypesFromCommittedDB(t *testing.T) { noncePair(addr, 7), codeHashPair(addr, ch), codePair(addr, bytecode), + balancePair(addr, balance), ), makeChangeSet(miscKey, miscVal, false), })) @@ -267,6 +269,11 @@ func TestGetAllKeyTypesFromCommittedDB(t *testing.T) { require.True(t, found, "code should be found") require.Equal(t, bytecode, got) + // Balance + got, found = s.Get(keys.EVMStoreKey, keys.BuildEVMKey(keys.EVMKeyBalance, addr[:])) + require.True(t, found, "balance should be found") + require.Equal(t, balance[:], got) + // Misc got, found = s.Get(keys.EVMStoreKey, miscKey) require.True(t, found, "misc should be found") @@ -281,6 +288,8 @@ func TestGetAllKeyTypesFromCommittedDB(t *testing.T) { require.True(t, found) found = s.Has(keys.EVMStoreKey, keys.BuildEVMKey(keys.EVMKeyCode, addr[:])) require.True(t, found) + found = s.Has(keys.EVMStoreKey, keys.BuildEVMKey(keys.EVMKeyBalance, addr[:])) + require.True(t, found) found = s.Has(keys.EVMStoreKey, miscKey) require.True(t, found) } @@ -589,6 +598,7 @@ func TestGetAfterReopenAllKeyTypes(t *testing.T) { slot := slotN(0x01) ch := codeHashN(0xAA) bytecode := []byte{0x60, 0x80} + balance := balanceN(0xC7) miscKey := append([]byte{0x09}, addr[:]...) // Phase 1: write everything and close @@ -606,6 +616,7 @@ func TestGetAfterReopenAllKeyTypes(t *testing.T) { codeHashPair(addr, ch), codePair(addr, bytecode), storagePair(addr, slot, []byte{0x42}), + balancePair(addr, balance), ), makeChangeSet(miscKey, []byte{0x77}, false), })) @@ -638,6 +649,10 @@ func TestGetAfterReopenAllKeyTypes(t *testing.T) { require.True(t, found, "code should survive reopen") require.Equal(t, bytecode, got) + got, found = s2.Get(keys.EVMStoreKey, keys.BuildEVMKey(keys.EVMKeyBalance, addr[:])) + require.True(t, found, "balance should survive reopen") + require.Equal(t, balance[:], got) + got, found = s2.Get(keys.EVMStoreKey, miscKey) require.True(t, found, "misc should survive reopen") require.Equal(t, []byte{0x77}, got) diff --git a/sei-db/state_db/sc/flatkv/store_replay_test.go b/sei-db/state_db/sc/flatkv/store_replay_test.go index f448315438..0b6e69d43e 100644 --- a/sei-db/state_db/sc/flatkv/store_replay_test.go +++ b/sei-db/state_db/sc/flatkv/store_replay_test.go @@ -51,7 +51,12 @@ func TestCatchupReplaysAlreadyAppliedBlockOnSeededStore(t *testing.T) { addr := ktype.Address{0xAB} slot := ktype.Slot{0xCD} key := keys.BuildEVMKey(keys.EVMKeyStorage, ktype.StorageKey(addr, slot)) - cs := makeChangeSet(key, padLeft32(0x11), false) + balanceKey := keys.BuildEVMKey(keys.EVMKeyBalance, addr[:]) + balanceVal := balanceN(0x6D) + cs := namedCS( + &proto.KVPair{Key: key, Value: padLeft32(0x11)}, + &proto.KVPair{Key: balanceKey, Value: balanceVal[:]}, + ) // History legally begins at 10, so this is a lagging watermark rather than a store that skipped // blocks 1-9. @@ -71,10 +76,16 @@ func TestCatchupReplaysAlreadyAppliedBlockOnSeededStore(t *testing.T) { require.Equal(t, int64(10), reopened.Version()) require.Equal(t, hashAfterCommit, rootHash(reopened)) - height, found, err := reopened.GetBlockHeightModified(keys.EVMStoreKey, key) - require.NoError(t, err) - require.True(t, found) - require.Equal(t, int64(10), height) + for _, replayed := range [][]byte{key, balanceKey} { + height, found, err := reopened.GetBlockHeightModified(keys.EVMStoreKey, replayed) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, int64(10), height) + } + + got, found := reopened.Get(keys.EVMStoreKey, balanceKey) + require.True(t, found, "balance should survive WAL replay") + require.Equal(t, balanceVal[:], got) } // gappedWALStore returns a store whose WAL holds exactly one block, at firstBlock, with nothing before it. diff --git a/sei-db/state_db/sc/flatkv/store_write_test.go b/sei-db/state_db/sc/flatkv/store_write_test.go index 3e6389328b..c4f85cdc2f 100644 --- a/sei-db/state_db/sc/flatkv/store_write_test.go +++ b/sei-db/state_db/sc/flatkv/store_write_test.go @@ -794,6 +794,108 @@ func TestMultipleApplyAccountFieldsPreservesOther(t *testing.T) { require.Equal(t, codeHash[:], chVal) } +// A balance write carries only the balance, so it has to be merged onto the account as it already +// stands. This is the case that breaks first if the balance kind is left out of the set of kinds whose +// accounts are read back before the merge: the write lands on an empty account and takes the nonce and +// the code hash with it. +func TestBalanceWritePreservesOtherAccountFields(t *testing.T) { + s := setupTestStore(t) + defer s.Close() + + addr := ktype.Address{0xBB} + nonceKey := keys.BuildEVMKey(keys.EVMKeyNonce, addr[:]) + codeHashKey := keys.BuildEVMKey(keys.EVMKeyCodeHash, addr[:]) + balanceKey := keys.BuildEVMKey(keys.EVMKeyBalance, addr[:]) + codeHash := codeHashN(0x7C) + balance := balanceN(42) + + cs1 := &proto.NamedChangeSet{ + Name: "evm", + Changeset: proto.ChangeSet{ + Pairs: []*proto.KVPair{ + {Key: nonceKey, Value: nonceBytes(9)}, + {Key: codeHashKey, Value: codeHash[:]}, + }, + }, + } + require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs1})) + commitAndCheck(t, s) + + require.NoError(t, s.ApplyChangeSets(s.Version()+1, + []*proto.NamedChangeSet{makeChangeSet(balanceKey, balance[:], false)})) + commitAndCheck(t, s) + + nonceVal, ok := s.Get(keys.EVMStoreKey, nonceKey) + require.True(t, ok) + require.Equal(t, nonceBytes(9), nonceVal, "nonce should be preserved after balance update") + + chVal, ok := s.Get(keys.EVMStoreKey, codeHashKey) + require.True(t, ok) + require.Equal(t, codeHash[:], chVal, "code hash should be preserved after balance update") + + balVal, ok := s.Get(keys.EVMStoreKey, balanceKey) + require.True(t, ok) + require.Equal(t, balance[:], balVal) +} + +// All three account fields written in one block land in one physical row. +func TestAccountFieldsMergeIntoOneRow(t *testing.T) { + s := setupTestStore(t) + defer s.Close() + + addr := ktype.Address{0xCD} + codeHash := codeHashN(0x11) + balance := balanceN(7) + + cs := &proto.NamedChangeSet{ + Name: "evm", + Changeset: proto.ChangeSet{ + Pairs: []*proto.KVPair{ + {Key: keys.BuildEVMKey(keys.EVMKeyNonce, addr[:]), Value: nonceBytes(3)}, + {Key: keys.BuildEVMKey(keys.EVMKeyCodeHash, addr[:]), Value: codeHash[:]}, + {Key: keys.BuildEVMKey(keys.EVMKeyBalance, addr[:]), Value: balance[:]}, + }, + }, + } + require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) + + accountWrite := stagedRow(t, s.accountStore, accountPhysKey(addr), vtype.DeserializeAccountData) + require.NotNil(t, accountWrite) + require.Equal(t, uint64(3), accountWrite.GetNonce()) + require.Equal(t, &codeHash, accountWrite.GetCodeHash()) + require.Equal(t, &balance, accountWrite.GetBalance()) +} + +// A balance is the only field an account needs to exist, and zeroing it is how one is deleted, so the +// row goes away with it. +func TestBalanceOnlyAccountDeletedWhenZeroed(t *testing.T) { + s := setupTestStore(t) + defer s.Close() + + addr := ktype.Address{0xCE} + balanceKey := keys.BuildEVMKey(keys.EVMKeyBalance, addr[:]) + balance := balanceN(5) + + require.NoError(t, s.ApplyChangeSets(s.Version()+1, + []*proto.NamedChangeSet{makeChangeSet(balanceKey, balance[:], false)})) + commitAndCheck(t, s) + + count, err := CountKeys(s) + require.NoError(t, err) + require.Equal(t, int64(1), count) + + require.NoError(t, s.ApplyChangeSets(s.Version()+1, + []*proto.NamedChangeSet{makeChangeSet(balanceKey, nil, true)})) + commitAndCheck(t, s) + + _, ok := s.Get(keys.EVMStoreKey, balanceKey) + require.False(t, ok) + + count, err = CountKeys(s) + require.NoError(t, err) + require.Zero(t, count, "zeroing the last field must remove the account row") +} + // ============================================================================= // LtHash determinism // ============================================================================= diff --git a/sei-db/state_db/sc/flatkv/testutil_test.go b/sei-db/state_db/sc/flatkv/testutil_test.go index 6aabbd5ddd..0d6f7626c9 100644 --- a/sei-db/state_db/sc/flatkv/testutil_test.go +++ b/sei-db/state_db/sc/flatkv/testutil_test.go @@ -212,6 +212,12 @@ func codeHashN(n byte) vtype.CodeHash { return h } +func balanceN(n byte) vtype.Balance { + var b vtype.Balance + b[31] = n + return b +} + func noncePair(addr ktype.Address, nonce uint64) *proto.KVPair { return &proto.KVPair{ Key: keys.BuildEVMKey(keys.EVMKeyNonce, addr[:]), @@ -268,6 +274,20 @@ func codeHashDeletePair(addr ktype.Address) *proto.KVPair { } } +func balancePair(addr ktype.Address, balance vtype.Balance) *proto.KVPair { + return &proto.KVPair{ + Key: keys.BuildEVMKey(keys.EVMKeyBalance, addr[:]), + Value: balance[:], + } +} + +func balanceDeletePair(addr ktype.Address) *proto.KVPair { + return &proto.KVPair{ + Key: keys.BuildEVMKey(keys.EVMKeyBalance, addr[:]), + Delete: true, + } +} + func namedCS(pairs ...*proto.KVPair) *proto.NamedChangeSet { return &proto.NamedChangeSet{ Name: "evm", diff --git a/sei-db/state_db/ss/composite/store.go b/sei-db/state_db/ss/composite/store.go index 7707010c0a..cd8f18fb7d 100644 --- a/sei-db/state_db/ss/composite/store.go +++ b/sei-db/state_db/ss/composite/store.go @@ -470,7 +470,7 @@ func stripEVMFromChangesets(changesets []*proto.NamedChangeSet) []*proto.NamedCh // convertFlatKVNodes transforms a single FlatKV physical-key snapshot node // into one or more SS nodes by stripping the module prefix from the key, // deserializing the vtype metadata from the value, and (for merged account -// rows) splitting into separate nonce and codeHash nodes. +// rows) splitting into separate nonce, codeHash and balance nodes. // // For EVM-specific keys (account, storage, code) the output StoreKey is "evm". // For legacy keys the original module name is preserved so they route back to @@ -529,6 +529,13 @@ func convertFlatKVNodes(node types.SnapshotNode) ([]types.SnapshotNode, error) { Value: append([]byte(nil), codeHash[:]...), }) } + if balance := acct.GetBalance(); *balance != (vtype.Balance{}) { + nodes = append(nodes, types.SnapshotNode{ + StoreKey: evm.EVMStoreKey, + Key: keys.BuildEVMKey(keys.EVMKeyBalance, strippedKey), + Value: append([]byte(nil), balance[:]...), + }) + } return nodes, nil case keys.EVMKeyStorage: diff --git a/sei-db/state_db/ss/composite/store_test.go b/sei-db/state_db/ss/composite/store_test.go index 17c771b83f..7c506f285d 100644 --- a/sei-db/state_db/ss/composite/store_test.go +++ b/sei-db/state_db/ss/composite/store_test.go @@ -1034,7 +1034,12 @@ func TestImport_OnlyEvmFlatkvModule(t *testing.T) { slot[31] = 0xAA storageVal := [32]byte{0: 0xBB} - acctVal := vtype.NewAccountData().SetNonce(42).SetCodeHash(&vtype.CodeHash{0: 0xCC}).Serialize() + balance := vtype.Balance{31: 0xDD} + acctVal := vtype.NewAccountData(). + SetNonce(42). + SetCodeHash(&vtype.CodeHash{0: 0xCC}). + SetBalance(&balance). + Serialize() storVal := vtype.NewStorageData().SetValue(&storageVal).Serialize() physAcct := ktype.EVMPhysicalKey(commonevm.EVMKeyNonce, addr1) @@ -1042,6 +1047,7 @@ func TestImport_OnlyEvmFlatkvModule(t *testing.T) { nonceKey := commonevm.BuildEVMKey(commonevm.EVMKeyNonce, addr1) codeHashKey := commonevm.BuildEVMKey(commonevm.EVMKeyCodeHash, addr1) + balanceKey := commonevm.BuildEVMKey(commonevm.EVMKeyBalance, addr1) storageKey := commonevm.BuildEVMKey(commonevm.EVMKeyStorage, append(addr2, slot...)) nonceBuf := make([]byte, 8) @@ -1076,6 +1082,10 @@ func TestImport_OnlyEvmFlatkvModule(t *testing.T) { require.NoError(t, err) require.Equal(t, vtype.CodeHash{0: 0xCC}, vtype.CodeHash(evmCodeHash)) + evmBalance, err := store.evmStore.Get(evm.EVMStoreKey, 1, balanceKey) + require.NoError(t, err) + require.Equal(t, balance, vtype.Balance(evmBalance)) + evmStor, err := store.evmStore.Get(evm.EVMStoreKey, 1, storageKey) require.NoError(t, err) require.Equal(t, storageVal[:], evmStor) @@ -1088,6 +1098,31 @@ func TestImport_OnlyEvmFlatkvModule(t *testing.T) { } } +// An account row carries a balance field whether or not a balance was ever written to it, so the split +// has to emit a balance node only where the field is set. Emitting a zero would invent a key that no +// balance write produced. +func TestImport_ZeroBalanceEmitsNoBalanceKey(t *testing.T) { + addr := make([]byte, 20) + addr[19] = 0x07 + + acctVal := vtype.NewAccountData().SetNonce(11).Serialize() + physAcct := ktype.EVMPhysicalKey(commonevm.EVMKeyNonce, addr) + balanceKey := commonevm.BuildEVMKey(commonevm.EVMKeyBalance, addr) + + store, cleanup := setupImportTestStore(t, true) + defer cleanup() + + ch := make(chan types.SnapshotNode, 2) + go feedNodes(ch, []types.SnapshotNode{ + {StoreKey: commonevm.FlatKVStoreKey, Key: physAcct, Value: acctVal}, + }) + require.NoError(t, store.Import(1, ch)) + + found, err := store.evmStore.Has(evm.EVMStoreKey, 1, balanceKey) + require.NoError(t, err) + require.False(t, found) +} + func TestImport_BothEvmAndEvmFlatkv(t *testing.T) { addr := make([]byte, 20) addr[19] = 0x03 diff --git a/sei-db/state_db/ss/evm/config_test.go b/sei-db/state_db/ss/evm/config_test.go index c214dd68f3..de5cb59120 100644 --- a/sei-db/state_db/ss/evm/config_test.go +++ b/sei-db/state_db/ss/evm/config_test.go @@ -23,8 +23,7 @@ func TestAllEVMStoreTypes(t *testing.T) { require.True(t, typeSet[StoreStorage], "StoreStorage should be in AllEVMStoreTypes") require.True(t, typeSet[StoreMisc], "StoreMisc should be in AllEVMStoreTypes") - // Balance should NOT be present (reserved for future) - require.False(t, typeSet[StoreBalance], "StoreBalance should not be in AllEVMStoreTypes yet") + require.True(t, typeSet[StoreBalance], "StoreBalance should be in AllEVMStoreTypes") } func TestStoreTypeName(t *testing.T) { diff --git a/sei-db/state_db/ss/evm/db_test.go b/sei-db/state_db/ss/evm/db_test.go index a3a8b92efa..a76e01bcc3 100644 --- a/sei-db/state_db/ss/evm/db_test.go +++ b/sei-db/state_db/ss/evm/db_test.go @@ -334,6 +334,7 @@ func TestEVMStateStoreMultipleSubDBs(t *testing.T) { codeKey := append([]byte{0x07}, addr...) storageKey := append([]byte{0x03}, append(addr, slot...)...) legacyKey := append([]byte{0x01}, addr...) + balanceKey := append([]byte{0x21}, addr...) cs := []*proto.NamedChangeSet{ { @@ -345,6 +346,7 @@ func TestEVMStateStoreMultipleSubDBs(t *testing.T) { {Key: codeKey, Value: []byte{0x60, 0x80}}, {Key: storageKey, Value: []byte("slot_val")}, {Key: legacyKey, Value: []byte("sei1abc")}, + {Key: balanceKey, Value: []byte("balance_val")}, }, }, }, @@ -361,6 +363,7 @@ func TestEVMStateStoreMultipleSubDBs(t *testing.T) { {"Code", codeKey, []byte{0x60, 0x80}}, {"Storage", storageKey, []byte("slot_val")}, {"Legacy", legacyKey, []byte("sei1abc")}, + {"Balance", balanceKey, []byte("balance_val")}, } for _, tc := range tests { @@ -481,6 +484,15 @@ func TestParseKey(t *testing.T) { require.Equal(t, addr, stripped) }) + t.Run("Parse balance key", func(t *testing.T) { + addr := make([]byte, 20) + key := append([]byte{0x21}, addr...) + + storeType, stripped := commonevm.ParseEVMKey(key) + require.Equal(t, StoreBalance, storeType) + require.Equal(t, addr, stripped) + }) + t.Run("Unknown key prefix goes to legacy", func(t *testing.T) { key := []byte{0xff, 0x01, 0x02} @@ -551,6 +563,7 @@ func TestEVMStateStoreSeparatedBucketIteration(t *testing.T) { nonceAddr := make([]byte, 20) nonceAddr[0] = 0x33 nonceKey := append([]byte{0x0a}, nonceAddr...) + balanceKey := append([]byte{0x21}, nonceAddr...) cs := []*proto.NamedChangeSet{{ Name: EVMStoreKey, @@ -559,6 +572,7 @@ func TestEVMStateStoreSeparatedBucketIteration(t *testing.T) { {Key: v1Key, Value: []byte("addr_v1")}, {Key: v2Key, Value: []byte("addr_v2")}, {Key: nonceKey, Value: []byte{0x07}}, + {Key: balanceKey, Value: []byte{0x2A}}, }, }, }} diff --git a/sei-db/state_db/ss/evm/types.go b/sei-db/state_db/ss/evm/types.go index 5f0d9d77a0..cdaf2e4bd8 100644 --- a/sei-db/state_db/ss/evm/types.go +++ b/sei-db/state_db/ss/evm/types.go @@ -12,8 +12,8 @@ const EVMStoreKey = commonevm.EVMStoreKey type EVMStoreType = commonevm.EVMKeyKind // NumEVMStoreTypes is the number of active EVM store key namespaces. -// Used for pre-allocating maps. Types: Nonce, CodeHash, Code, Storage, Legacy. -const NumEVMStoreTypes = 5 +// Used for pre-allocating maps. Types: Nonce, CodeHash, Code, Storage, Legacy, Balance. +const NumEVMStoreTypes = 6 // Re-export EVMKeyKind constants for convenience const ( @@ -23,12 +23,10 @@ const ( StoreCode = commonevm.EVMKeyCode StoreStorage = commonevm.EVMKeyStorage StoreMisc = commonevm.EVMKeyMisc // Catch-all: codesize, address mappings, receipts, etc. - // StoreBalance is reserved for future migration; balances currently use tendermint store - StoreBalance EVMStoreType = 100 + StoreBalance = commonevm.EVMKeyBalance ) // AllEVMStoreTypes returns all EVM store types that have separate DBs. -// Note: Balance is not included until migration from tendermint store. func AllEVMStoreTypes() []EVMStoreType { return []EVMStoreType{ StoreNonce, @@ -36,6 +34,7 @@ func AllEVMStoreTypes() []EVMStoreType { StoreCode, StoreStorage, StoreMisc, + StoreBalance, } } diff --git a/sei-db/tools/cmd/seidb/operations/evm_logical_digest.go b/sei-db/tools/cmd/seidb/operations/evm_logical_digest.go index 25fe2e8743..837cd41f8c 100644 --- a/sei-db/tools/cmd/seidb/operations/evm_logical_digest.go +++ b/sei-db/tools/cmd/seidb/operations/evm_logical_digest.go @@ -1466,6 +1466,16 @@ func consumeSemanticMemiavlLeaf(accounts map[string]*semanticAccountDigestState, } account := getSemanticAccount(accounts, keyBytes) copy(account.codeHash[:], rawVal) + case keys.EVMKeyBalance: + if len(rawVal) != 32 { + return fmt.Errorf("semantic memiavl %s: balance %X has length %d, want 32", + caller, rawKey, len(rawVal)) + } + if accounts == nil { + return nil + } + account := getSemanticAccount(accounts, keyBytes) + copy(account.balance[:], rawVal) case keys.EVMKeyCode: if len(rawVal) == 0 { return nil diff --git a/sei-db/tools/cmd/seidb/operations/evm_logical_digest_test.go b/sei-db/tools/cmd/seidb/operations/evm_logical_digest_test.go index 477e598b18..38d0507285 100644 --- a/sei-db/tools/cmd/seidb/operations/evm_logical_digest_test.go +++ b/sei-db/tools/cmd/seidb/operations/evm_logical_digest_test.go @@ -93,6 +93,7 @@ func coreEVMRawPairs() []*proto.KVPair { slot := bytesOfLen(32, 0x07) storageKeyBytes := append(append([]byte{}, addr...), slot...) codeHash := bytesOfLen(32, 0xAB) + balance := bytesOfLen(32, 0x5E) storageValue := bytesOfLen(32, 0x2A) code := []byte{0x60, 0x2A, 0x60, 0x00} miscKey := append([]byte{0x09}, addr...) @@ -101,6 +102,7 @@ func coreEVMRawPairs() []*proto.KVPair { return []*proto.KVPair{ {Key: keys.BuildEVMKey(keys.EVMKeyNonce, addr), Value: nonceBytes(7)}, {Key: keys.BuildEVMKey(keys.EVMKeyCodeHash, addr), Value: codeHash}, + {Key: keys.BuildEVMKey(keys.EVMKeyBalance, addr), Value: balance}, {Key: keys.BuildEVMKey(keys.EVMKeyStorage, storageKeyBytes), Value: storageValue}, {Key: keys.BuildEVMKey(keys.EVMKeyCode, addr), Value: code}, {Key: miscKey, Value: miscValue}, diff --git a/sei-db/tools/cmd/seidb/operations/flatkv_state_size.go b/sei-db/tools/cmd/seidb/operations/flatkv_state_size.go index 161e027266..d45ca91e8b 100644 --- a/sei-db/tools/cmd/seidb/operations/flatkv_state_size.go +++ b/sei-db/tools/cmd/seidb/operations/flatkv_state_size.go @@ -109,7 +109,7 @@ func classifyFlatKVPhysicalKey(key []byte) string { } kind, _ := keys.ParseEVMKey(innerKey) switch kind { - case ktype.EVMKeyAccount, keys.EVMKeyCodeHash: + case ktype.EVMKeyAccount, keys.EVMKeyCodeHash, keys.EVMKeyBalance: return flatkvBucketAccount case keys.EVMKeyCode: return flatkvBucketCode diff --git a/sei-tendermint/internal/p2p/evmonly_inmemory_app.go b/sei-tendermint/internal/p2p/evmonly_inmemory_app.go index 4468103a42..e6e246453b 100644 --- a/sei-tendermint/internal/p2p/evmonly_inmemory_app.go +++ b/sei-tendermint/internal/p2p/evmonly_inmemory_app.go @@ -187,13 +187,14 @@ func evmOnlyStoreAddress(address common.Address) gigastore.Address { func (a *evmOnlyInMemoryApplication) EvmNonce(address common.Address) uint64 { snapshot := a.store.OpenView() defer snapshot.Close() - return snapshot.GetNonce(evmOnlyStoreAddress(address)) + nonce, _ := snapshot.GetNonce(evmOnlyStoreAddress(address)) + return nonce } func (a *evmOnlyInMemoryApplication) EvmBalance(address common.Address, _ []byte) uint256.Int { snapshot := a.store.OpenView() defer snapshot.Close() - balance := snapshot.GetBalance(evmOnlyStoreAddress(address)) + balance, _ := snapshot.GetBalance(evmOnlyStoreAddress(address)) return *new(uint256.Int).SetBytes(balance[:]) } diff --git a/x/evm/types/keys.go b/x/evm/types/keys.go index 82fa46e21c..7c66b9f22b 100644 --- a/x/evm/types/keys.go +++ b/x/evm/types/keys.go @@ -64,6 +64,7 @@ var ( ZeroStorageCleanupCheckpointKey = []byte{0x1e} NonceBumpPrefix = []byte{0x1f} // transient BlockHashPrefix = []byte{0x20} + BalanceKeyPrefix = []byte{0x21} ) var ( @@ -88,6 +89,12 @@ func StateKey(evmAddress common.Address) []byte { return append(StateKeyPrefix, evmAddress[:]...) } +// BalanceKey returns the store key holding evmAddress's balance as a 32-byte +// big-endian value. +func BalanceKey(evmAddress common.Address) []byte { + return append(BalanceKeyPrefix, evmAddress[:]...) +} + func ReceiptKey(txHash common.Hash) []byte { return append(ReceiptKeyPrefix, txHash[:]...) }