diff --git a/sei-db/state_db/bench/wrappers/db_implementations.go b/sei-db/state_db/bench/wrappers/db_implementations.go index 432cb4ef53..64fcf818cd 100644 --- a/sei-db/state_db/bench/wrappers/db_implementations.go +++ b/sei-db/state_db/bench/wrappers/db_implementations.go @@ -39,13 +39,24 @@ func DefaultBenchStateStoreConfig() *config.StateStoreConfig { return &cfg } -func newMemIAVLCommitStore(dbDir string) (DBWrapper, error) { +// DefaultBenchMemIAVLConfig returns the memiavl config the benchmarks open +// with by default. Note AsyncCommitBuffer=10: Commit() returns once the WAL +// write is enqueued, not once it is durable. +func DefaultBenchMemIAVLConfig() memiavl.Config { cfg := memiavl.DefaultConfig() cfg.AsyncCommitBuffer = 10 cfg.SnapshotInterval = 1000 cfg.SnapshotMinTimeInterval = 60 + return cfg +} + +func newMemIAVLCommitStore(dbDir string, cfg *memiavl.Config) (DBWrapper, error) { + if cfg == nil { + defaultCfg := DefaultBenchMemIAVLConfig() + cfg = &defaultCfg + } fmt.Printf("Opening memIAVL from directory %s\n", dbDir) - cs := memiavl.NewCommitStore(dbDir, cfg) + cs := memiavl.NewCommitStore(dbDir, *cfg) if err := cs.Initialize([]string{EVMStoreName}); err != nil { return nil, fmt.Errorf("memiavl Initialize: %w", err) } @@ -148,7 +159,11 @@ func NewDBImpl(ctx context.Context, dbType DBType, dataDir string, dbConfig any) case NoOp: return NewNoOpWrapper(), nil case MemIAVL: - return newMemIAVLCommitStore(dataDir) + memiavlCfg, ok := dbConfig.(*memiavl.Config) + if dbConfig != nil && !ok { + return nil, fmt.Errorf("invalid MemIAVL config type %T", dbConfig) + } + return newMemIAVLCommitStore(dataDir, memiavlCfg) case FlatKV: flatKVConfig, ok := dbConfig.(*flatkvConfig.Config) if dbConfig != nil && !ok { diff --git a/sei-db/state_db/bench/writeset.go b/sei-db/state_db/bench/writeset.go new file mode 100644 index 0000000000..ba0a53f34e --- /dev/null +++ b/sei-db/state_db/bench/writeset.go @@ -0,0 +1,280 @@ +package bench + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/sei-protocol/sei-chain/sei-db/common/keys" + "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/bench/wrappers" + flatkvConfig "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/config" +) + +// This file implements the write-set replay adapter: it parses a captured +// write-set file (typically derived from a debug_traceCall prestateTracer +// diff) into per-block changesets and replays them through a storage-engine +// wrapper, timing ApplyChangeSets and Commit separately. +// +// v1 scope: EVM-module keys only (storage/code/nonce/codehash/raw). Bank +// (balance) changesets require the bank store layout and are deliberately +// out of scope; see the gas-repricing storage doc. + +// WriteSetEntryKind enumerates the supported key kinds in a write-set file. +const ( + WriteKindStorage = "storage" // requires address + slot + WriteKindCode = "code" // requires address + WriteKindNonce = "nonce" // requires address + WriteKindCodeHash = "codehash" // requires address + WriteKindRaw = "raw" // requires key (full store key, hex) +) + +// WriteSetEntry is one captured write. Hex fields accept an optional 0x prefix. +type WriteSetEntry struct { + // Kind is one of the WriteKind* constants. + Kind string `json:"kind"` + // Address is the 20-byte EVM address (storage/code/nonce/codehash kinds). + Address string `json:"address,omitempty"` + // Slot is the 32-byte storage slot (storage kind only). + Slot string `json:"slot,omitempty"` + // Key is the full raw store key (raw kind only). + Key string `json:"key,omitempty"` + // Value is the new value. Ignored when Delete is true. + Value string `json:"value,omitempty"` + // Delete marks a deletion instead of a write. + Delete bool `json:"delete,omitempty"` +} + +// WriteSetBlock groups the writes that commit together as one block. +type WriteSetBlock struct { + Writes []WriteSetEntry `json:"writes"` +} + +// WriteSet is the top-level write-set file format. +type WriteSet struct { + // Module is the store the writes belong to. Only "evm" is supported in v1; + // empty defaults to "evm". + Module string `json:"module,omitempty"` + Blocks []WriteSetBlock `json:"blocks"` +} + +// LoadWriteSet reads and validates a write-set file. +func LoadWriteSet(path string) (*WriteSet, error) { + data, err := os.ReadFile(path) //nolint:gosec // benchmark input path supplied by the operator + if err != nil { + return nil, fmt.Errorf("read write-set file: %w", err) + } + var ws WriteSet + if err := json.Unmarshal(data, &ws); err != nil { + return nil, fmt.Errorf("parse write-set file: %w", err) + } + if err := ws.Validate(); err != nil { + return nil, err + } + return &ws, nil +} + +// Validate checks module support and per-entry field consistency. +func (ws *WriteSet) Validate() error { + if ws.Module != "" && ws.Module != keys.EVMStoreKey { + return fmt.Errorf("unsupported module %q: v1 replay supports only %q", ws.Module, keys.EVMStoreKey) + } + if len(ws.Blocks) == 0 { + return fmt.Errorf("write set has no blocks") + } + for bi, block := range ws.Blocks { + for wi, w := range block.Writes { + if _, err := buildEntryKey(w); err != nil { + return fmt.Errorf("block %d write %d: %w", bi, wi, err) + } + if !w.Delete { + if _, err := decodeEntryValue(w); err != nil { + return fmt.Errorf("block %d write %d: %w", bi, wi, err) + } + } + } + } + return nil +} + +// TotalKeys returns the total number of writes across all blocks. +func (ws *WriteSet) TotalKeys() int { + total := 0 + for _, b := range ws.Blocks { + total += len(b.Writes) + } + return total +} + +// BlockChangesets converts one block into the NamedChangeSet slice consumed by +// DBWrapper.ApplyChangeSets. +func (ws *WriteSet) BlockChangesets(blockIdx int) ([]*proto.NamedChangeSet, error) { + block := ws.Blocks[blockIdx] + pairs := make([]*proto.KVPair, 0, len(block.Writes)) + for wi, w := range block.Writes { + key, err := buildEntryKey(w) + if err != nil { + return nil, fmt.Errorf("block %d write %d: %w", blockIdx, wi, err) + } + pair := &proto.KVPair{Key: key, Delete: w.Delete} + if !w.Delete { + value, err := decodeEntryValue(w) + if err != nil { + return nil, fmt.Errorf("block %d write %d: %w", blockIdx, wi, err) + } + pair.Value = value + } + pairs = append(pairs, pair) + } + return []*proto.NamedChangeSet{{ + Name: keys.EVMStoreKey, + Changeset: proto.ChangeSet{Pairs: pairs}, + }}, nil +} + +// buildEntryKey builds the raw store key for a write-set entry. +func buildEntryKey(w WriteSetEntry) ([]byte, error) { + switch w.Kind { + case WriteKindStorage: + addr, err := decodeHexField("address", w.Address, keys.AddressLen) + if err != nil { + return nil, err + } + slot, err := decodeHexField("slot", w.Slot, 32) + if err != nil { + return nil, err + } + return keys.BuildEVMKey(keys.EVMKeyStorage, append(addr, slot...)), nil + case WriteKindCode, WriteKindNonce, WriteKindCodeHash: + addr, err := decodeHexField("address", w.Address, keys.AddressLen) + if err != nil { + return nil, err + } + kind := map[string]keys.EVMKeyKind{ + WriteKindCode: keys.EVMKeyCode, + WriteKindNonce: keys.EVMKeyNonce, + WriteKindCodeHash: keys.EVMKeyCodeHash, + }[w.Kind] + return keys.BuildEVMKey(kind, addr), nil + case WriteKindRaw: + key, err := decodeHexField("key", w.Key, 0) + if err != nil { + return nil, err + } + if len(key) == 0 { + return nil, fmt.Errorf("raw write has empty key") + } + return key, nil + default: + return nil, fmt.Errorf("unknown write kind %q", w.Kind) + } +} + +// valueLenForKind returns the exact byte length a kind's value must have, or 0 +// when the length is unconstrained (code and raw). The fixed widths mirror the +// FlatKV apply path (vtype.ParseNonce/ParseCodeHash/ParseStorageValue), so a +// wrong-length value in a hand-authored write-set file is rejected up front by +// Validate rather than only failing later inside ApplyChangeSets on FlatKV +// (memiavl stores raw bytes and would silently accept it, breaking the +// same-write-set-across-backends premise of the benchmark). +// +// Raw entries are the escape hatch this check cannot cover: their keys are +// opaque here, so hand-authored raw entries must target key families FlatKV +// does not width-check (legacy prefixes such as 0x09 codesize). A raw key +// aliasing an optimized family (e.g. 0x0a nonce) with a wrong-width value +// passes Validate, replays on memiavl, and hard-fails on FlatKV. +func valueLenForKind(kind string) int { + switch kind { + case WriteKindNonce: + return 8 + case WriteKindStorage, WriteKindCodeHash: + return 32 + default: // WriteKindCode, WriteKindRaw: unconstrained + return 0 + } +} + +// decodeEntryValue decodes a write entry's value, enforcing the fixed width its +// kind requires (see valueLenForKind). +func decodeEntryValue(w WriteSetEntry) ([]byte, error) { + return decodeHexField("value", w.Value, valueLenForKind(w.Kind)) +} + +// decodeHexField decodes a hex field, tolerating a 0x prefix. wantLen of 0 +// disables the length check. An empty string decodes to nil. +func decodeHexField(name, value string, wantLen int) ([]byte, error) { + trimmed := strings.TrimPrefix(value, "0x") + decoded, err := hex.DecodeString(trimmed) + if err != nil { + return nil, fmt.Errorf("field %s: invalid hex %q: %w", name, value, err) + } + if wantLen > 0 && len(decoded) != wantLen { + return nil, fmt.Errorf("field %s: expected %d bytes, got %d", name, wantLen, len(decoded)) + } + return decoded, nil +} + +// OpenReplayWrapper opens a fresh DBWrapper for a replay run, supplying the +// explicit default config that the FlatKV wrapper factory requires. +// +// memiavl is opened with AsyncCommitBuffer=0 (synchronous WAL write) rather +// than the shared bench default of 10. With the async buffer, memiavl's +// Commit() returns once the WAL entry is enqueued, while FlatKV's Commit() +// waits for its WAL write — the reported commit_ns/key would compare enqueue +// latency against write latency. Neither backend fsyncs, so with a +// synchronous WAL write on both sides the durability semantics match. +func OpenReplayWrapper(ctx context.Context, backend wrappers.DBType, dbDir string) (wrappers.DBWrapper, error) { + var dbConfig any + switch backend { + case wrappers.FlatKV: + dbConfig = flatkvConfig.DefaultConfig() + case wrappers.MemIAVL: + cfg := wrappers.DefaultBenchMemIAVLConfig() + cfg.AsyncCommitBuffer = 0 + dbConfig = &cfg + } + return wrappers.NewDBImpl(ctx, backend, dbDir, dbConfig) +} + +// ReplayResult reports a replay run with apply and commit timed separately. +type ReplayResult struct { + Blocks int + Keys int + ApplyDuration time.Duration + CommitDuration time.Duration +} + +// ReplayWriteSet replays the write set through the wrapper, one block per +// version, timing ApplyChangeSets and Commit separately. The wrapper must be +// freshly opened (or snapshot-loaded); replay starts at wrapper.Version()+1. +func ReplayWriteSet(wrapper wrappers.DBWrapper, ws *WriteSet) (ReplayResult, error) { + result := ReplayResult{Blocks: len(ws.Blocks), Keys: ws.TotalKeys()} + baseVersion := wrapper.Version() + for i := range ws.Blocks { + changesets, err := ws.BlockChangesets(i) + if err != nil { + return result, err + } + entry := &proto.ChangelogEntry{ + Version: baseVersion + int64(i) + 1, + Changesets: changesets, + } + + applyStart := time.Now() + if err := wrapper.ApplyChangeSets(entry); err != nil { + return result, fmt.Errorf("apply block %d: %w", i, err) + } + result.ApplyDuration += time.Since(applyStart) + + commitStart := time.Now() + if _, err := wrapper.Commit(); err != nil { + return result, fmt.Errorf("commit block %d: %w", i, err) + } + result.CommitDuration += time.Since(commitStart) + } + return result, nil +} diff --git a/sei-db/state_db/bench/writeset_bench_test.go b/sei-db/state_db/bench/writeset_bench_test.go new file mode 100644 index 0000000000..5f621a2c51 --- /dev/null +++ b/sei-db/state_db/bench/writeset_bench_test.go @@ -0,0 +1,102 @@ +package bench + +import ( + "fmt" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-db/state_db/bench/wrappers" +) + +// BenchmarkWriteSetReplay replays a captured write-set file against both +// storage backends, timing ApplyChangeSets and Commit separately. +// +// Inputs (environment variables): +// +// TRACE_PATH path to a prestateTracer diffMode JSON file (the raw +// {"pre","post"} result or a whole JSON-RPC response), +// converted on the fly; takes precedence over WRITESET_PATH. +// WRITESET_PATH path to a write-set JSON file (see writeset.go). Raw +// tracer output is not accepted here; use TRACE_PATH. +// SNAPSHOT_PATH optional state sync snapshot chunks directory imported +// before the timed region (same as the other benchmarks). +// +// Example: +// +// TRACE_PATH=/tmp/sstore_trace.json go test ./sei-db/state_db/bench \ +// -run '^$' -bench '^BenchmarkWriteSetReplay$' -benchtime=5x +func BenchmarkWriteSetReplay(b *testing.B) { + ws := loadBenchWriteSet(b) + + for _, backend := range []wrappers.DBType{wrappers.MemIAVL, wrappers.FlatKV} { + b.Run(string(backend), func(b *testing.B) { + // Accumulate across iterations and report once: b.ReportMetric keeps + // only the last value for a given unit, so reporting inside the loop + // would surface a single iteration instead of an average over b.N. + var totalApply, totalCommit time.Duration + var totalKeys int + for range b.N { + result := runWriteSetReplay(b, backend, ws) + totalApply += result.ApplyDuration + totalCommit += result.CommitDuration + totalKeys += result.Keys + } + if totalKeys == 0 { + return + } + keys := float64(totalKeys) + b.ReportMetric(totalApply.Seconds()/keys*1e9, "apply_ns/key") + b.ReportMetric(totalCommit.Seconds()/keys*1e9, "commit_ns/key") + }) + } +} + +func loadBenchWriteSet(b *testing.B) *WriteSet { + if tracePath := os.Getenv("TRACE_PATH"); tracePath != "" { + converted, err := ConvertPrestateDiffFile(tracePath) + require.NoError(b, err) + if converted.SkippedBalanceChanges > 0 { + b.Logf("skipped %d balance change(s): bank-module replay is out of scope", + converted.SkippedBalanceChanges) + } + return converted.WriteSet + } + if wsPath := os.Getenv("WRITESET_PATH"); wsPath != "" { + ws, err := LoadWriteSet(wsPath) + require.NoError(b, err) + return ws + } + b.Skip("set TRACE_PATH or WRITESET_PATH to run the write-set replay benchmark") + return nil +} + +func runWriteSetReplay(b *testing.B, backend wrappers.DBType, ws *WriteSet) ReplayResult { + b.StopTimer() + dbDir := b.TempDir() + wrapper, err := OpenReplayWrapper(b.Context(), backend, dbDir) + require.NoError(b, err) + defer func() { + require.NoError(b, wrapper.Close()) + }() + + if snapshotPath := os.Getenv("SNAPSHOT_PATH"); snapshotPath != "" { + snapshotHeight, err := parseSnapshotHeight(snapshotPath) + require.NoError(b, err) + importer, err := wrapper.Importer(snapshotHeight) + require.NoError(b, err) + require.NoError(b, importSnapshot(snapshotPath, importer)) + require.NoError(b, wrapper.LoadVersion(0)) + } + + b.StartTimer() + result, err := ReplayWriteSet(wrapper, ws) + b.StopTimer() + require.NoError(b, err) + + fmt.Printf("[Replay %s] blocks=%d keys=%d apply=%s commit=%s\n", + backend, result.Blocks, result.Keys, result.ApplyDuration, result.CommitDuration) + return result +} diff --git a/sei-db/state_db/bench/writeset_convert.go b/sei-db/state_db/bench/writeset_convert.go new file mode 100644 index 0000000000..1323b5e730 --- /dev/null +++ b/sei-db/state_db/bench/writeset_convert.go @@ -0,0 +1,262 @@ +package bench + +import ( + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + ethcrypto "github.com/ethereum/go-ethereum/crypto" +) + +// This file converts a debug_traceCall prestateTracer diffMode result +// ({"pre": {...}, "post": {...}}) into a WriteSet for replay. +// +// Mapping rules (v1): +// - storage slots present in post -> storage write with the post value +// - storage slots present in pre, not post -> storage delete (slot zeroed) +// - nonce changed -> nonce write (8-byte big-endian) +// - code changed -> code write + codehash write +// (keccak256 of the code) + codesize raw write (0x09||addr, 8-byte length), +// mirroring x/evm's deploy path +// - account present in pre, absent in post (SELFDESTRUCT) -> deletes for the +// account's nonce, code, codehash, and codesize keys, plus the storage +// deletes from the rule above. Only slots present in pre can be deleted; +// a real account wipe also removes slots the trace never touched, so +// self-destruct replays are a lower bound on the true delete volume. +// - balance changes are bank-module writes and are NOT converted; they are +// counted in SkippedBalanceChanges so callers can see what was dropped +// (a removed account's balance zeroing is counted the same way) +// +// Known v1 fidelity gap: deploying to a previously-unassociated address also +// writes the Sei<->EVM address mapping (two raw keys, 0x01||evm and 0x02||sei) +// and creates a Sei account, via x/evm's SetCode -> SetAddressMapping path. Those +// writes are NOT emitted here because a prestate trace does not reveal the prior +// association state, so we cannot tell whether the mapping write actually fired +// (the same reason balance changes are skipped). New-contract deploy replays +// therefore slightly undercount apply/commit cost; emitting them conditionally +// is left to a future revision. +// +// Addresses and slots are emitted in sorted order so conversion output is +// deterministic for a given trace. + +// codeSizeKeyPrefix mirrors x/evm/types.CodeSizeKeyPrefix. It routes to the +// legacy key family, which the sei-db keys package intentionally does not +// enumerate, so the byte is duplicated here the same way keys/evm.go +// duplicates the other prefixes. +var codeSizeKeyPrefix = []byte{0x09} + +// prestateAccount is one account entry in a prestateTracer result. +type prestateAccount struct { + Balance string `json:"balance,omitempty"` + Nonce *uint64 `json:"nonce,omitempty"` + Code string `json:"code,omitempty"` + Storage map[string]string `json:"storage,omitempty"` +} + +// prestateDiff is the diffMode payload of a prestateTracer trace. +type prestateDiff struct { + Pre map[string]prestateAccount `json:"pre"` + Post map[string]prestateAccount `json:"post"` +} + +// ConvertResult carries the converted write set plus conversion statistics. +type ConvertResult struct { + WriteSet *WriteSet + // SkippedBalanceChanges counts balance changes that were not converted + // because balances live in the bank module (out of v1 scope): accounts + // with a post-state balance, plus removed accounts whose pre-state + // balance was zeroed. + SkippedBalanceChanges int +} + +// ConvertPrestateDiffFile reads a prestateTracer diffMode JSON file (either +// the raw {"pre","post"} object or a JSON-RPC response with that object under +// "result") and converts it into a single-block WriteSet. +func ConvertPrestateDiffFile(path string) (*ConvertResult, error) { + data, err := os.ReadFile(path) //nolint:gosec // benchmark input path supplied by the operator + if err != nil { + return nil, fmt.Errorf("read trace file: %w", err) + } + return ConvertPrestateDiff(data) +} + +// ConvertPrestateDiff converts prestateTracer diffMode JSON bytes into a +// single-block WriteSet. +func ConvertPrestateDiff(data []byte) (*ConvertResult, error) { + var rpcEnvelope struct { + Result json.RawMessage `json:"result"` + } + if err := json.Unmarshal(data, &rpcEnvelope); err == nil && len(rpcEnvelope.Result) > 0 { + data = rpcEnvelope.Result + } + + var diff prestateDiff + if err := json.Unmarshal(data, &diff); err != nil { + return nil, fmt.Errorf("parse prestate diff: %w", err) + } + if diff.Post == nil { + return nil, fmt.Errorf("trace has no post state; was the tracer run with diffMode=true?") + } + + result := &ConvertResult{} + var writes []WriteSetEntry + + for _, addr := range sortedKeys(diff.Post) { + post := diff.Post[addr] + pre := diff.Pre[addr] + + writes = append(writes, convertStorage(addr, pre, post)...) + + if post.Nonce != nil { + nonce := make([]byte, 8) + binary.BigEndian.PutUint64(nonce, *post.Nonce) + writes = append(writes, WriteSetEntry{ + Kind: WriteKindNonce, + Address: addr, + Value: hex.EncodeToString(nonce), + }) + } + + if post.Code != "" && post.Code != pre.Code { + codeWrites, err := convertCode(addr, post.Code) + if err != nil { + return nil, err + } + writes = append(writes, codeWrites...) + } + + if post.Balance != "" { + result.SkippedBalanceChanges++ + } + } + + // Slots that were zeroed appear in pre but not post: emit deletes. Membership + // is tested on the normalized (padded) slot key because the write pass + // normalizes the post slot the same way; comparing raw hex could miss a match + // when pre and post encode the same slot differently (padded vs unpadded, 0x + // prefix, case), emitting a spurious delete that clobbers the write — deletes + // are appended after writes, and both engines apply last-write-wins per key. + for _, addr := range sortedKeys(diff.Pre) { + pre := diff.Pre[addr] + post, inPost := diff.Post[addr] + postSlots := make(map[string]struct{}, len(post.Storage)) + for slot := range post.Storage { + postSlots[padTo32(slot)] = struct{}{} + } + for _, slot := range sortedKeys(pre.Storage) { + if _, stillSet := postSlots[padTo32(slot)]; !stillSet { + writes = append(writes, WriteSetEntry{ + Kind: WriteKindStorage, + Address: addr, + Slot: padTo32(slot), + Delete: true, + }) + } + } + if !inPost { + removalWrites, err := convertAccountRemoval(addr, pre) + if err != nil { + return nil, err + } + writes = append(writes, removalWrites...) + if pre.Balance != "" { + result.SkippedBalanceChanges++ + } + } + } + + if len(writes) == 0 { + return nil, fmt.Errorf("trace produced no convertible writes") + } + result.WriteSet = &WriteSet{Blocks: []WriteSetBlock{{Writes: writes}}} + if err := result.WriteSet.Validate(); err != nil { + return nil, fmt.Errorf("converted write set is invalid: %w", err) + } + return result, nil +} + +// convertStorage emits writes for every slot present in the post state. +// Slots and values are padded to 32 bytes: x/evm writes fixed 32-byte values, +// and some tracers emit unpadded hex. +func convertStorage(addr string, _, post prestateAccount) []WriteSetEntry { + writes := make([]WriteSetEntry, 0, len(post.Storage)) + for _, slot := range sortedKeys(post.Storage) { + writes = append(writes, WriteSetEntry{ + Kind: WriteKindStorage, + Address: addr, + Slot: padTo32(slot), + Value: padTo32(post.Storage[slot]), + }) + } + return writes +} + +// convertCode emits the code, codehash, and codesize writes that a contract +// deployment produces. It deliberately omits the Sei<->EVM address-mapping and +// account-creation writes that SetCode also performs for a previously +// unassociated address; see the fidelity-gap note in the file header. +func convertCode(addr, codeHex string) ([]WriteSetEntry, error) { + code, err := hex.DecodeString(strings.TrimPrefix(codeHex, "0x")) + if err != nil { + return nil, fmt.Errorf("address %s: invalid code hex: %w", addr, err) + } + addrBytes, err := decodeHexField("address", addr, 20) + if err != nil { + return nil, err + } + size := make([]byte, 8) + binary.BigEndian.PutUint64(size, uint64(len(code))) + return []WriteSetEntry{ + {Kind: WriteKindCode, Address: addr, Value: hex.EncodeToString(code)}, + {Kind: WriteKindCodeHash, Address: addr, Value: hex.EncodeToString(ethcrypto.Keccak256(code))}, + {Kind: WriteKindRaw, Key: hex.EncodeToString(append(codeSizeKeyPrefix, addrBytes...)), Value: hex.EncodeToString(size)}, + }, nil +} + +// convertAccountRemoval emits the account-level deletes for an address that is +// present in pre but absent from post (the diffMode shape a SELFDESTRUCT +// produces): nonce, and — when the account had code — code, codehash, and +// codesize. Storage-slot deletes are handled by the caller's delete pass; see +// the file header for why slots absent from pre cannot be deleted. +func convertAccountRemoval(addr string, pre prestateAccount) ([]WriteSetEntry, error) { + var writes []WriteSetEntry + if pre.Nonce != nil { + writes = append(writes, WriteSetEntry{Kind: WriteKindNonce, Address: addr, Delete: true}) + } + if pre.Code != "" { + addrBytes, err := decodeHexField("address", addr, 20) + if err != nil { + return nil, err + } + writes = append(writes, + WriteSetEntry{Kind: WriteKindCode, Address: addr, Delete: true}, + WriteSetEntry{Kind: WriteKindCodeHash, Address: addr, Delete: true}, + WriteSetEntry{Kind: WriteKindRaw, Key: hex.EncodeToString(append(codeSizeKeyPrefix, addrBytes...)), Delete: true}, + ) + } + return writes, nil +} + +// padTo32 left-pads a hex slot to 32 bytes, tolerating a 0x prefix. Some +// tracers emit unpadded slot keys; store keys are always 32 bytes. +func padTo32(slot string) string { + trimmed := strings.TrimPrefix(slot, "0x") + if len(trimmed) >= 64 { + return trimmed + } + return strings.Repeat("0", 64-len(trimmed)) + trimmed +} + +// sortedKeys returns the map's keys in sorted order for deterministic output. +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/sei-db/state_db/bench/writeset_test.go b/sei-db/state_db/bench/writeset_test.go new file mode 100644 index 0000000000..a9cf803027 --- /dev/null +++ b/sei-db/state_db/bench/writeset_test.go @@ -0,0 +1,287 @@ +package bench + +import ( + "encoding/hex" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-db/state_db/bench/wrappers" +) + +// prestateFixture is a real debug_traceCall prestateTracer diffMode response +// captured from pacific-1 for bytecode 0x602a60005500 +// (PUSH1 0x2a; PUSH1 0; SSTORE; STOP) with a code state override. +const prestateFixture = `{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "post": { + "0x0000000000000000000000000000000000000001": {"nonce": 1}, + "0x1000000000000000000000000000000000000001": { + "storage": { + "0x0000000000000000000000000000000000000000000000000000000000000000": + "0x000000000000000000000000000000000000000000000000000000000000002a" + } + } + }, + "pre": { + "0x0000000000000000000000000000000000000001": {"balance": "0x27147114878000"}, + "0x1000000000000000000000000000000000000001": {"balance": "0x0", "code": "0x602a60005500"} + } + } +}` + +func TestConvertPrestateDiff(t *testing.T) { + converted, err := ConvertPrestateDiff([]byte(prestateFixture)) + require.NoError(t, err) + ws := converted.WriteSet + require.Len(t, ws.Blocks, 1) + + byKind := map[string]int{} + for _, w := range ws.Blocks[0].Writes { + byKind[w.Kind]++ + } + require.Equal(t, 1, byKind[WriteKindStorage], "one SSTORE slot") + require.Equal(t, 1, byKind[WriteKindNonce], "sender nonce bump") + + changesets, err := ws.BlockChangesets(0) + require.NoError(t, err) + require.Len(t, changesets, 1) + require.Equal(t, "evm", changesets[0].Name) + + var sawStorageKey bool + for _, pair := range changesets[0].Changeset.Pairs { + if pair.Key[0] == 0x03 { + sawStorageKey = true + require.Len(t, pair.Key, 53, "storage key is 0x03||addr||slot") + require.Len(t, pair.Value, 32, "storage value padded to 32 bytes") + require.Equal(t, byte(0x2a), pair.Value[31]) + } + } + require.True(t, sawStorageKey) +} + +func TestConvertPrestateDiffEmitsDeletes(t *testing.T) { + trace := `{ + "pre": {"0x1000000000000000000000000000000000000001": {"storage": {"0x01": "0x2a"}}}, + "post": {"0x1000000000000000000000000000000000000001": {"nonce": 1}} + }` + converted, err := ConvertPrestateDiff([]byte(trace)) + require.NoError(t, err) + + var deletes int + for _, w := range converted.WriteSet.Blocks[0].Writes { + if w.Delete { + deletes++ + require.Equal(t, WriteKindStorage, w.Kind) + require.Len(t, w.Slot, 64, "slot padded to 32 bytes") + } + } + require.Equal(t, 1, deletes, "slot zeroed in post emits a delete") +} + +func TestConvertPrestateDiffSelfDestruct(t *testing.T) { + // An account present in pre but absent from post is the diffMode shape a + // SELFDESTRUCT produces: the account-level keys must be deleted too, not + // just the storage slots seen in pre. + trace := `{ + "pre": {"0x3000000000000000000000000000000000000003": { + "balance": "0x2a", "nonce": 1, "code": "0x602a60005500", + "storage": {"0x01": "0x2a"}}}, + "post": {} + }` + converted, err := ConvertPrestateDiff([]byte(trace)) + require.NoError(t, err) + require.Equal(t, 1, converted.SkippedBalanceChanges, + "removed account's balance zeroing is counted as skipped") + + deletesByKind := map[string]int{} + for _, w := range converted.WriteSet.Blocks[0].Writes { + require.True(t, w.Delete, "a removed account produces only deletes") + deletesByKind[w.Kind]++ + } + require.Equal(t, map[string]int{ + WriteKindStorage: 1, + WriteKindNonce: 1, + WriteKindCode: 1, + WriteKindCodeHash: 1, + WriteKindRaw: 1, // codesize (0x09||addr) + }, deletesByKind) + + // The delete-only write set must build valid changesets. + changesets, err := converted.WriteSet.BlockChangesets(0) + require.NoError(t, err) + for _, pair := range changesets[0].Changeset.Pairs { + require.True(t, pair.Delete) + } + + // Deleting keys that were never written must replay cleanly on both + // backends (a fresh DB has none of the removed account's keys). + for _, backend := range []wrappers.DBType{wrappers.MemIAVL, wrappers.FlatKV} { + t.Run(string(backend), func(t *testing.T) { + wrapper, err := OpenReplayWrapper(t.Context(), backend, t.TempDir()) + require.NoError(t, err) + defer func() { + require.NoError(t, wrapper.Close()) + }() + _, err = ReplayWriteSet(wrapper, converted.WriteSet) + require.NoError(t, err) + }) + } +} + +func TestConvertPrestateDiffNoSpuriousDeleteOnEncodingMismatch(t *testing.T) { + // The same slot is unpadded in pre but padded in post. The delete pass must + // normalize both before comparing, or it emits a delete that clobbers the + // write (last-write-wins), losing the updated value. + trace := `{ + "pre": {"0x1000000000000000000000000000000000000001": {"storage": {"0x01": "0x2a"}}}, + "post": {"0x1000000000000000000000000000000000000001": {"storage": { + "0x0000000000000000000000000000000000000000000000000000000000000001": "0x2b"}}} + }` + converted, err := ConvertPrestateDiff([]byte(trace)) + require.NoError(t, err) + + for _, w := range converted.WriteSet.Blocks[0].Writes { + require.False(t, w.Delete, "same slot written in post must not also be deleted") + } + + changesets, err := converted.WriteSet.BlockChangesets(0) + require.NoError(t, err) + var storageWrites int + for _, pair := range changesets[0].Changeset.Pairs { + if pair.Key[0] == 0x03 { + storageWrites++ + require.False(t, pair.Delete) + require.Equal(t, byte(0x2b), pair.Value[31], "updated value survives") + } + } + require.Equal(t, 1, storageWrites) +} + +func TestConvertPrestateDiffCodeDeployment(t *testing.T) { + trace := `{ + "pre": {"0x2000000000000000000000000000000000000002": {}}, + "post": {"0x2000000000000000000000000000000000000002": {"code": "0x602a60005500", "nonce": 1}} + }` + converted, err := ConvertPrestateDiff([]byte(trace)) + require.NoError(t, err) + + byKind := map[string]WriteSetEntry{} + for _, w := range converted.WriteSet.Blocks[0].Writes { + byKind[w.Kind] = w + } + require.Contains(t, byKind, WriteKindCode) + require.Contains(t, byKind, WriteKindCodeHash) + require.Contains(t, byKind, WriteKindRaw, "codesize write") + + codeHash, err := hex.DecodeString(byKind[WriteKindCodeHash].Value) + require.NoError(t, err) + require.Len(t, codeHash, 32) + + rawKey, err := hex.DecodeString(byKind[WriteKindRaw].Key) + require.NoError(t, err) + require.Equal(t, byte(0x09), rawKey[0], "codesize key prefix") + require.Len(t, rawKey, 21) +} + +func TestConvertPrestateDiffRequiresDiffMode(t *testing.T) { + _, err := ConvertPrestateDiff([]byte(`{"pre": {}}`)) + require.ErrorContains(t, err, "diffMode") +} + +func TestReplayWriteSetOnBothBackends(t *testing.T) { + converted, err := ConvertPrestateDiff([]byte(prestateFixture)) + require.NoError(t, err) + ws := converted.WriteSet + + for _, backend := range []wrappers.DBType{wrappers.MemIAVL, wrappers.FlatKV} { + t.Run(string(backend), func(t *testing.T) { + wrapper, err := OpenReplayWrapper(t.Context(), backend, t.TempDir()) + require.NoError(t, err) + defer func() { + require.NoError(t, wrapper.Close()) + }() + + result, err := ReplayWriteSet(wrapper, ws) + require.NoError(t, err) + require.Equal(t, 1, result.Blocks) + require.Equal(t, ws.TotalKeys(), result.Keys) + require.Positive(t, result.ApplyDuration) + require.Positive(t, result.CommitDuration) + require.Equal(t, int64(1), wrapper.Version()) + + // The SSTORE'd slot must be readable back through the store key. + changesets, err := ws.BlockChangesets(0) + require.NoError(t, err) + for _, pair := range changesets[0].Changeset.Pairs { + if pair.Key[0] == 0x03 { + value, found, err := wrapper.Read(pair.Key) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, pair.Value, value) + } + } + }) + } +} + +func TestLoadWriteSetValidates(t *testing.T) { + dir := t.TempDir() + path := dir + "/ws.json" + + require.NoError(t, writeFile(path, `{"blocks": [{"writes": [ + {"kind": "storage", "address": "0x1000000000000000000000000000000000000001", + "slot": "0x0000000000000000000000000000000000000000000000000000000000000000", + "value": "0x000000000000000000000000000000000000000000000000000000000000002a"} + ]}]}`)) + ws, err := LoadWriteSet(path) + require.NoError(t, err) + require.Equal(t, 1, ws.TotalKeys()) + + require.NoError(t, writeFile(path, `{"blocks": [{"writes": [{"kind": "bogus"}]}]}`)) + _, err = LoadWriteSet(path) + require.ErrorContains(t, err, "unknown write kind") + + require.NoError(t, writeFile(path, `{"module": "bank", "blocks": [{"writes": []}]}`)) + _, err = LoadWriteSet(path) + require.ErrorContains(t, err, "unsupported module") +} + +func TestValidateRejectsWrongLengthValue(t *testing.T) { + addr := "0x1000000000000000000000000000000000000001" + + // A wrong-length value for a fixed-width kind is rejected up front, so the + // benchmark never feeds divergent data to memiavl (permissive) vs FlatKV + // (which hard-errors on bad lengths deep inside ApplyChangeSets). + for _, tc := range []struct { + name string + entry WriteSetEntry + }{ + {"short nonce", WriteSetEntry{Kind: WriteKindNonce, Address: addr, Value: "0x2a000000"}}, + {"short codehash", WriteSetEntry{Kind: WriteKindCodeHash, Address: addr, Value: "0x2a"}}, + {"short storage", WriteSetEntry{Kind: WriteKindStorage, Address: addr, + Slot: "0x" + hex.EncodeToString(make([]byte, 32)), Value: "0x2a"}}, + } { + t.Run(tc.name, func(t *testing.T) { + ws := &WriteSet{Blocks: []WriteSetBlock{{Writes: []WriteSetEntry{tc.entry}}}} + err := ws.Validate() + require.ErrorContains(t, err, "expected") + _, err = ws.BlockChangesets(0) + require.ErrorContains(t, err, "expected") + }) + } + + // Correctly-sized fixed-width values and unconstrained kinds (code/raw) pass. + ws := &WriteSet{Blocks: []WriteSetBlock{{Writes: []WriteSetEntry{ + {Kind: WriteKindNonce, Address: addr, Value: "0x" + hex.EncodeToString(make([]byte, 8))}, + {Kind: WriteKindCode, Address: addr, Value: "0x602a60005500"}, + }}}} + require.NoError(t, ws.Validate()) +} + +func writeFile(path, contents string) error { + return os.WriteFile(path, []byte(contents), 0o600) +}