diff --git a/Makefile b/Makefile index 236b8a701d..f4282f5784 100644 --- a/Makefile +++ b/Makefile @@ -403,7 +403,7 @@ CLUSTER_ENV_VARS = DOCKER_PLATFORM=$(DOCKER_PLATFORM) USERID=$(shell id -u) GROU GIGA_OCC=$(GIGA_OCC) \ RECEIPT_BACKEND=$(RECEIPT_BACKEND) \ AUTOBAHN=$(AUTOBAHN) \ - AUTOBAHN_EVMONLY_IN_MEMORY=$(AUTOBAHN_EVMONLY_IN_MEMORY) \ + AUTOBAHN_EVMONLY=$(AUTOBAHN_EVMONLY) \ GIGA_STORAGE=$(GIGA_STORAGE) \ GIGA_MIGRATE_FROM_MEMIAVL=$(GIGA_MIGRATE_FROM_MEMIAVL) \ GIGA_FLATKV_ONLY=$(GIGA_FLATKV_ONLY) @@ -559,9 +559,9 @@ autobahn-integration-test: @GOWORK=off go test -tags autobahn_integration -v -count=1 -timeout 30m ./integration_test/autobahn/... .PHONY: autobahn-integration-test -# Run the minimal in-memory EVM-only executor behind a four-validator Autobahn cluster. +# Run the disk-backed EVM-only executor behind a four-validator Autobahn cluster. autobahn-evmonly-integration-test: - @AUTOBAHN_EVMONLY_IN_MEMORY=true GOWORK=off go test -tags autobahn_integration -v -count=1 -timeout 30m ./integration_test/autobahn/... + @AUTOBAHN_EVMONLY=true GOWORK=off go test -tags autobahn_integration -v -count=1 -timeout 30m ./integration_test/autobahn/... .PHONY: autobahn-evmonly-integration-test # Run a mixed-mode cluster: node 0 uses GIGA_EXECUTOR with OCC, nodes 1-3 use standard V2. diff --git a/cmd/autobahn-e2e/aws.go b/cmd/autobahn-e2e/aws.go index 77f1dc2304..a8d7860e6d 100644 --- a/cmd/autobahn-e2e/aws.go +++ b/cmd/autobahn-e2e/aws.go @@ -427,7 +427,7 @@ func (a *application) startRemoteCluster(ctx context.Context, state clusterState "git clone --filter=blob:none " + shellQuote(aws.RepoURL) + " " + shellQuote(aws.RemoteDir), "cd " + shellQuote(aws.RemoteDir), "git checkout --detach " + shellQuote(aws.Ref), - "AUTOBAHN=true AUTOBAHN_EVMONLY_IN_MEMORY=true DOCKER_DETACH=true make docker-cluster-start", + "AUTOBAHN=true AUTOBAHN_EVMONLY=true DOCKER_DETACH=true make docker-cluster-start", }, " && ") if err := a.runner.stream(ctx, sshCommand(state, command)); err != nil { return fmt.Errorf("start remote cluster: %w", err) diff --git a/cmd/autobahn-e2e/command_test.go b/cmd/autobahn-e2e/command_test.go index a2f4267925..8c06b4f533 100644 --- a/cmd/autobahn-e2e/command_test.go +++ b/cmd/autobahn-e2e/command_test.go @@ -145,7 +145,7 @@ func TestAWSDeployCreatesManagedResourcesAndReadyState(t *testing.T) { commands := joinedCommands(runner.commands) require.Contains(t, commands, "authorize-security-group-ingress") require.Contains(t, commands, "--cidr 198.51.100.4/32") - require.Contains(t, commands, "AUTOBAHN_EVMONLY_IN_MEMORY=true") + require.Contains(t, commands, "AUTOBAHN_EVMONLY=true") require.Contains(t, commands, "-o StrictHostKeyChecking=accept-new") } diff --git a/cmd/autobahn-e2e/deploy.go b/cmd/autobahn-e2e/deploy.go index 95635f0bc8..0ce3119c71 100644 --- a/cmd/autobahn-e2e/deploy.go +++ b/cmd/autobahn-e2e/deploy.go @@ -130,7 +130,7 @@ func (a *application) deployLocal(ctx context.Context, options deployOptions) er dir: repoRoot, env: []string{ "AUTOBAHN=true", - "AUTOBAHN_EVMONLY_IN_MEMORY=true", + "AUTOBAHN_EVMONLY=true", "DOCKER_DETACH=true", }, name: "make", diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 247bcb79c5..0994a586ff 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -20,7 +20,7 @@ services: - GIGA_OCC - RECEIPT_BACKEND - AUTOBAHN - - AUTOBAHN_EVMONLY_IN_MEMORY + - AUTOBAHN_EVMONLY - GIGA_STORAGE - GIGA_MIGRATE_FROM_MEMIAVL - GIGA_FLATKV_ONLY @@ -56,7 +56,7 @@ services: - GIGA_OCC - RECEIPT_BACKEND - AUTOBAHN - - AUTOBAHN_EVMONLY_IN_MEMORY + - AUTOBAHN_EVMONLY - GIGA_STORAGE - GIGA_MIGRATE_FROM_MEMIAVL - GIGA_FLATKV_ONLY @@ -88,7 +88,7 @@ services: - GIGA_OCC - RECEIPT_BACKEND - AUTOBAHN - - AUTOBAHN_EVMONLY_IN_MEMORY + - AUTOBAHN_EVMONLY - GIGA_STORAGE - GIGA_MIGRATE_FROM_MEMIAVL - GIGA_FLATKV_ONLY @@ -124,7 +124,7 @@ services: - GIGA_OCC - RECEIPT_BACKEND - AUTOBAHN - - AUTOBAHN_EVMONLY_IN_MEMORY + - AUTOBAHN_EVMONLY - GIGA_STORAGE - GIGA_MIGRATE_FROM_MEMIAVL - GIGA_FLATKV_ONLY diff --git a/docker/localnode/config/config.toml b/docker/localnode/config/config.toml index a509dbc03f..00640381c7 100644 --- a/docker/localnode/config/config.toml +++ b/docker/localnode/config/config.toml @@ -18,7 +18,7 @@ moniker = "sei-node-0" mode = "validator" # Test-only application replacement for Autobahn EVM load tests -evm-only-in-memory = false +evm-only = false # Database backend: goleveldb | cleveldb | boltdb | rocksdb | badgerdb # * goleveldb (github.com/syndtr/goleveldb - most popular implementation) diff --git a/docker/localnode/scripts/step4_config_override.sh b/docker/localnode/scripts/step4_config_override.sh index edcee91187..2591c702fa 100755 --- a/docker/localnode/scripts/step4_config_override.sh +++ b/docker/localnode/scripts/step4_config_override.sh @@ -7,7 +7,7 @@ VALIDATOR=${VALIDATOR:-true} GIGA_EXECUTOR=${GIGA_EXECUTOR:-true} GIGA_OCC=${GIGA_OCC:-true} AUTOBAHN=${AUTOBAHN:-false} -AUTOBAHN_EVMONLY_IN_MEMORY=${AUTOBAHN_EVMONLY_IN_MEMORY:-false} +AUTOBAHN_EVMONLY=${AUTOBAHN_EVMONLY:-false} GIGA_STORAGE=${GIGA_STORAGE:-false} # GIGA_FLATKV_ONLY=true boots the cluster directly in the terminal v3 # steady state: all SC writes route to FlatKV and memiavl is not allocated. @@ -169,9 +169,9 @@ if [ "$AUTOBAHN" = "true" ]; then NODE_DIRS="$NODE_DIRS build/generated/node_${i}" done - if [ "$AUTOBAHN_EVMONLY_IN_MEMORY" = "true" ]; then - seid tendermint gen-autobahn-config $NODE_DIRS --output "$AUTOBAHN_CONFIG" --persistent-state-dir= - sed -i 's/^evm-only-in-memory = .*/evm-only-in-memory = true/' ~/.sei/config/config.toml + if [ "$AUTOBAHN_EVMONLY" = "true" ]; then + seid tendermint gen-autobahn-config $NODE_DIRS --output "$AUTOBAHN_CONFIG" + sed -i 's/^evm-only = .*/evm-only = true/' ~/.sei/config/config.toml sed -i '/^\[rpc\]/,/^\[/ s|^laddr = .*|laddr = ""|' ~/.sei/config/config.toml sed -i '/^\[api\]/,/^\[/ s/^enable = .*/enable = false/' ~/.sei/config/app.toml sed -i '/^\[grpc\]/,/^\[/ s/^enable = .*/enable = false/' ~/.sei/config/app.toml diff --git a/docker/localnode/scripts/step5_start_sei.sh b/docker/localnode/scripts/step5_start_sei.sh index 57961fd332..eb081c37f0 100755 --- a/docker/localnode/scripts/step5_start_sei.sh +++ b/docker/localnode/scripts/step5_start_sei.sh @@ -16,7 +16,7 @@ echo "Node $NODE_ID seid is started now" # launch.complete means the node's query surface is available, not merely that # the process has started. node_query_ready() { - if [ "${AUTOBAHN_EVMONLY_IN_MEMORY:-false}" = "true" ]; then + if [ "${AUTOBAHN_EVMONLY:-false}" = "true" ]; then curl -fsS -X POST \ -H 'content-type: application/json' \ --data '{"jsonrpc":"2.0","id":1,"method":"eth_sendRawTransaction","params":["0x01"]}' \ diff --git a/giga/evmonly/README.md b/giga/evmonly/README.md index 2d9e6771c9..23b04ce175 100644 --- a/giga/evmonly/README.md +++ b/giga/evmonly/README.md @@ -28,11 +28,13 @@ The `evmonly` package currently provides: - go-ethereum `core.ApplyMessage` execution against an SDK-free `vm.StateDB` - key-addressable state reads for balance, nonce, code, and storage - deterministic post-block `StateChangeSet` construction -- direct snapshot reads and ordered state commits through `giga.StateDB` +- direct snapshot reads and ordered state commits through the Giga `StateDB` - optional executor-internal Block-STM-style execution for optimistic parallel transaction execution with granular validation and reruns - Ethereum receipt construction with logs, bloom, gas, tx hash, block metadata, contract address, and effective gas price +- receipt persistence through the real Giga `ReceiptStore` backend in load-test + runtimes, with a concurrency-safe in-memory implementation for unit tests - a versioned `MemoryStore` giga implementation over an immutable `StateReader` for tests and load generation - fail-closed custom precompile placeholders @@ -70,34 +72,46 @@ prepare then execute in one call. `PreparedBlock` is trusted executor-produced data: callers should pass the result of `PrepareBlock` unchanged, because `ExecutePreparedBlock` does not recover senders again. -The executor is always store-backed. `WithStore(...)` selects the `giga.StateDB` -implementation and its `NamedChangeSetEncoder`; execution fails closed if -either is missing. For each block the executor opens a current -`giga.StateView`, executes against its EVM-native read methods, converts the -resulting `StateChangeSet`, and calls `CommitStateChanges`. Execution and commit -on an executor are serialized so blocks cannot share a stale snapshot or -overlap commits; callers must still submit block heights in order. The snapshot -stays open through the commit and is always closed afterward. An empty block -still commits an encoded empty changeset so the store can advance its height. -Stateless preparation can continue concurrently with store-backed execution. - -The encoder is explicit because `giga.StateDB` defines the protobuf commit +The executor is always store-backed. `WithStorageManager(...)` selects the +`bootstrap.GigaStorageManager` that provides both the Giga `StateDB` and the ledger +receipt store, plus the `NamedChangeSetEncoder` for its state implementation. +Unit tests can supply those dependencies independently. Execution fails closed +if either store or the encoder is missing. +For each block the executor opens a current `giga.StateView`, executes against +its EVM-native read methods, converts the resulting `StateChangeSet`, and calls +`CommitStateChanges`. Execution and commit on an executor are serialized so +blocks cannot share a stale snapshot or overlap commits; callers must still +submit block heights in order. The snapshot stays open through the commit and +is always closed afterward. An empty block still commits an encoded empty +changeset so the store can advance its height. Stateless preparation can +continue concurrently with store-backed execution. + +The encoder is explicit because the Giga `StateDB` defines the protobuf commit transport but does not define an on-disk key layout. In particular, an encoder must preserve `StorageClears` as prefix clears rather than silently dropping -persisted slots that were not read during execution. Encoding or commit failures -release the block result and return an error without invoking `ResultSink`. -`ResultSink` runs after the state commit succeeds; a sink error does not roll -back that commit. - -`MemoryStore` is the non-persistent implementation used by tests and the load -harness. It wraps an immutable `StateReader`, encodes changes directly into -typed `NamedChangeSet` key/value pairs, and retains committed values in -versioned overlays so current and historical snapshots stay stable without -copying the complete base state per block. It is not the production SC/SS -implementation. Every base -`StateReader` method must be safe for concurrent calls, and returned balances -and code must remain immutable while read. Call `Close()` to disable future OCC -execution on an executor. +persisted slots that were not read during execution. Encoding, state commit, or +receipt-store failures release the block result and return an error without +invoking `ResultSink`. Ethereum receipts are converted into +`receipt.ReceiptRecord` values and persisted through the shared +`receipt.ReceiptStore` interface before the height-advancing state commit, +including for empty blocks. A receipt failure leaves state unchanged so the +block can be retried. A state failure can leave receipts behind, but retrying +the block overwrites them. `ResultSink` runs only after both stores succeed. + +FlatKV does not yet expose balance reads and writes. EVM-only runtimes therefore +use `PlaceholderBalanceStore` for balances while committing nonce, code, and +storage changes to the manager-owned state database. The placeholder applies +post-block balances only after the persistent state commit succeeds. + +`MemoryStore` and `MemoryReceiptStore` are non-persistent unit-test doubles. +`MemoryStore` wraps an immutable `StateReader`, retains committed values in +versioned overlays, and keeps current and historical snapshots stable without +copying the complete base state per block. `MemoryReceiptStore` implements the +shared receipt interface and indexes cloned Sei receipt records by block number +and transaction hash. Load-test runtimes use the real Giga storage manager +instead. Every base `StateReader` method must be safe for concurrent calls, and +returned balances and code must remain immutable while read. Call `Close()` to +disable future OCC execution on an executor. A non-nil `error` means block validation failed and the caller must not commit a partial output. EVM call failures inside an otherwise valid transaction are diff --git a/giga/evmonly/balance_store.go b/giga/evmonly/balance_store.go new file mode 100644 index 0000000000..737eaee80c --- /dev/null +++ b/giga/evmonly/balance_store.go @@ -0,0 +1,64 @@ +package evmonly + +import ( + "math/big" + "sync" + + "github.com/ethereum/go-ethereum/common" +) + +// BalanceReader returns an account's current EVM balance. +type BalanceReader interface { + GetBalance(common.Address) *big.Int +} + +// BalanceStore holds current EVM balances over an immutable initial balance +// source. +type BalanceStore interface { + BalanceReader + ApplyBalanceChanges([]BalanceChange) +} + +// PlaceholderBalanceStore is a process-local BalanceStore for runtimes whose +// persistent state backend does not expose EVM balances. +type PlaceholderBalanceStore struct { + mu sync.RWMutex + initial BalanceReader + balances map[common.Address]*big.Int +} + +// NewPlaceholderBalanceStore returns a balance store backed by initial for +// accounts without an applied balance change. +func NewPlaceholderBalanceStore(initial BalanceReader) *PlaceholderBalanceStore { + return &PlaceholderBalanceStore{ + initial: initial, + balances: make(map[common.Address]*big.Int), + } +} + +// GetBalance returns the latest applied balance or the account's initial +// balance when it has not changed. +func (s *PlaceholderBalanceStore) GetBalance(address common.Address) *big.Int { + s.mu.RLock() + balance, ok := s.balances[address] + if ok { + balance = cloneBig(balance) + } + s.mu.RUnlock() + if ok { + return balance + } + if s.initial == nil { + return new(big.Int) + } + return cloneBig(s.initial.GetBalance(address)) +} + +// ApplyBalanceChanges installs the post-block balances in changes. +func (s *PlaceholderBalanceStore) ApplyBalanceChanges(changes []BalanceChange) { + s.mu.Lock() + defer s.mu.Unlock() + for _, change := range changes { + s.balances[change.Address] = cloneBig(change.Balance) + } +} diff --git a/giga/evmonly/balance_store_test.go b/giga/evmonly/balance_store_test.go new file mode 100644 index 0000000000..4ca1353fc7 --- /dev/null +++ b/giga/evmonly/balance_store_test.go @@ -0,0 +1,26 @@ +package evmonly + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestPlaceholderBalanceStoreAppliesOwnedBalanceChanges(t *testing.T) { + address := common.Address{0x11} + initial := NewMemoryState() + initial.SetBalance(address, big.NewInt(10)) + store := NewPlaceholderBalanceStore(initial) + + require.Equal(t, big.NewInt(10), store.GetBalance(address)) + + updated := big.NewInt(20) + store.ApplyBalanceChanges([]BalanceChange{{Address: address, Balance: updated}}) + updated.SetInt64(30) + got := store.GetBalance(address) + require.Equal(t, big.NewInt(20), got) + got.SetInt64(40) + require.Equal(t, big.NewInt(20), store.GetBalance(address)) +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/README.md b/giga/evmonly/cmd/evmonly-loadtest/README.md index 3b50982b4b..0349fcfaca 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/README.md +++ b/giga/evmonly/cmd/evmonly-loadtest/README.md @@ -1,8 +1,10 @@ # evmonly-loadtest `evmonly-loadtest` is a standalone executable for feeding synthetic blocks to -the EVM-only executor through an in-memory `giga.StateDB`, without Cosmos SDK -state, mempool, RPC, or production SC/SS persistence. +the EVM-only executor through the disk-backed Giga state and receipt stores, +without Cosmos SDK state, mempool, or RPC. It opens the complete production +storage manager, including the block store, in a temporary directory that is +removed when the load test exits. The synthetic workload defaults to local EVM chain ID `1337`; override it with `--chain-id` when testing another signing domain. @@ -11,9 +13,10 @@ It currently generates pure EVM legacy transfer transactions, ERC20 transfer transactions using `sei-load`'s compiled contract runtime, and a contract-call workload that exercises nested StateDB snapshot/revert behavior. By default, each generated sender account has one -nonce-0 transaction and is funded in the command's in-memory genesis state -before its block is queued. Recipients are unique by default so the transfer -workloads exercise the optimistic no-overlap case. Pass +nonce-0 transaction and is funded in generated genesis state. Non-balance +genesis state is committed to FlatKV before the measured blocks run. Recipients +are unique by default so the transfer workloads exercise the optimistic +no-overlap case. Pass `--recipient-conflict-rate=<0..1>` to pair that fraction of each block's transactions onto shared recipients, or pass `--recipient=0x...` to force all transactions to a single recipient. Pass `--same-sender` to use one sender per @@ -178,14 +181,20 @@ The command reports these saturation signals on stdout and at `/metrics`: Every run uses the Giga executor lifecycle: -- `generatedState` implements `evmonly.StateReader` and supplies immutable - generated genesis balances, nonces, code, and storage. -- `evmonly.MemoryStore` opens versioned snapshots over that genesis state and - applies the executor's encoded output through `CommitStateChanges`. +- `generatedState` builds deterministic genesis balances, nonces, code, and + storage. The harness commits nonce, code, and storage state to FlatKV at + height 1. +- Balances use a process-local placeholder until FlatKV exposes balance reads + and writes. The placeholder applies every post-block balance change, so load + execution preserves balance semantics without changing the storage package. +- The measured workload begins at height 2 and commits non-balance state and + receipts through the real Giga storage manager. The manager also opens the + production block store; the standalone harness has no consensus layer to + populate it. - `discardResultSink` discards the already-committed block result and receipts; it is not responsible for state persistence. -With `--result-sink=file`, after the in-memory Giga commit succeeds the loadtest +With `--result-sink=file`, after the Giga commit succeeds the loadtest harness hands pooled `evmonly.BlockResult` values to an async writer through the executor's `evmonly.ResultSink` interface. The writer appends changesets to `changesets.rlp` and receipts to `receipts.rlp` under `--persist-dir`; each diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index e8be3793d2..5e76fb42df 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -28,7 +28,10 @@ import ( func withGeneratedState(state evmonly.StateReader) evmonly.Option { store := evmonly.NewMemoryStore(state) - return evmonly.WithStore(store, store.EncodeChangeSet) + return func(executor *evmonly.Executor) { + evmonly.WithStore(store, store.EncodeChangeSet)(executor) + evmonly.WithReceiptStore(evmonly.NewMemoryReceiptStore())(executor) + } } type readOnlyGeneratedStore struct { @@ -41,7 +44,10 @@ func (*readOnlyGeneratedStore) CommitStateChanges(int64, []*proto.NamedChangeSet func withReadOnlyGeneratedState(state evmonly.StateReader) evmonly.Option { store := &readOnlyGeneratedStore{MemoryStore: evmonly.NewMemoryStore(state)} - return evmonly.WithStore(store, store.EncodeChangeSet) + return func(executor *evmonly.Executor) { + evmonly.WithStore(store, store.EncodeChangeSet)(executor) + evmonly.WithReceiptStore(evmonly.NewMemoryReceiptStore())(executor) + } } func TestTransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { diff --git a/giga/evmonly/cmd/evmonly-loadtest/pipeline.go b/giga/evmonly/cmd/evmonly-loadtest/pipeline.go index 795f227342..b540f4fa57 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/pipeline.go +++ b/giga/evmonly/cmd/evmonly-loadtest/pipeline.go @@ -14,6 +14,8 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/sei-protocol/sei-chain/giga/evmonly" "github.com/sei-protocol/sei-chain/giga/evmonly/cmd/evmonly-loadtest/scenarios" + "github.com/sei-protocol/sei-chain/sei-db/bootstrap" + seidbconfig "github.com/sei-protocol/sei-chain/sei-db/config" "golang.org/x/sync/errgroup" ) @@ -105,6 +107,34 @@ func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workloa prebuildElapsed := time.Since(prebuildStartedAt) printPrebuildReport(prebuildElapsed, prebuilt, cfg.txsPerBlock) + storageDirectory, err := os.MkdirTemp("", "evmonly-loadtest-storage-") + if err != nil { + return fmt.Errorf("create storage directory: %w", err) + } + defer func() { + err = errors.Join(err, os.RemoveAll(storageDirectory)) + }() + storageConfig, err := seidbconfig.DefaultGigaStorageConfig(storageDirectory) + if err != nil { + return fmt.Errorf("configure storage manager: %w", err) + } + storage, err := bootstrap.NewGigaStorageManager(ctx, storageConfig.WithFullNodeMode()) + if err != nil { + return fmt.Errorf("open storage manager: %w", err) + } + defer func() { + err = errors.Join(err, storage.Close()) + }() + changeSetEncoder := evmonly.NewFlatKVChangeSetEncoder(storage.SC()) + balanceStore := evmonly.NewPlaceholderBalanceStore(state) + genesisChanges, err := changeSetEncoder(state.changeSet()) + if err != nil { + return fmt.Errorf("encode generated genesis state: %w", err) + } + if err := storage.StateDB().CommitStateChanges(1, genesisChanges); err != nil { + return fmt.Errorf("commit generated genesis state: %w", err) + } + profiles, err := startProfiles(cfg) if err != nil { return err @@ -121,10 +151,10 @@ func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workloa startedAt := time.Now() group, groupCtx := errgroup.WithContext(ctx) - store := evmonly.NewMemoryStore(state) executor := evmonly.NewExecutor( executorConfig(cfg), - evmonly.WithStore(store, store.EncodeChangeSet), + evmonly.WithStorageManager(storage, changeSetEncoder), + evmonly.WithBalanceStore(balanceStore), evmonly.WithResultSink(sinks), ) defer executor.Close() @@ -174,7 +204,7 @@ func prebuildBlockRequests(ctx context.Context, cfg config, workload blockWorklo if number > cfg.blocks { return nil } - request, err := workload.BuildBlock(groupCtx, number) + request, err := workload.BuildBlock(groupCtx, number+1) if err != nil { if groupCtx.Err() != nil { return nil diff --git a/giga/evmonly/cmd/evmonly-loadtest/state.go b/giga/evmonly/cmd/evmonly-loadtest/state.go index 1e79a3991b..36291c1056 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/state.go +++ b/giga/evmonly/cmd/evmonly-loadtest/state.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "math/big" + "sort" "sync" "sync/atomic" @@ -161,6 +163,58 @@ func (s *generatedState) requireMutable() { } } +func (s *generatedState) changeSet() evmonly.StateChangeSet { + if !s.frozen.Load() { + panic("generated state must be frozen before encoding") + } + addresses := make(map[common.Address]struct{}, len(s.balances)+len(s.nonces)+len(s.code)+len(s.storage)) + for address := range s.balances { + addresses[address] = struct{}{} + } + for address := range s.nonces { + addresses[address] = struct{}{} + } + for address := range s.code { + addresses[address] = struct{}{} + } + for address := range s.storage { + addresses[address] = struct{}{} + } + ordered := make([]common.Address, 0, len(addresses)) + for address := range addresses { + ordered = append(ordered, address) + } + sort.Slice(ordered, func(i, j int) bool { + return bytes.Compare(ordered[i][:], ordered[j][:]) < 0 + }) + + var changes evmonly.StateChangeSet + for _, address := range ordered { + if nonce, ok := s.nonces[address]; ok { + changes.Nonces = append(changes.Nonces, evmonly.NonceChange{Address: address, Nonce: nonce}) + } + if code, ok := s.code[address]; ok { + changes.Code = append(changes.Code, evmonly.CodeChange{Address: address, Code: cloneBytes(code)}) + } + slots := s.storage[address] + orderedSlots := make([]common.Hash, 0, len(slots)) + for slot := range slots { + orderedSlots = append(orderedSlots, slot) + } + sort.Slice(orderedSlots, func(i, j int) bool { + return bytes.Compare(orderedSlots[i][:], orderedSlots[j][:]) < 0 + }) + for _, slot := range orderedSlots { + changes.Storage = append(changes.Storage, evmonly.StorageChange{ + Address: address, + Key: slot, + Value: slots[slot], + }) + } + } + return changes +} + func cloneBytes(v []byte) []byte { if len(v) == 0 { return nil diff --git a/giga/evmonly/executor.go b/giga/evmonly/executor.go index a8e76da0ca..4a253408ce 100644 --- a/giga/evmonly/executor.go +++ b/giga/evmonly/executor.go @@ -15,6 +15,7 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/params" "github.com/sei-protocol/sei-chain/giga/evmonly/precompiles" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" ) @@ -26,8 +27,10 @@ type Executor struct { resultPool *blockResultPool stateDBPool sync.Pool storeMu sync.Mutex - store gigatypes.StateDB + stateStore gigatypes.StateDB + receiptStore receipt.ReceiptStore changeSetEncoder NamedChangeSetEncoder + balanceStore BalanceStore closed atomic.Bool } @@ -39,13 +42,11 @@ func WithResultSink(sink ResultSink) Option { } } -// WithStore selects the giga store implementation used for all state reads and -// commits. The encoder owns the implementation-specific conversion from the -// executor's EVM-native StateChangeSet to the store's protobuf changesets. -func WithStore(store gigatypes.StateDB, encoder NamedChangeSetEncoder) Option { +// WithBalanceStore supplies balances when the persistent state view does not +// implement balance reads. +func WithBalanceStore(store BalanceStore) Option { return func(e *Executor) { - e.store = store - e.changeSetEncoder = encoder + e.balanceStore = store } } diff --git a/giga/evmonly/executor_test.go b/giga/evmonly/executor_test.go index 7e3c01bef2..0274673715 100644 --- a/giga/evmonly/executor_test.go +++ b/giga/evmonly/executor_test.go @@ -20,6 +20,8 @@ import ( "github.com/stretchr/testify/require" "github.com/sei-protocol/sei-chain/giga/evmonly/precompiles" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" ) const ( @@ -34,6 +36,18 @@ type recordingResultSink struct { releases []func() } +type failingReceiptStore struct { + *MemoryReceiptStore + err error +} + +func (s *failingReceiptStore) SetReceipts(ctx sdk.Context, records []receipt.ReceiptRecord) error { + if s.err != nil { + return s.err + } + return s.MemoryReceiptStore.SetReceipts(ctx, records) +} + func (s *recordingResultSink) StoreBlockResult(_ context.Context, height uint64, result *BlockResult, release func()) error { s.heights = append(s.heights, height) s.results = append(s.results, result) @@ -111,6 +125,69 @@ func TestExecutorInvokesResultSink(t *testing.T) { sink.releases[0]() } +func TestExecutorStoresReceipts(t *testing.T) { + chainID := big.NewInt(testChainID) + key, err := crypto.GenerateKey() + require.NoError(t, err) + sender := crypto.PubkeyToAddress(key.PublicKey) + recipient := common.HexToAddress("0x00000000000000000000000000000000000000a9") + + state := NewMemoryState() + state.SetBalance(sender, big.NewInt(testFundedBalanceWei)) + receiptStore := NewMemoryReceiptStore() + rawTx := signLegacyTx(t, key, chainID, 0, &recipient, big.NewInt(7), nil) + stateStore := NewMemoryStore(state) + executor := NewExecutor(Config{}, withTestStores(stateStore, receiptStore, stateStore.EncodeChangeSet)) + ctx := blockContext(chainID) + ctx.Number = 77 + + result, err := executor.ExecuteBlock(t.Context(), BlockRequest{ + Context: ctx, + Txs: [][]byte{rawTx}, + }) + + require.NoError(t, err) + require.Len(t, result.Receipts, 1) + stored, err := receiptStore.GetReceipt(newReceiptContext(t.Context(), int64(ctx.Number)), result.Receipts[0].TxHash) + require.NoError(t, err) + require.Equal(t, result.Receipts[0].TxHash.Hex(), stored.TxHashHex) + require.Equal(t, ctx.Number, stored.BlockNumber) + require.Equal(t, sender.Hex(), stored.From) + require.Equal(t, recipient.Hex(), stored.To) + require.Equal(t, uint64(ethtypes.ReceiptStatusSuccessful), uint64(stored.Status)) +} + +func TestExecutorReturnsReceiptStoreError(t *testing.T) { + storeErr := errors.New("receipt write failed") + receiptStore := &failingReceiptStore{MemoryReceiptStore: NewMemoryReceiptStore(), err: storeErr} + stateStore := NewMemoryStore(NewMemoryState()) + sink := &recordingResultSink{} + executor := NewExecutor( + Config{BlockResultPoolSize: 1}, + withTestStores(stateStore, receiptStore, EncodeMemoryStoreChangeSet), + WithResultSink(sink), + ) + request := BlockRequest{Context: blockContext(big.NewInt(testChainID))} + + result, err := executor.ExecuteBlock(t.Context(), request) + + require.ErrorIs(t, err, storeErr) + require.Nil(t, result) + require.Empty(t, sink.results) + require.Equal(t, BlockResultPoolStats{Capacity: 1, Available: 1}, executor.ResultPoolStats()) + view := stateStore.OpenView() + require.Zero(t, view.GetBlockHeight()) + view.Close() + + receiptStore.err = nil + result, err = executor.ExecuteBlock(t.Context(), request) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, sink.results, 1) + result.Release() + sink.releases[0]() +} + func TestExecutorPooledResultRelease(t *testing.T) { chainID := big.NewInt(testChainID) key, err := crypto.GenerateKey() diff --git a/giga/evmonly/flatkv_changeset.go b/giga/evmonly/flatkv_changeset.go new file mode 100644 index 0000000000..4d7e00f5e0 --- /dev/null +++ b/giga/evmonly/flatkv_changeset.go @@ -0,0 +1,129 @@ +package evmonly + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + "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/sc/flatkv" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/vtype" +) + +// NewFlatKVChangeSetEncoder returns an encoder for FlatKV's non-balance EVM +// keyspace. The store is used to expand storage-prefix clears. +func NewFlatKVChangeSetEncoder(store *flatkv.CommitStore) NamedChangeSetEncoder { + return func(changes StateChangeSet) ([]*proto.NamedChangeSet, error) { + return encodeFlatKVChangeSet(store, changes) + } +} + +func encodeFlatKVChangeSet(store *flatkv.CommitStore, changes StateChangeSet) ([]*proto.NamedChangeSet, error) { + if store == nil { + return nil, errors.New("flatkv changeset encoder requires a store") + } + pairs := make([]*proto.KVPair, 0, + len(changes.Nonces)+2*len(changes.Code)+len(changes.Storage)) + + // Balance changes remain in the executor's placeholder balance store until + // the persistent state view exposes balance reads and writes. + for _, change := range changes.Nonces { + value := make([]byte, vtype.NonceLen) + binary.BigEndian.PutUint64(value, change.Nonce) + pairs = append(pairs, &proto.KVPair{ + Key: flatKVAddressKey(keys.EVMKeyNonce, change.Address), + Value: value, + }) + } + for _, change := range changes.Code { + codeHashPair := &proto.KVPair{Key: flatKVAddressKey(keys.EVMKeyCodeHash, change.Address)} + codePair := &proto.KVPair{Key: flatKVAddressKey(keys.EVMKeyCode, change.Address)} + if change.Delete || len(change.Code) == 0 { + codeHashPair.Delete = true + codePair.Delete = true + } else { + codeHash := crypto.Keccak256Hash(change.Code) + codeHashPair.Value = codeHash[:] + codePair.Value = cloneBytes(change.Code) + } + pairs = append(pairs, codeHashPair, codePair) + } + for _, address := range changes.StorageClears { + var err error + pairs, err = appendFlatKVStorageClearPairs(store, pairs, address) + if err != nil { + return nil, err + } + } + for _, change := range changes.Storage { + pair := &proto.KVPair{Key: flatKVStorageKey(change.Address, change.Key)} + if change.Delete || change.Value == (common.Hash{}) { + pair.Delete = true + } else { + pair.Value = cloneBytes(change.Value[:]) + } + pairs = append(pairs, pair) + } + if len(pairs) == 0 { + return nil, nil + } + return []*proto.NamedChangeSet{{ + Name: keys.EVMStoreKey, + Changeset: proto.ChangeSet{Pairs: pairs}, + }}, nil +} + +func appendFlatKVStorageClearPairs( + store *flatkv.CommitStore, + pairs []*proto.KVPair, + address common.Address, +) ([]*proto.KVPair, error) { + start := flatKVStoragePrefix(address) + iterator, err := store.Iterator(keys.EVMStoreKey, start, ktype.PrefixEnd(start), true) + if err != nil { + return nil, fmt.Errorf("iterate storage for clear of %s: %w", address, err) + } + defer func() { _ = iterator.Close() }() + + for ; iterator.Valid(); iterator.Next() { + key := iterator.Key() + if len(key) != 1+common.AddressLength+common.HashLength || + key[0] != flatKVStoragePrefixByte() || !bytes.Equal(key[1:1+common.AddressLength], address[:]) { + return nil, fmt.Errorf("unexpected storage key while clearing %s: %x", address, key) + } + pairs = append(pairs, &proto.KVPair{Key: cloneBytes(key), Delete: true}) + } + if err := iterator.Error(); err != nil { + return nil, fmt.Errorf("iterate storage for clear of %s: %w", address, err) + } + return pairs, nil +} + +func flatKVAddressKey(kind keys.EVMKeyKind, address common.Address) []byte { + return keys.BuildEVMKey(kind, address[:]) +} + +func flatKVStorageKey(address common.Address, slot common.Hash) []byte { + key := make([]byte, 0, common.AddressLength+common.HashLength) + key = append(key, address[:]...) + key = append(key, slot[:]...) + return keys.BuildEVMKey(keys.EVMKeyStorage, key) +} + +func flatKVStoragePrefix(address common.Address) []byte { + return keys.BuildEVMKey(keys.EVMKeyStorage, address[:]) +} + +func flatKVStoragePrefixByte() byte { + prefix, ok := keys.EVMKeyPrefixByte(keys.EVMKeyStorage) + if !ok { + panic("missing EVM storage prefix") + } + return prefix +} diff --git a/giga/evmonly/flatkv_changeset_test.go b/giga/evmonly/flatkv_changeset_test.go new file mode 100644 index 0000000000..f51f657eab --- /dev/null +++ b/giga/evmonly/flatkv_changeset_test.go @@ -0,0 +1,64 @@ +package evmonly + +import ( + "context" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" + flatkvconfig "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/config" +) + +func TestFlatKVChangeSetEncoderPersistsExecutorState(t *testing.T) { + cfg := flatkvconfig.DefaultConfig() + cfg.DataDir = t.TempDir() + store, err := openFlatKVTestStore(t.Context(), cfg) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + + address := common.Address{0x11} + slotA, slotB := common.Hash{0x21}, common.Hash{0x22} + encode := NewFlatKVChangeSetEncoder(store) + changes, err := encode(StateChangeSet{ + Nonces: []NonceChange{{Address: address, Nonce: 7}}, + Code: []CodeChange{{Address: address, Code: []byte{0x60, 0x01}}}, + Storage: []StorageChange{ + {Address: address, Key: slotA, Value: common.Hash{0xaa}}, + {Address: address, Key: slotB, Value: common.Hash{0xbb}}, + }, + }) + require.NoError(t, err) + require.NoError(t, store.CommitStateChanges(1, changes)) + + view := store.OpenView() + require.Equal(t, uint64(7), view.GetNonce(address)) + require.Equal(t, []byte{0x60, 0x01}, view.GetCode(address)) + require.Equal(t, common.Hash{0xaa}, view.GetStorage(address, slotA)) + view.Close() + + changes, err = encode(StateChangeSet{ + StorageClears: []common.Address{address}, + Storage: []StorageChange{{Address: address, Key: slotB, Value: common.Hash{0xcc}}}, + }) + require.NoError(t, err) + require.NoError(t, store.CommitStateChanges(2, changes)) + + view = store.OpenView() + defer view.Close() + require.Equal(t, common.Hash{}, view.GetStorage(address, slotA)) + require.Equal(t, common.Hash{0xcc}, view.GetStorage(address, slotB)) +} + +func openFlatKVTestStore(ctx context.Context, cfg *flatkvconfig.Config) (*flatkv.CommitStore, error) { + store, err := flatkv.NewCommitStore(ctx, cfg, nil) + if err != nil { + return nil, err + } + if err := store.LoadLatest(); err != nil { + _ = store.Close() + return nil, err + } + return store, nil +} diff --git a/giga/evmonly/giga_store.go b/giga/evmonly/giga_store.go index 7136ee7a0d..0faf4fac78 100644 --- a/giga/evmonly/giga_store.go +++ b/giga/evmonly/giga_store.go @@ -15,7 +15,8 @@ import ( const maxGigaStoreBlockNumber = uint64(1<<63 - 1) var ( - errMissingStore = errors.New("executor requires a giga store") + errMissingStateStore = errors.New("executor requires a state store") + errMissingReceiptStore = errors.New("executor requires a receipt store") errMissingNamedChangeSetEncoder = errors.New("giga store requires a named changeset encoder") ) @@ -28,8 +29,13 @@ var _ StateReader = gigaSnapshotStateReader{} type NamedChangeSetEncoder func(StateChangeSet) ([]*proto.NamedChangeSet, error) func (e *Executor) executePreparedBlockWithStore(ctx context.Context, req PreparedBlock) (*BlockResult, error) { - if e.store == nil { - return nil, errMissingStore + stateStore := e.stateStore + if stateStore == nil { + return nil, errMissingStateStore + } + receiptStore := e.receiptStore + if receiptStore == nil { + return nil, errMissingReceiptStore } if e.changeSetEncoder == nil { return nil, errMissingNamedChangeSetEncoder @@ -48,13 +54,13 @@ func (e *Executor) executePreparedBlockWithStore(ctx context.Context, req Prepar if err := ctx.Err(); err != nil { return nil, err } - snapshot := e.store.OpenView() + snapshot := stateStore.OpenView() if snapshot == nil { return nil, errors.New("giga store returned a nil snapshot") } defer snapshot.Close() - result, err := e.executePreparedBlock(ctx, req, gigaSnapshotStateReader{snapshot: snapshot}) + result, err := e.executePreparedBlock(ctx, req, gigaSnapshotStateReader{snapshot: snapshot, balances: e.balanceStore}) if err != nil { return nil, err } @@ -75,18 +81,32 @@ func (e *Executor) executePreparedBlockWithStore(ctx context.Context, req Prepar if err := ctx.Err(); err != nil { return nil, err } - if err := e.store.CommitStateChanges(blockNumber, changesets); err != nil { + records, err := receiptRecords(req.Context.Number, result) + if err != nil { + return nil, fmt.Errorf("encode receipts for block %d: %w", req.Context.Number, err) + } + if err := receiptStore.SetReceipts(newReceiptContext(ctx, blockNumber), records); err != nil { + return nil, fmt.Errorf("store receipts for block %d: %w", req.Context.Number, err) + } + if err := stateStore.CommitStateChanges(blockNumber, changesets); err != nil { return nil, fmt.Errorf("commit state changes for block %d: %w", req.Context.Number, err) } + if e.balanceStore != nil { + e.balanceStore.ApplyBalanceChanges(result.ChangeSet.Balances) + } ok = true return result, nil } type gigaSnapshotStateReader struct { snapshot gigatypes.EVMStateView + balances BalanceReader } func (r gigaSnapshotStateReader) GetBalance(addr common.Address) *big.Int { + if r.balances != nil { + return cloneBig(r.balances.GetBalance(addr)) + } balance := r.snapshot.GetBalance(addr) return new(big.Int).SetBytes(balance[:]) } diff --git a/giga/evmonly/giga_store_test.go b/giga/evmonly/giga_store_test.go index 2072c854bb..f15806917e 100644 --- a/giga/evmonly/giga_store_test.go +++ b/giga/evmonly/giga_store_test.go @@ -154,7 +154,9 @@ func TestExecutorCommitsGigaStoreStateChanges(t *testing.T) { recipient := testAddress(0xa9) snapshot := newMemoryGigaSnapshot(40) - snapshot.setBalance(sender, big.NewInt(testFundedBalanceWei)) + initialBalances := NewMemoryState() + initialBalances.SetBalance(sender, big.NewInt(testFundedBalanceWei)) + balanceStore := NewPlaceholderBalanceStore(initialBalances) store := &recordingGigaStore{snapshot: snapshot} wantChangesets := []*proto.NamedChangeSet{{ Name: "encoded", @@ -174,7 +176,10 @@ func TestExecutorCommitsGigaStoreStateChanges(t *testing.T) { rawTx := signLegacyTx(t, key, chainID, 0, &recipient, big.NewInt(7), nil) blockCtx := blockContext(chainID) blockCtx.Number = 41 - executor := NewExecutor(Config{}, WithStore(store, encoder)) + executor := NewExecutor(Config{}, + withTestStores(store, NewMemoryReceiptStore(), encoder), + WithBalanceStore(balanceStore), + ) result, err := executor.ExecuteBlock(t.Context(), BlockRequest{ Context: blockCtx, Txs: [][]byte{rawTx}, @@ -187,6 +192,9 @@ func TestExecutorCommitsGigaStoreStateChanges(t *testing.T) { require.Equal(t, []int64{41}, store.commitBlock) require.Equal(t, [][]*proto.NamedChangeSet{wantChangesets}, store.commits) require.Contains(t, result.ChangeSet.Balances, BalanceChange{Address: recipient, Balance: big.NewInt(7)}) + for _, change := range result.ChangeSet.Balances { + require.Equal(t, change.Balance, balanceStore.GetBalance(change.Address)) + } result.Release() } @@ -219,7 +227,7 @@ func TestExecutorGigaStoreSnapshotFeedsOCCExecution(t *testing.T) { } executor := NewExecutor( Config{MinGasPrice: big.NewInt(0), OCCWorkers: 2}, - WithStore(store, encoder), + withTestStores(store, NewMemoryReceiptStore(), encoder), ) defer executor.Close() blockCtx := blockContext(chainID) @@ -238,19 +246,38 @@ func TestExecutorGigaStoreSnapshotFeedsOCCExecution(t *testing.T) { } func TestExecutorGigaStoreFailuresDoNotCommitPartialState(t *testing.T) { - t.Run("missing store", func(t *testing.T) { + t.Run("missing stores", func(t *testing.T) { executor := NewExecutor(Config{}) result, err := executor.ExecuteBlock(t.Context(), BlockRequest{Context: blockContext(big.NewInt(testChainID))}) - require.ErrorIs(t, err, errMissingStore) + require.ErrorIs(t, err, errMissingStateStore) + require.Nil(t, result) + }) + + t.Run("missing state store", func(t *testing.T) { + executor := NewExecutor(Config{}, WithReceiptStore(NewMemoryReceiptStore())) + + result, err := executor.ExecuteBlock(t.Context(), BlockRequest{Context: blockContext(big.NewInt(testChainID))}) + + require.ErrorIs(t, err, errMissingStateStore) + require.Nil(t, result) + }) + + t.Run("missing receipt store", func(t *testing.T) { + store := NewMemoryStore(NewMemoryState()) + executor := NewExecutor(Config{}, WithStore(store, store.EncodeChangeSet)) + + result, err := executor.ExecuteBlock(t.Context(), BlockRequest{Context: blockContext(big.NewInt(testChainID))}) + + require.ErrorIs(t, err, errMissingReceiptStore) require.Nil(t, result) }) t.Run("missing encoder", func(t *testing.T) { snapshot := newMemoryGigaSnapshot(0) store := &recordingGigaStore{snapshot: snapshot} - executor := NewExecutor(Config{}, WithStore(store, nil)) + executor := NewExecutor(Config{}, withTestStores(store, NewMemoryReceiptStore(), nil)) result, err := executor.ExecuteBlock(t.Context(), BlockRequest{Context: blockContext(big.NewInt(testChainID))}) @@ -262,7 +289,7 @@ func TestExecutorGigaStoreFailuresDoNotCommitPartialState(t *testing.T) { t.Run("nil snapshot", func(t *testing.T) { store := &recordingGigaStore{} - executor := NewExecutor(Config{}, WithStore(store, func(StateChangeSet) ([]*proto.NamedChangeSet, error) { + executor := NewExecutor(Config{}, withTestStores(store, NewMemoryReceiptStore(), func(StateChangeSet) ([]*proto.NamedChangeSet, error) { return nil, nil })) @@ -277,7 +304,7 @@ func TestExecutorGigaStoreFailuresDoNotCommitPartialState(t *testing.T) { snapshot := newMemoryGigaSnapshot(0) store := &recordingGigaStore{snapshot: snapshot} encodeErr := errors.New("encode failed") - executor := NewExecutor(Config{BlockResultPoolSize: 1}, WithStore(store, func(StateChangeSet) ([]*proto.NamedChangeSet, error) { + executor := NewExecutor(Config{BlockResultPoolSize: 1}, withTestStores(store, NewMemoryReceiptStore(), func(StateChangeSet) ([]*proto.NamedChangeSet, error) { return nil, encodeErr })) @@ -299,7 +326,7 @@ func TestExecutorGigaStoreFailuresDoNotCommitPartialState(t *testing.T) { snapshot := newMemoryGigaSnapshot(0) store := &recordingGigaStore{snapshot: snapshot} encodeCalls := 0 - executor := NewExecutor(Config{MinGasPrice: big.NewInt(0)}, WithStore(store, func(StateChangeSet) ([]*proto.NamedChangeSet, error) { + executor := NewExecutor(Config{MinGasPrice: big.NewInt(0)}, withTestStores(store, NewMemoryReceiptStore(), func(StateChangeSet) ([]*proto.NamedChangeSet, error) { encodeCalls++ return nil, nil })) @@ -320,7 +347,7 @@ func TestExecutorGigaStoreFailuresDoNotCommitPartialState(t *testing.T) { snapshot := newMemoryGigaSnapshot(0) store := &recordingGigaStore{snapshot: snapshot} ctx, cancel := context.WithCancel(t.Context()) - executor := NewExecutor(Config{BlockResultPoolSize: 1}, WithStore(store, func(StateChangeSet) ([]*proto.NamedChangeSet, error) { + executor := NewExecutor(Config{BlockResultPoolSize: 1}, withTestStores(store, NewMemoryReceiptStore(), func(StateChangeSet) ([]*proto.NamedChangeSet, error) { cancel() return []*proto.NamedChangeSet{}, nil })) @@ -335,26 +362,49 @@ func TestExecutorGigaStoreFailuresDoNotCommitPartialState(t *testing.T) { }) t.Run("commit error", func(t *testing.T) { + chainID := big.NewInt(testChainID) + key, err := crypto.GenerateKey() + require.NoError(t, err) + sender := crypto.PubkeyToAddress(key.PublicKey) + recipient := testAddress(0xad) + initialBalance := big.NewInt(1_000_000_000) + initialBalances := NewMemoryState() + initialBalances.SetBalance(sender, initialBalance) + balanceStore := NewPlaceholderBalanceStore(initialBalances) snapshot := newMemoryGigaSnapshot(0) commitErr := errors.New("commit failed") store := &recordingGigaStore{snapshot: snapshot, commitErr: commitErr} - executor := NewExecutor(Config{BlockResultPoolSize: 1}, WithStore(store, func(StateChangeSet) ([]*proto.NamedChangeSet, error) { - return []*proto.NamedChangeSet{}, nil - })) + receiptStore := NewMemoryReceiptStore() + executor := NewExecutor( + Config{BlockResultPoolSize: 1, MinGasPrice: big.NewInt(0)}, + withTestStores(store, receiptStore, func(StateChangeSet) ([]*proto.NamedChangeSet, error) { + return []*proto.NamedChangeSet{}, nil + }), + WithBalanceStore(balanceStore), + ) + rawTx := signLegacyTxWithGasPrice( + t, key, chainID, 0, &recipient, big.NewInt(7), nil, 100_000, big.NewInt(0), + ) - result, err := executor.ExecuteBlock(t.Context(), BlockRequest{Context: blockContext(big.NewInt(testChainID))}) + result, err := executor.ExecuteBlock(t.Context(), BlockRequest{ + Context: blockContext(chainID), + Txs: [][]byte{rawTx}, + }) require.ErrorIs(t, err, commitErr) require.Nil(t, result) require.Len(t, store.commits, 1) require.Equal(t, 1, snapshot.closeCount) require.Equal(t, BlockResultPoolStats{Capacity: 1, Available: 1}, executor.ResultPoolStats()) + require.Equal(t, int64(blockContext(big.NewInt(testChainID)).Number), receiptStore.LatestVersion()) + require.Equal(t, initialBalance, balanceStore.GetBalance(sender)) + require.Zero(t, balanceStore.GetBalance(recipient).Sign()) }) t.Run("block number overflow", func(t *testing.T) { snapshot := newMemoryGigaSnapshot(0) store := &recordingGigaStore{snapshot: snapshot} - executor := NewExecutor(Config{}, WithStore(store, func(StateChangeSet) ([]*proto.NamedChangeSet, error) { + executor := NewExecutor(Config{}, withTestStores(store, NewMemoryReceiptStore(), func(StateChangeSet) ([]*proto.NamedChangeSet, error) { return nil, nil })) blockCtx := blockContext(big.NewInt(testChainID)) diff --git a/giga/evmonly/memory_store.go b/giga/evmonly/memory_store.go index 05fcb94874..102ed8c911 100644 --- a/giga/evmonly/memory_store.go +++ b/giga/evmonly/memory_store.go @@ -32,7 +32,7 @@ const ( var _ gigatypes.StateDB = (*MemoryStore)(nil) // MemoryStore adapts an immutable StateReader to the giga StateDB interface. It -// is intended for tests and load generation, not production persistence. +// is intended only for tests, not runtime persistence. // Commits are retained as versioned in-memory overlays so open and historical // snapshots remain stable without cloning the complete base state per block. type MemoryStore struct { diff --git a/giga/evmonly/memory_store_test.go b/giga/evmonly/memory_store_test.go index 5976a4366f..4fb807e028 100644 --- a/giga/evmonly/memory_store_test.go +++ b/giga/evmonly/memory_store_test.go @@ -198,7 +198,7 @@ func TestExecutorCommitsConsecutiveBlocksThroughMemoryStore(t *testing.T) { base := NewMemoryState() base.SetBalance(sender, big.NewInt(testFundedBalanceWei)) store := NewMemoryStore(base) - executor := NewExecutor(Config{}, WithStore(store, store.EncodeChangeSet)) + executor := NewExecutor(Config{}, withTestStores(store, NewMemoryReceiptStore(), store.EncodeChangeSet)) for nonce := uint64(0); nonce < 2; nonce++ { ctx := blockContext(chainID) diff --git a/giga/evmonly/receipt.go b/giga/evmonly/receipt.go new file mode 100644 index 0000000000..0317efed3b --- /dev/null +++ b/giga/evmonly/receipt.go @@ -0,0 +1,65 @@ +package evmonly + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/common" + + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" + tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" + evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" +) + +func receiptRecords(blockNumber uint64, result *BlockResult) ([]receipt.ReceiptRecord, error) { + if len(result.Receipts) != len(result.Txs) { + return nil, fmt.Errorf("receipt count %d does not match transaction result count %d", len(result.Receipts), len(result.Txs)) + } + records := make([]receipt.ReceiptRecord, len(result.Receipts)) + for i, ethReceipt := range result.Receipts { + if ethReceipt == nil { + return nil, fmt.Errorf("receipt %d is nil", i) + } + transactionIndex, ok := utils.SafeCast[uint32](ethReceipt.TransactionIndex) + if !ok { + return nil, fmt.Errorf("receipt %d transaction index %d exceeds uint32", i, ethReceipt.TransactionIndex) + } + status, ok := utils.SafeCast[uint32](ethReceipt.Status) + if !ok { + return nil, fmt.Errorf("receipt %d status %d exceeds uint32", i, ethReceipt.Status) + } + txResult := result.Txs[i] + stored := &evmtypes.Receipt{ + TxType: uint32(ethReceipt.Type), + CumulativeGasUsed: ethReceipt.CumulativeGasUsed, + TxHashHex: ethReceipt.TxHash.Hex(), + GasUsed: ethReceipt.GasUsed, + BlockNumber: blockNumber, + TransactionIndex: transactionIndex, + Status: status, + From: txResult.Sender.Hex(), + Logs: evmtypes.NewLogsFromEth(ethReceipt.Logs), + LogsBloom: append([]byte(nil), ethReceipt.Bloom[:]...), + } + if ethReceipt.EffectiveGasPrice != nil { + stored.EffectiveGasPrice = ethReceipt.EffectiveGasPrice.Uint64() + } + if txResult.To != nil { + stored.To = txResult.To.Hex() + } + if txResult.ContractAddress != (common.Address{}) { + stored.ContractAddress = txResult.ContractAddress.Hex() + } + if txResult.Err != nil { + stored.VmError = txResult.Err.Error() + } + records[i] = receipt.ReceiptRecord{TxHash: ethReceipt.TxHash, Receipt: stored} + } + return records, nil +} + +func newReceiptContext(ctx context.Context, blockHeight int64) sdk.Context { + return sdk.NewContext(nil, tmproto.Header{Height: blockHeight}, false).WithContext(ctx) +} diff --git a/giga/evmonly/receipt_store.go b/giga/evmonly/receipt_store.go new file mode 100644 index 0000000000..c75d85fe78 --- /dev/null +++ b/giga/evmonly/receipt_store.go @@ -0,0 +1,258 @@ +package evmonly + +import ( + "fmt" + "sync" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/eth/filters" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" + evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" +) + +var _ receipt.ReceiptStore = (*MemoryReceiptStore)(nil) + +type memoryReceiptEntry struct { + blockNumber uint64 + receipt *evmtypes.Receipt +} + +// MemoryReceiptStore is a process-local receipt store for unit tests. Runtime +// and load-test code uses the configured receipt backend instead. +type MemoryReceiptStore struct { + mu sync.RWMutex + + latestVersion int64 + earliestVersion int64 + blocks map[uint64]map[common.Hash]*evmtypes.Receipt + byTxHash map[common.Hash]memoryReceiptEntry +} + +// NewMemoryReceiptStore returns an empty MemoryReceiptStore. +func NewMemoryReceiptStore() *MemoryReceiptStore { + return &MemoryReceiptStore{ + blocks: make(map[uint64]map[common.Hash]*evmtypes.Receipt), + byTxHash: make(map[common.Hash]memoryReceiptEntry), + } +} + +// Name returns the store name used by storage lifecycle logs. +func (*MemoryReceiptStore) Name() string { + return "ReceiptDB" +} + +// LatestVersion returns the greatest block height recorded by the store. +func (s *MemoryReceiptStore) LatestVersion() int64 { + s.mu.RLock() + defer s.mu.RUnlock() + return s.latestVersion +} + +// EarliestVersion returns the current receipt retention floor. +func (s *MemoryReceiptStore) EarliestVersion() int64 { + s.mu.RLock() + defer s.mu.RUnlock() + return s.earliestVersion +} + +// SetLatestVersion advances the greatest block height recorded by the store. +func (s *MemoryReceiptStore) SetLatestVersion(version int64) error { + if version < 0 { + return fmt.Errorf("receipt version must not be negative: %d", version) + } + s.mu.Lock() + defer s.mu.Unlock() + if version > s.latestVersion { + s.latestVersion = version + } + return nil +} + +// SetEarliestVersion advances the receipt retention floor. +func (s *MemoryReceiptStore) SetEarliestVersion(version int64) error { + if version < 0 { + return fmt.Errorf("receipt version must not be negative: %d", version) + } + s.mu.Lock() + defer s.mu.Unlock() + if version > s.earliestVersion { + s.earliestVersion = version + } + return nil +} + +// GetReceipt returns a caller-owned copy of the receipt for txHash. +func (s *MemoryReceiptStore) GetReceipt(ctx sdk.Context, txHash common.Hash) (*evmtypes.Receipt, error) { + return s.GetReceiptFromStore(ctx, txHash) +} + +// GetReceiptFromStore returns a caller-owned copy of the receipt for txHash. +func (s *MemoryReceiptStore) GetReceiptFromStore(ctx sdk.Context, txHash common.Hash) (*evmtypes.Receipt, error) { + if err := receiptContextError(ctx); err != nil { + return nil, err + } + s.mu.RLock() + defer s.mu.RUnlock() + entry, ok := s.byTxHash[txHash] + if !ok { + return nil, receipt.ErrNotFound + } + if s.earliestVersion > 0 && entry.blockNumber < uint64(s.earliestVersion) { //nolint:gosec // earliestVersion is positive. + return nil, receipt.ErrNotFound + } + return cloneStoredReceipt(entry.receipt), nil +} + +// SetReceipts stores caller-owned copies of receipt records. +func (s *MemoryReceiptStore) SetReceipts(ctx sdk.Context, records []receipt.ReceiptRecord) error { + if err := receiptContextError(ctx); err != nil { + return err + } + if ctx.BlockHeight() < 0 { + return fmt.Errorf("receipt block height must not be negative: %d", ctx.BlockHeight()) + } + + stored := make([]receipt.ReceiptRecord, 0, len(records)) + latestVersion := ctx.BlockHeight() + for _, record := range records { + if record.Receipt == nil { + continue + } + if record.Receipt.BlockNumber > maxGigaStoreBlockNumber { + return fmt.Errorf("receipt block number %d exceeds int64", record.Receipt.BlockNumber) + } + if blockVersion := int64(record.Receipt.BlockNumber); blockVersion > latestVersion { //nolint:gosec // bounded above. + latestVersion = blockVersion + } + stored = append(stored, receipt.ReceiptRecord{ + TxHash: record.TxHash, + Receipt: cloneStoredReceipt(record.Receipt), + }) + } + if err := receiptContextError(ctx); err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + if err := receiptContextError(ctx); err != nil { + return err + } + for _, record := range stored { + if previous, ok := s.byTxHash[record.TxHash]; ok { + delete(s.blocks[previous.blockNumber], record.TxHash) + if len(s.blocks[previous.blockNumber]) == 0 { + delete(s.blocks, previous.blockNumber) + } + } + blockNumber := record.Receipt.BlockNumber + if s.blocks[blockNumber] == nil { + s.blocks[blockNumber] = make(map[common.Hash]*evmtypes.Receipt) + } + s.blocks[blockNumber][record.TxHash] = record.Receipt + s.byTxHash[record.TxHash] = memoryReceiptEntry{ + blockNumber: blockNumber, + receipt: record.Receipt, + } + } + if latestVersion > s.latestVersion { + s.latestVersion = latestVersion + } + return nil +} + +// FilterLogs reports that the in-memory backend does not support range queries. +func (*MemoryReceiptStore) FilterLogs( + ctx sdk.Context, + _, _ uint64, + _ filters.FilterCriteria, + _ *receipt.LogBudget, +) ([]*ethtypes.Log, error) { + if err := receiptContextError(ctx); err != nil { + return nil, err + } + return nil, receipt.ErrRangeQueryNotSupported +} + +// Close closes the receipt store. +func (*MemoryReceiptStore) Close() error { + return nil +} + +// ExternalPruning reports that retention is controlled by the shared collector. +func (*MemoryReceiptStore) ExternalPruning() bool { + return true +} + +// PruneHistory removes receipts strictly below blockNumber. +func (s *MemoryReceiptStore) PruneHistory(blockNumber uint64) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.latestVersion <= 0 || blockNumber > uint64(s.latestVersion) { //nolint:gosec // latestVersion is positive. + return nil + } + for height, blockReceipts := range s.blocks { + if height >= blockNumber { + continue + } + for txHash := range blockReceipts { + delete(s.byTxHash, txHash) + } + delete(s.blocks, height) + } + if blockNumber <= maxGigaStoreBlockNumber && int64(blockNumber) > s.earliestVersion { //nolint:gosec // bounded above. + s.earliestVersion = int64(blockNumber) //nolint:gosec // bounded above. + } + return nil +} + +// PruneSnapshots is a no-op because receipts have no snapshots. +func (*MemoryReceiptStore) PruneSnapshots(uint64) error { + return nil +} + +// GetRollbackFloor returns the earliest block a rollback may target. +func (s *MemoryReceiptStore) GetRollbackFloor(rollbackWindow uint64) uint64 { + head, err := s.GetLatestBlock() + if err != nil || head <= rollbackWindow { + return 0 + } + return head - rollbackWindow +} + +// GetLatestBlock returns the greatest block height recorded by the store. +func (s *MemoryReceiptStore) GetLatestBlock() (uint64, error) { + latest := s.LatestVersion() + if latest <= 0 { + return 0, nil + } + return uint64(latest), nil //nolint:gosec // latest is positive. +} + +func cloneStoredReceipt(stored *evmtypes.Receipt) *evmtypes.Receipt { + if stored == nil { + return nil + } + cloned := *stored + cloned.LogsBloom = append([]byte(nil), stored.LogsBloom...) + cloned.Logs = make([]*evmtypes.Log, len(stored.Logs)) + for i, log := range stored.Logs { + if log == nil { + continue + } + clonedLog := *log + clonedLog.Topics = append([]string(nil), log.Topics...) + clonedLog.Data = append([]byte(nil), log.Data...) + cloned.Logs[i] = &clonedLog + } + return &cloned +} + +func receiptContextError(ctx sdk.Context) error { + if ctx.Context() == nil { + return nil + } + return ctx.Context().Err() +} diff --git a/giga/evmonly/receipt_store_test.go b/giga/evmonly/receipt_store_test.go new file mode 100644 index 0000000000..dfd1a9edd1 --- /dev/null +++ b/giga/evmonly/receipt_store_test.go @@ -0,0 +1,104 @@ +package evmonly + +import ( + "context" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/eth/filters" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" + evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" +) + +func TestMemoryReceiptStoreIndexesOwnedReceiptCopies(t *testing.T) { + store := NewMemoryReceiptStore() + txHash := common.Hash{1} + record := receipt.ReceiptRecord{ + TxHash: txHash, + Receipt: &evmtypes.Receipt{ + TxHashHex: txHash.Hex(), + BlockNumber: 5, + LogsBloom: []byte{2}, + Logs: []*evmtypes.Log{{ + Address: common.Address{3}.Hex(), + Topics: []string{common.Hash{4}.Hex()}, + Data: []byte{5}, + }}, + }, + } + + receiptCtx := newReceiptContext(t.Context(), 5) + require.NoError(t, store.SetReceipts(receiptCtx, []receipt.ReceiptRecord{record})) + record.Receipt.LogsBloom[0] = 12 + record.Receipt.Logs[0].Address = common.Address{13}.Hex() + record.Receipt.Logs[0].Topics[0] = common.Hash{14}.Hex() + record.Receipt.Logs[0].Data[0] = 15 + + stored, err := store.GetReceipt(receiptCtx, txHash) + require.NoError(t, err) + require.Equal(t, []byte{2}, stored.LogsBloom) + require.Equal(t, common.Address{3}.Hex(), stored.Logs[0].Address) + require.Equal(t, []string{common.Hash{4}.Hex()}, stored.Logs[0].Topics) + require.Equal(t, []byte{5}, stored.Logs[0].Data) + + stored.Status = 1 + stored.Logs[0].Data[0] = 16 + storedAgain, err := store.GetReceipt(receiptCtx, txHash) + require.NoError(t, err) + require.Zero(t, storedAgain.Status) + require.Equal(t, []byte{5}, storedAgain.Logs[0].Data) + require.Equal(t, int64(5), store.LatestVersion()) +} + +func TestMemoryReceiptStoreMovesReceiptsAndRecordsEmptyBlocks(t *testing.T) { + store := NewMemoryReceiptStore() + txHash := common.Hash{1} + first := &evmtypes.Receipt{TxHashHex: txHash.Hex(), BlockNumber: 7} + second := &evmtypes.Receipt{TxHashHex: txHash.Hex(), BlockNumber: 8} + + require.NoError(t, store.SetReceipts(newReceiptContext(t.Context(), 7), []receipt.ReceiptRecord{{TxHash: txHash, Receipt: first}})) + require.NoError(t, store.SetReceipts(newReceiptContext(t.Context(), 8), []receipt.ReceiptRecord{{TxHash: txHash, Receipt: second}})) + + stored, err := store.GetReceipt(newReceiptContext(t.Context(), 8), txHash) + require.NoError(t, err) + require.Equal(t, uint64(8), stored.BlockNumber) + require.NotContains(t, store.blocks, uint64(7)) + require.Equal(t, int64(8), store.LatestVersion()) + + require.NoError(t, store.SetReceipts(newReceiptContext(t.Context(), 9), nil)) + require.Equal(t, int64(9), store.LatestVersion()) +} + +func TestMemoryReceiptStorePrunesHistory(t *testing.T) { + store := NewMemoryReceiptStore() + oldHash := common.Hash{1} + newHash := common.Hash{2} + records := []receipt.ReceiptRecord{ + {TxHash: oldHash, Receipt: &evmtypes.Receipt{TxHashHex: oldHash.Hex(), BlockNumber: 3}}, + {TxHash: newHash, Receipt: &evmtypes.Receipt{TxHashHex: newHash.Hex(), BlockNumber: 4}}, + } + require.NoError(t, store.SetReceipts(newReceiptContext(t.Context(), 4), records)) + + require.NoError(t, store.PruneHistory(4)) + _, err := store.GetReceipt(newReceiptContext(t.Context(), 4), oldHash) + require.ErrorIs(t, err, receipt.ErrNotFound) + _, err = store.GetReceipt(newReceiptContext(t.Context(), 4), newHash) + require.NoError(t, err) + require.Equal(t, int64(4), store.EarliestVersion()) + require.Equal(t, uint64(2), store.GetRollbackFloor(2)) +} + +func TestMemoryReceiptStoreHonorsCanceledContext(t *testing.T) { + store := NewMemoryReceiptStore() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + receiptCtx := newReceiptContext(ctx, 1) + + require.ErrorIs(t, store.SetReceipts(receiptCtx, nil), context.Canceled) + _, err := store.GetReceipt(receiptCtx, common.Hash{}) + require.ErrorIs(t, err, context.Canceled) + _, err = store.FilterLogs(receiptCtx, 1, 1, filters.FilterCriteria{}, nil) + require.ErrorIs(t, err, context.Canceled) +} diff --git a/giga/evmonly/receipt_test.go b/giga/evmonly/receipt_test.go new file mode 100644 index 0000000000..c880e8c701 --- /dev/null +++ b/giga/evmonly/receipt_test.go @@ -0,0 +1,87 @@ +package evmonly + +import ( + "errors" + "math" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +func TestReceiptRecordsConvertExecutorResults(t *testing.T) { + txHash := common.Hash{1} + sender := common.Address{2} + recipient := common.Address{3} + contract := common.Address{4} + topic := common.Hash{5} + vmErr := errors.New("execution reverted") + ethReceipt := ðtypes.Receipt{ + Type: ethtypes.DynamicFeeTxType, + Status: ethtypes.ReceiptStatusFailed, + CumulativeGasUsed: 43_000, + Bloom: ethtypes.Bloom{6}, + Logs: []*ethtypes.Log{{ + Address: recipient, + Topics: []common.Hash{topic}, + Data: []byte{7}, + Index: 8, + }}, + TxHash: txHash, + ContractAddress: contract, + GasUsed: 22_000, + EffectiveGasPrice: big.NewInt(9), + TransactionIndex: 10, + } + result := &BlockResult{ + Receipts: ethtypes.Receipts{ethReceipt}, + Txs: []TxResult{{ + Hash: txHash, + Sender: sender, + To: &recipient, + ContractAddress: contract, + Err: vmErr, + }}, + } + + records, err := receiptRecords(11, result) + + require.NoError(t, err) + require.Len(t, records, 1) + require.Equal(t, txHash, records[0].TxHash) + stored := records[0].Receipt + require.Equal(t, uint32(ethtypes.DynamicFeeTxType), stored.TxType) + require.Equal(t, uint64(43_000), stored.CumulativeGasUsed) + require.Equal(t, contract.Hex(), stored.ContractAddress) + require.Equal(t, txHash.Hex(), stored.TxHashHex) + require.Equal(t, uint64(22_000), stored.GasUsed) + require.Equal(t, uint64(9), stored.EffectiveGasPrice) + require.Equal(t, uint64(11), stored.BlockNumber) + require.Equal(t, uint32(10), stored.TransactionIndex) + require.Equal(t, uint32(ethtypes.ReceiptStatusFailed), stored.Status) + require.Equal(t, sender.Hex(), stored.From) + require.Equal(t, recipient.Hex(), stored.To) + require.Equal(t, vmErr.Error(), stored.VmError) + require.Equal(t, ethReceipt.Bloom[:], stored.LogsBloom) + require.Len(t, stored.Logs, 1) + require.Equal(t, recipient.Hex(), stored.Logs[0].Address) + require.Equal(t, []string{topic.Hex()}, stored.Logs[0].Topics) + require.Equal(t, []byte{7}, stored.Logs[0].Data) + require.Equal(t, uint32(8), stored.Logs[0].Index) +} + +func TestReceiptRecordsRejectMalformedBlockResult(t *testing.T) { + _, err := receiptRecords(1, &BlockResult{Receipts: ethtypes.Receipts{{}}}) + require.ErrorContains(t, err, "does not match") + + _, err = receiptRecords(1, &BlockResult{Receipts: ethtypes.Receipts{nil}, Txs: []TxResult{{}}}) + require.ErrorContains(t, err, "receipt 0 is nil") + + _, err = receiptRecords(1, &BlockResult{ + Receipts: ethtypes.Receipts{{Status: uint64(math.MaxUint32) + 1}}, + Txs: []TxResult{{}}, + }) + require.ErrorContains(t, err, "status") +} diff --git a/giga/evmonly/storage_manager.go b/giga/evmonly/storage_manager.go new file mode 100644 index 0000000000..e3371e38db --- /dev/null +++ b/giga/evmonly/storage_manager.go @@ -0,0 +1,34 @@ +package evmonly + +import ( + "github.com/sei-protocol/sei-chain/sei-db/bootstrap" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" + gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" +) + +// WithStorageManager selects the stores used for state and receipt persistence. +// The encoder converts executor-native state changes into the state store's format. +func WithStorageManager(manager *bootstrap.GigaStorageManager, encoder NamedChangeSetEncoder) Option { + return func(e *Executor) { + if manager != nil { + e.stateStore = manager.StateDB() + e.receiptStore = manager.ReceiptDB() + } + e.changeSetEncoder = encoder + } +} + +// WithStore selects a state store independently of a storage manager. +func WithStore(store gigatypes.StateDB, encoder NamedChangeSetEncoder) Option { + return func(e *Executor) { + e.stateStore = store + e.changeSetEncoder = encoder + } +} + +// WithReceiptStore selects a receipt store independently of a storage manager. +func WithReceiptStore(store receipt.ReceiptStore) Option { + return func(e *Executor) { + e.receiptStore = store + } +} diff --git a/giga/evmonly/test_store_test.go b/giga/evmonly/test_store_test.go index f6cd184da1..972c5a0330 100644 --- a/giga/evmonly/test_store_test.go +++ b/giga/evmonly/test_store_test.go @@ -1,6 +1,10 @@ package evmonly -import "github.com/sei-protocol/sei-chain/sei-db/proto" +import ( + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" + "github.com/sei-protocol/sei-chain/sei-db/proto" + gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" +) type readOnlyTestStore struct { *MemoryStore @@ -11,8 +15,15 @@ func (*readOnlyTestStore) CommitStateChanges(int64, []*proto.NamedChangeSet) err } // withTestState keeps executor unit tests focused on execution behavior while -// production code exposes only giga StateDB configuration. +// supplying the test-only in-memory stores. func withTestState(state StateReader) Option { store := &readOnlyTestStore{MemoryStore: NewMemoryStore(state)} - return WithStore(store, store.EncodeChangeSet) + return withTestStores(store, NewMemoryReceiptStore(), store.EncodeChangeSet) +} + +func withTestStores(store gigatypes.StateDB, receiptStore receipt.ReceiptStore, encoder NamedChangeSetEncoder) Option { + return func(executor *Executor) { + WithStore(store, encoder)(executor) + WithReceiptStore(receiptStore)(executor) + } } diff --git a/integration_test/autobahn/README.md b/integration_test/autobahn/README.md index 7da72101ae..16618f985b 100644 --- a/integration_test/autobahn/README.md +++ b/integration_test/autobahn/README.md @@ -1,9 +1,13 @@ # Autobahn EVM-only E2E clusters -`autobahn-e2e` manages the four-validator, in-memory EVM-only Autobahn -topology used by the integration load test. It keeps cluster metadata under -`~/.sei/autobahn-e2e` by default. Override that location with -`--state-dir` or `AUTOBAHN_E2E_STATE_DIR`. +`autobahn-e2e` manages the four-validator, disk-backed EVM-only Autobahn +topology used by the integration load test. Each validator uses the same Giga +storage manager as the production EVM-only path, including FlatKV state, +littidx receipts, and littblock blocks. Balances remain in a process-local +placeholder until FlatKV balance access is available; all other execution state +uses the manager-owned state database. The command keeps cluster metadata under +`~/.sei/autobahn-e2e` by default. Override that location with `--state-dir` or +`AUTOBAHN_E2E_STATE_DIR`. Build the command once: diff --git a/integration_test/autobahn/autobahn_test.go b/integration_test/autobahn/autobahn_test.go index 03abc79d31..515f54948e 100644 --- a/integration_test/autobahn/autobahn_test.go +++ b/integration_test/autobahn/autobahn_test.go @@ -88,7 +88,7 @@ const ( haltStableTimeout = 2 * time.Minute testRecipientEVM = "0x1000000000000000000000000000000000000001" - evmOnlyInMemoryEnv = "AUTOBAHN_EVMONLY_IN_MEMORY" + evmOnlyEnv = "AUTOBAHN_EVMONLY" evmOnlyLoadTxs = 4_000 evmOnlyLoadTimeout = 3 * time.Minute evmOnlyMetricsURL = "http://127.0.0.1:26660/metrics" @@ -219,17 +219,17 @@ func assertAutobahnEnabled(t *testing.T) { } } -func evmOnlyInMemoryEnabled() bool { - return os.Getenv(evmOnlyInMemoryEnv) == "true" +func evmOnlyEnabled() bool { + return os.Getenv(evmOnlyEnv) == "true" } -func assertEVMOnlyInMemoryEnabled(t *testing.T) { +func assertEVMOnlyEnabled(t *testing.T) { t.Helper() for _, name := range listRunningNodes(t) { cmd := exec.Command("docker", "exec", name, "sh", "-c", - "grep -q 'Autobahn EVM-only in-memory execution enabled' build/generated/logs/seid-*.log") + "grep -q 'Autobahn EVM-only execution enabled with disk-backed Giga storage' build/generated/logs/seid-*.log") if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("EVM-only in-memory execution not enabled on %s: %v\n%s", name, err, out) + t.Fatalf("EVM-only execution not enabled on %s: %v\n%s", name, err, out) } } } @@ -371,7 +371,7 @@ func TestMain(m *testing.M) { teardownCluster() // best-effort os.Exit(1) } - if !evmOnlyInMemoryEnabled() { + if !evmOnlyEnabled() { if err := setupFullnodeNode(); err != nil { fmt.Fprintf(os.Stderr, "fullnode sidecar setup failed: %v\n", err) teardownCluster() @@ -600,7 +600,7 @@ func TestAutobahn(t *testing.T) { // validator sets. maxFaults = (clusterSize - 1) / 3 t.Logf("cluster size = %d, max tolerated faults = %d (assuming equal weights)", clusterSize, maxFaults) - if evmOnlyInMemoryEnabled() { + if evmOnlyEnabled() { t.Run("EVMOnlyLoad", testEVMOnlyLoad) return } @@ -620,7 +620,7 @@ func (evmOnlyLoadState) SetState(common.Address, common.Hash, common.Hash) {} func testEVMOnlyLoad(t *testing.T) { assertAutobahnEnabled(t) - assertEVMOnlyInMemoryEnabled(t) + assertEVMOnlyEnabled(t) assertEVMOnlyTendermintRPCDisabled(t) if clusterSize != 4 { t.Fatalf("EVM-only Docker load test requires four validators, got %d", clusterSize) @@ -628,7 +628,7 @@ func testEVMOnlyLoad(t *testing.T) { workload, err := scenarios.NewTransferWorkload(scenarios.Config{ TxsPerBlock: evmOnlyLoadTxs, - ChainID: new(big.Int).SetUint64(tmconfig.AutobahnEVMOnlyInMemoryChainID), + ChainID: new(big.Int).SetUint64(tmconfig.AutobahnEVMOnlyChainID), GasPrice: big.NewInt(1_000_000_000), SenderBalance: new(big.Int).Lsh(big.NewInt(1), 200), TransferValue: big.NewInt(1), diff --git a/sei-tendermint/config/autobahn.go b/sei-tendermint/config/autobahn.go index d087af99b8..93dfed0aaa 100644 --- a/sei-tendermint/config/autobahn.go +++ b/sei-tendermint/config/autobahn.go @@ -84,8 +84,8 @@ type AutobahnFileConfig struct { BlockDB AutobahnBlockDBConfig `json:"block_db,omitzero"` } -// AutobahnEVMOnlyInMemoryChainID is the chain ID of the test-only EVM executor. -const AutobahnEVMOnlyInMemoryChainID uint64 = 713715 +// AutobahnEVMOnlyChainID is the chain ID of the test-only EVM executor. +const AutobahnEVMOnlyChainID uint64 = 713715 func (c *AutobahnFileConfig) GetEnableEvmProxy() bool { return c.EnableEvmProxy.Or(true) diff --git a/sei-tendermint/config/config.go b/sei-tendermint/config/config.go index 6123018868..19eda4370a 100644 --- a/sei-tendermint/config/config.go +++ b/sei-tendermint/config/config.go @@ -249,9 +249,9 @@ type BaseConfig struct { // TEST-ONLY MockApp bool `mapstructure:"mock-app"` - // EVMOnlyInMemory replaces the provided ABCI application with an ephemeral EVM executor. + // EVMOnly replaces the provided ABCI application with the disk-backed EVM-only executor. // TEST-ONLY - EVMOnlyInMemory bool `mapstructure:"evm-only-in-memory"` + EVMOnly bool `mapstructure:"evm-only"` // Deprecated: out-of-process ABCI has been removed and this option no longer // has any effect. diff --git a/sei-tendermint/config/config_test.go b/sei-tendermint/config/config_test.go index 02de116c3e..b5fb274429 100644 --- a/sei-tendermint/config/config_test.go +++ b/sei-tendermint/config/config_test.go @@ -21,7 +21,7 @@ func TestDefaultConfig(t *testing.T) { assert.NotNil(t, cfg.Mempool) assert.NotNil(t, cfg.Consensus) assert.False(t, cfg.FastCheckTx) - assert.False(t, cfg.EVMOnlyInMemory) + assert.False(t, cfg.EVMOnly) // check the root dir stuff... cfg.SetRoot("/foo") diff --git a/sei-tendermint/config/toml.go b/sei-tendermint/config/toml.go index bfe30f6a61..cd0b605fab 100644 --- a/sei-tendermint/config/toml.go +++ b/sei-tendermint/config/toml.go @@ -148,9 +148,9 @@ fast-check-tx = {{ .BaseConfig.FastCheckTx }} # TEST-ONLY mock-app = {{ .BaseConfig.MockApp }} -# EVMOnlyInMemory replaces the provided ABCI application with an ephemeral EVM executor. +# EVMOnly replaces the provided ABCI application with the disk-backed EVM-only executor. # TEST-ONLY -evm-only-in-memory = {{ .BaseConfig.EVMOnlyInMemory }} +evm-only = {{ .BaseConfig.EVMOnly }} ####################################################################### ### Autobahn Configuration ### diff --git a/sei-tendermint/config/toml_test.go b/sei-tendermint/config/toml_test.go index b40856e220..fe92632115 100644 --- a/sei-tendermint/config/toml_test.go +++ b/sei-tendermint/config/toml_test.go @@ -70,7 +70,7 @@ func checkConfig(t *testing.T, configFile string) { "send", "fast-check-tx = false", "mock-app = false", - "evm-only-in-memory = false", + "evm-only = false", "addr", "wal", "max", diff --git a/sei-tendermint/internal/p2p/evmonly_inmemory_app.go b/sei-tendermint/internal/evmonlyapp/app.go similarity index 65% rename from sei-tendermint/internal/p2p/evmonly_inmemory_app.go rename to sei-tendermint/internal/evmonlyapp/app.go index a81d6623fe..4cc9da3997 100644 --- a/sei-tendermint/internal/p2p/evmonly_inmemory_app.go +++ b/sei-tendermint/internal/evmonlyapp/app.go @@ -1,4 +1,4 @@ -package p2p +package evmonlyapp import ( "context" @@ -16,78 +16,93 @@ import ( "github.com/holiman/uint256" "github.com/sei-protocol/sei-chain/giga/evmonly" + "github.com/sei-protocol/sei-chain/sei-db/bootstrap" gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -const evmOnlyInMemoryMinGasPrice = 1_000_000_000 +const evmOnlyMinGasPrice = 1_000_000_000 -var evmOnlyInMemoryBaseBalance = new(big.Int).Lsh(big.NewInt(1), 200) +var evmOnlyBaseBalance = new(big.Int).Lsh(big.NewInt(1), 200) -type evmOnlyInMemoryApplication struct { +type evmOnlyApplication struct { abci.BaseApplication - chainID *big.Int - chainConfig *params.ChainConfig - store *evmonly.MemoryStore - validators []abci.ValidatorUpdate - state utils.Mutex[*evmOnlyInMemoryState] + chainID *big.Int + chainConfig *params.ChainConfig + storage *bootstrap.GigaStorageManager + changeSetEncoder evmonly.NamedChangeSetEncoder + balanceStore *evmonly.PlaceholderBalanceStore + validators []abci.ValidatorUpdate + state utils.Mutex[*evmOnlyState] } -type evmOnlyInMemoryState struct { +type evmOnlyState struct { executor utils.Option[*evmonly.Executor] gasLimit uint64 nextHeight int64 committedHeight int64 appHash common.Hash parentHash common.Hash - pending utils.Option[evmOnlyInMemoryPending] + pending utils.Option[evmOnlyPending] } -type evmOnlyInMemoryPending struct { +type evmOnlyPending struct { height int64 appHash common.Hash blockHash common.Hash } -var _ abci.Application = (*evmOnlyInMemoryApplication)(nil) +var _ abci.Application = (*evmOnlyApplication)(nil) -// NewEVMOnlyInMemoryApplication returns an ephemeral raw-Ethereum application for -// Autobahn Docker load tests. -func NewEVMOnlyInMemoryApplication(chainID uint64, validators []abci.ValidatorUpdate) abci.Application { - base := evmOnlyFundedState{} - store := evmonly.NewMemoryStore(base) +// NewEVMOnlyApplication returns the raw-Ethereum application used by Autobahn +// load tests. Storage owns non-balance state, receipts, and blocks; balances +// use a process-local placeholder store. +func NewEVMOnlyApplication( + chainID uint64, + validators []abci.ValidatorUpdate, + storage *bootstrap.GigaStorageManager, + changeSetEncoder evmonly.NamedChangeSetEncoder, +) abci.Application { chainConfig := *params.AllDevChainProtocolChanges chainConfig.ChainID = new(big.Int).SetUint64(chainID) - return &evmOnlyInMemoryApplication{ - chainID: new(big.Int).SetUint64(chainID), - chainConfig: &chainConfig, - store: store, - validators: slices.Clone(validators), - state: utils.NewMutex(&evmOnlyInMemoryState{}), + return &evmOnlyApplication{ + chainID: new(big.Int).SetUint64(chainID), + chainConfig: &chainConfig, + storage: storage, + changeSetEncoder: changeSetEncoder, + balanceStore: evmonly.NewPlaceholderBalanceStore(evmOnlyFundedBalances{}), + validators: slices.Clone(validators), + state: utils.NewMutex(&evmOnlyState{}), } } -func (a *evmOnlyInMemoryApplication) InitChain(req *abci.RequestInitChain) (*abci.ResponseInitChain, error) { +func (a *evmOnlyApplication) InitChain(req *abci.RequestInitChain) (*abci.ResponseInitChain, error) { if req.InitialHeight <= 0 { return nil, fmt.Errorf("EVM-only initial height must be positive: %d", req.InitialHeight) } - gasLimit, err := evmOnlyInMemoryGasLimit(req) + gasLimit, err := evmOnlyGasLimit(req) if err != nil { return nil, err } + if err := a.seedInitialStateVersion(req.InitialHeight); err != nil { + return nil, err + } for state := range a.state.Lock() { if state.executor.IsPresent() { return nil, fmt.Errorf("EVM-only application already initialized") } state.executor = utils.Some(evmonly.NewExecutor(evmonly.Config{ ChainConfig: a.chainConfig, - MinGasPrice: big.NewInt(evmOnlyInMemoryMinGasPrice), + MinGasPrice: big.NewInt(evmOnlyMinGasPrice), OCCWorkers: runtime.GOMAXPROCS(0), ParseWorkers: runtime.GOMAXPROCS(0), BlockResultPoolSize: 1, - }, evmonly.WithStore(a.store, a.store.EncodeChangeSet))) + }, + evmonly.WithStorageManager(a.storage, a.changeSetEncoder), + evmonly.WithBalanceStore(a.balanceStore), + )) state.gasLimit = gasLimit state.nextHeight = req.InitialHeight state.committedHeight = req.InitialHeight - 1 @@ -96,7 +111,25 @@ func (a *evmOnlyInMemoryApplication) InitChain(req *abci.RequestInitChain) (*abc panic("unreachable") } -func evmOnlyInMemoryGasLimit(req *abci.RequestInitChain) (uint64, error) { +func (a *evmOnlyApplication) seedInitialStateVersion(initialHeight int64) error { + stateStore := a.storage.SC() + if stateStore == nil || initialHeight == 1 { + return nil + } + latest, err := stateStore.GetLatestVersion() + if err != nil { + return fmt.Errorf("read EVM-only state version: %w", err) + } + if latest != 0 { + return fmt.Errorf("EVM-only state is already at height %d before InitChain", latest) + } + if err := stateStore.SetInitialVersion(initialHeight); err != nil { + return fmt.Errorf("seed EVM-only initial state version %d: %w", initialHeight, err) + } + return nil +} + +func evmOnlyGasLimit(req *abci.RequestInitChain) (uint64, error) { if req.ConsensusParams == nil || req.ConsensusParams.Block == nil || req.ConsensusParams.Block.MaxGas <= 0 { return 0, fmt.Errorf("EVM-only max gas must be positive") } @@ -107,10 +140,10 @@ func evmOnlyInMemoryGasLimit(req *abci.RequestInitChain) (uint64, error) { return gasLimit, nil } -func (a *evmOnlyInMemoryApplication) Info() *abci.ResponseInfo { +func (a *evmOnlyApplication) Info() *abci.ResponseInfo { for state := range a.state.Lock() { return &abci.ResponseInfo{ - Data: "evmonly-in-memory", + Data: "evmonly", LastBlockHeight: state.committedHeight, LastBlockAppHash: append([]byte(nil), state.appHash[:]...), } @@ -118,18 +151,18 @@ func (a *evmOnlyInMemoryApplication) Info() *abci.ResponseInfo { panic("unreachable") } -func (a *evmOnlyInMemoryApplication) LastBlockHeight() int64 { +func (a *evmOnlyApplication) LastBlockHeight() int64 { for state := range a.state.Lock() { return state.committedHeight } panic("unreachable") } -func (a *evmOnlyInMemoryApplication) GetValidators() []abci.ValidatorUpdate { +func (a *evmOnlyApplication) GetValidators() []abci.ValidatorUpdate { return slices.Clone(a.validators) } -func (a *evmOnlyInMemoryApplication) CheckTx(_ context.Context, req *abci.RequestCheckTxV2) *abci.ResponseCheckTxV2 { +func (a *evmOnlyApplication) CheckTx(_ context.Context, req *abci.RequestCheckTxV2) *abci.ResponseCheckTxV2 { // TODO(evmonly-production): close the gap between admission and block validity // before accepting arbitrary traffic; this test app assumes executable load-test transactions. tx, sender, err := a.parseTx(req.Tx) @@ -154,7 +187,7 @@ func (a *evmOnlyInMemoryApplication) CheckTx(_ context.Context, req *abci.Reques } } -func (a *evmOnlyInMemoryApplication) parseTx(raw []byte) (*ethtypes.Transaction, common.Address, error) { +func (a *evmOnlyApplication) parseTx(raw []byte) (*ethtypes.Transaction, common.Address, error) { tx := new(ethtypes.Transaction) if err := tx.UnmarshalBinary(raw); err != nil { return nil, common.Address{}, err @@ -168,8 +201,8 @@ func (a *evmOnlyInMemoryApplication) parseTx(raw []byte) (*ethtypes.Transaction, if tx.Type() == ethtypes.BlobTxType { return nil, common.Address{}, fmt.Errorf("blob transactions are not supported") } - if tx.GasPrice().Cmp(big.NewInt(evmOnlyInMemoryMinGasPrice)) < 0 { - return nil, common.Address{}, fmt.Errorf("ethereum transaction gas price is below %d", evmOnlyInMemoryMinGasPrice) + if tx.GasPrice().Cmp(big.NewInt(evmOnlyMinGasPrice)) < 0 { + return nil, common.Address{}, fmt.Errorf("ethereum transaction gas price is below %d", evmOnlyMinGasPrice) } sender, err := ethtypes.Sender(ethtypes.LatestSignerForChainID(a.chainID), tx) if err != nil { @@ -184,20 +217,17 @@ func evmOnlyStoreAddress(address common.Address) gigatypes.Address { return storeAddress } -func (a *evmOnlyInMemoryApplication) EvmNonce(address common.Address) uint64 { - snapshot := a.store.OpenView() +func (a *evmOnlyApplication) EvmNonce(address common.Address) uint64 { + snapshot := a.storage.StateDB().OpenView() defer snapshot.Close() return snapshot.GetNonce(evmOnlyStoreAddress(address)) } -func (a *evmOnlyInMemoryApplication) EvmBalance(address common.Address, _ []byte) uint256.Int { - snapshot := a.store.OpenView() - defer snapshot.Close() - balance := snapshot.GetBalance(evmOnlyStoreAddress(address)) - return *new(uint256.Int).SetBytes(balance[:]) +func (a *evmOnlyApplication) EvmBalance(address common.Address, _ []byte) uint256.Int { + return *uint256.MustFromBig(a.balanceStore.GetBalance(address)) } -func (a *evmOnlyInMemoryApplication) FinalizeBlock(ctx context.Context, req *abci.RequestFinalizeBlock) (*abci.ResponseFinalizeBlock, error) { +func (a *evmOnlyApplication) FinalizeBlock(ctx context.Context, req *abci.RequestFinalizeBlock) (*abci.ResponseFinalizeBlock, error) { height := req.Header.Height if height <= 0 { return nil, fmt.Errorf("EVM-only block height must be positive: %d", height) @@ -240,11 +270,11 @@ func (a *evmOnlyInMemoryApplication) FinalizeBlock(ctx context.Context, req *abc return nil, err } defer result.Release() - appHash, err := hashEVMOnlyInMemoryResult(state.appHash, number, blockHash, result) + appHash, err := hashEVMOnlyResult(state.appHash, number, blockHash, result) if err != nil { return nil, err } - state.pending = utils.Some(evmOnlyInMemoryPending{height: height, appHash: appHash, blockHash: blockHash}) + state.pending = utils.Some(evmOnlyPending{height: height, appHash: appHash, blockHash: blockHash}) return &abci.ResponseFinalizeBlock{ AppHash: append([]byte(nil), appHash[:]...), TxResults: evmOnlyABCIResults(result), @@ -253,7 +283,7 @@ func (a *evmOnlyInMemoryApplication) FinalizeBlock(ctx context.Context, req *abc panic("unreachable") } -func (a *evmOnlyInMemoryApplication) Commit(context.Context) (*abci.ResponseCommit, error) { +func (a *evmOnlyApplication) Commit(context.Context) (*abci.ResponseCommit, error) { for state := range a.state.Lock() { pending, ok := state.pending.Get() if !ok { @@ -263,7 +293,7 @@ func (a *evmOnlyInMemoryApplication) Commit(context.Context) (*abci.ResponseComm state.nextHeight = pending.height + 1 state.appHash = pending.appHash state.parentHash = pending.blockHash - state.pending = utils.None[evmOnlyInMemoryPending]() + state.pending = utils.None[evmOnlyPending]() return &abci.ResponseCommit{}, nil } panic("unreachable") @@ -282,7 +312,7 @@ func evmOnlyABCIResults(result *evmonly.BlockResult) []*abci.ExecTxResult { return txResults } -func hashEVMOnlyInMemoryResult(previous common.Hash, height uint64, blockHash common.Hash, result *evmonly.BlockResult) (common.Hash, error) { +func hashEVMOnlyResult(previous common.Hash, height uint64, blockHash common.Hash, result *evmonly.BlockResult) (common.Hash, error) { h := sha256.New() _, _ = h.Write(previous[:]) _, _ = h.Write(binary.BigEndian.AppendUint64(nil, height)) @@ -316,12 +346,8 @@ func writeEVMOnlyHashBytes(w byteWriter, value []byte) { _, _ = w.Write(value) } -type evmOnlyFundedState struct{} +type evmOnlyFundedBalances struct{} -func (evmOnlyFundedState) AccountExists(common.Address) bool { return true } -func (evmOnlyFundedState) GetBalance(common.Address) *big.Int { - return new(big.Int).Set(evmOnlyInMemoryBaseBalance) +func (evmOnlyFundedBalances) GetBalance(common.Address) *big.Int { + return new(big.Int).Set(evmOnlyBaseBalance) } -func (evmOnlyFundedState) GetNonce(common.Address) uint64 { return 0 } -func (evmOnlyFundedState) GetCode(common.Address) []byte { return nil } -func (evmOnlyFundedState) GetState(common.Address, common.Hash) common.Hash { return common.Hash{} } diff --git a/sei-tendermint/internal/p2p/evmonly_inmemory_app_test.go b/sei-tendermint/internal/evmonlyapp/app_test.go similarity index 69% rename from sei-tendermint/internal/p2p/evmonly_inmemory_app_test.go rename to sei-tendermint/internal/evmonlyapp/app_test.go index 81fbcfe32e..bd721ff5e3 100644 --- a/sei-tendermint/internal/p2p/evmonly_inmemory_app_test.go +++ b/sei-tendermint/internal/evmonlyapp/app_test.go @@ -1,4 +1,4 @@ -package p2p +package evmonlyapp import ( "math/big" @@ -9,6 +9,10 @@ import ( ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" + "github.com/sei-protocol/sei-chain/giga/evmonly" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + "github.com/sei-protocol/sei-chain/sei-db/bootstrap" + seidbconfig "github.com/sei-protocol/sei-chain/sei-db/config" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" @@ -23,7 +27,7 @@ func signedEVMOnlyTestTx(t *testing.T, chainID uint64, nonce uint64) ([]byte, co recipient := common.HexToAddress("0x1000000000000000000000000000000000000001") tx := ethtypes.NewTx(ðtypes.LegacyTx{ Nonce: nonce, - GasPrice: big.NewInt(evmOnlyInMemoryMinGasPrice), + GasPrice: big.NewInt(evmOnlyMinGasPrice), Gas: 21_000, To: &recipient, Value: big.NewInt(1), @@ -37,7 +41,7 @@ func signedEVMOnlyTestTx(t *testing.T, chainID uint64, nonce uint64) ([]byte, co func newInitializedEVMOnlyTestApp(t *testing.T) abci.Application { t.Helper() - app := NewEVMOnlyInMemoryApplication(evmOnlyTestChainID, nil) + app := newEVMOnlyTestApp(t, nil) _, err := app.InitChain(&abci.RequestInitChain{ InitialHeight: 1, ConsensusParams: &tmproto.ConsensusParams{ @@ -48,9 +52,21 @@ func newInitializedEVMOnlyTestApp(t *testing.T) abci.Application { return app } -func TestEVMOnlyInMemoryApplicationExecutesRawEthereumBlock(t *testing.T) { +func newEVMOnlyTestApp(t *testing.T, validators []abci.ValidatorUpdate) abci.Application { + t.Helper() + storageConfig, err := seidbconfig.DefaultGigaStorageConfig(t.TempDir()) + require.NoError(t, err) + storage, err := bootstrap.NewGigaStorageManager(t.Context(), storageConfig.WithFullNodeMode()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, storage.Close()) }) + return NewEVMOnlyApplication(evmOnlyTestChainID, validators, storage, evmonly.NewFlatKVChangeSetEncoder(storage.SC())) +} + +func TestEVMOnlyApplicationExecutesRawEthereumBlock(t *testing.T) { app := newInitializedEVMOnlyTestApp(t) raw, sender := signedEVMOnlyTestTx(t, evmOnlyTestChainID, 0) + tx := new(ethtypes.Transaction) + require.NoError(t, tx.UnmarshalBinary(raw)) check := app.CheckTx(t.Context(), &abci.RequestCheckTxV2{Tx: raw}) require.True(t, check.IsOK()) require.True(t, check.IsEVM) @@ -74,9 +90,14 @@ func TestEVMOnlyInMemoryApplicationExecutesRawEthereumBlock(t *testing.T) { require.Equal(t, int64(1), app.LastBlockHeight()) require.Equal(t, uint64(1), app.EvmNonce(sender)) require.Equal(t, response.AppHash, app.Info().LastBlockAppHash) + receiptCtx := sdk.NewContext(nil, tmproto.Header{Height: 1}, false).WithContext(t.Context()) + receipt, err := app.(*evmOnlyApplication).storage.ReceiptDB().GetReceipt(receiptCtx, tx.Hash()) + require.NoError(t, err) + require.Equal(t, tx.Hash().Hex(), receipt.TxHashHex) + require.Equal(t, uint64(1), receipt.BlockNumber) } -func TestEVMOnlyInMemoryApplicationRejectsWrongChain(t *testing.T) { +func TestEVMOnlyApplicationRejectsWrongChain(t *testing.T) { app := newInitializedEVMOnlyTestApp(t) raw, _ := signedEVMOnlyTestTx(t, evmOnlyTestChainID+1, 0) @@ -85,7 +106,7 @@ func TestEVMOnlyInMemoryApplicationRejectsWrongChain(t *testing.T) { require.True(t, response.IsErr()) } -func TestEVMOnlyInMemoryApplicationProducesDeterministicRoot(t *testing.T) { +func TestEVMOnlyApplicationProducesDeterministicRoot(t *testing.T) { raw, _ := signedEVMOnlyTestTx(t, evmOnlyTestChainID, 0) request := &abci.RequestFinalizeBlock{ Txs: [][]byte{raw}, @@ -106,8 +127,8 @@ func TestEVMOnlyInMemoryApplicationProducesDeterministicRoot(t *testing.T) { require.Equal(t, firstResponse.AppHash, secondResponse.AppHash) } -func TestEVMOnlyInMemoryApplicationRequiresInitChain(t *testing.T) { - app := NewEVMOnlyInMemoryApplication(evmOnlyTestChainID, nil) +func TestEVMOnlyApplicationRequiresInitChain(t *testing.T) { + app := newEVMOnlyTestApp(t, nil) _, err := app.FinalizeBlock(t.Context(), &abci.RequestFinalizeBlock{ Hash: crypto.Keccak256([]byte("block-1")), @@ -120,9 +141,9 @@ func TestEVMOnlyInMemoryApplicationRequiresInitChain(t *testing.T) { require.Error(t, err) } -func TestEVMOnlyInMemoryApplicationReturnsConfiguredValidators(t *testing.T) { +func TestEVMOnlyApplicationReturnsConfiguredValidators(t *testing.T) { configured := []abci.ValidatorUpdate{{Power: 7}} - app := NewEVMOnlyInMemoryApplication(evmOnlyTestChainID, configured) + app := newEVMOnlyTestApp(t, configured) configured[0].Power = 11 first := app.GetValidators() diff --git a/sei-tendermint/node/fast_check_tx_test.go b/sei-tendermint/node/fast_check_tx_test.go index 38d6daf9f4..095cd91dc8 100644 --- a/sei-tendermint/node/fast_check_tx_test.go +++ b/sei-tendermint/node/fast_check_tx_test.go @@ -60,13 +60,14 @@ func TestFastCheckTxApplicationOverridesCheckTx(t *testing.T) { func TestPrepareApplicationMockAppIgnoresFastCheckTx(t *testing.T) { app := abci.BaseApplication{} - prepared, err := prepareApplication(&config.Config{ + prepared, storage, err := prepareApplication(t.Context(), &config.Config{ BaseConfig: config.BaseConfig{ MockApp: true, FastCheckTx: true, }, }, app) require.NoError(t, err) + require.False(t, storage.IsPresent()) _, ok := prepared.(*MockApp) require.True(t, ok) @@ -75,42 +76,51 @@ func TestPrepareApplicationMockAppIgnoresFastCheckTx(t *testing.T) { func TestPrepareApplicationFastCheckTxWithoutMockApp(t *testing.T) { app := abci.BaseApplication{} - prepared, err := prepareApplication(&config.Config{ + prepared, storage, err := prepareApplication(t.Context(), &config.Config{ BaseConfig: config.BaseConfig{ FastCheckTx: true, }, }, app) require.NoError(t, err) + require.False(t, storage.IsPresent()) _, ok := prepared.(fastCheckTxApplication) require.True(t, ok) } -func TestPrepareApplicationEVMOnlyInMemory(t *testing.T) { +func TestPrepareApplicationEVMOnly(t *testing.T) { app := abci.BaseApplication{} validator := makeValidator([]byte("evm-only-validator"), []byte("evm-only-node"), "localhost:26660") autobahnConfigFile := writeAutobahnConfig(t, defaultFileConfig(t, []config.AutobahnValidator{validator})) - prepared, err := prepareApplication(&config.Config{ + prepared, storage, err := prepareApplication(t.Context(), &config.Config{ BaseConfig: config.BaseConfig{ - EVMOnlyInMemory: true, - MockApp: true, - FastCheckTx: true, + EVMOnly: true, + MockApp: true, + FastCheckTx: true, }, AutobahnConfigFile: autobahnConfigFile, }, app) require.NoError(t, err) - - require.Equal(t, "evmonly-in-memory", prepared.Info().Data) + manager, ok := storage.Get() + require.True(t, ok) + t.Cleanup(func() { require.NoError(t, manager.Close()) }) + require.NotNil(t, manager.BlockStore()) + require.NotNil(t, manager.StateDB()) + require.NotNil(t, manager.SC()) + require.NotNil(t, manager.SS()) + require.NotNil(t, manager.ReceiptDB()) + + require.Equal(t, "evmonly", prepared.Info().Data) validators := prepared.GetValidators() require.Len(t, validators, 1) require.Equal(t, int64(1), validators[0].Power) require.Equal(t, validator.ValidatorKey.Bytes(), validators[0].PubKey.GetEd25519()) } -func TestPrepareApplicationEVMOnlyInMemoryRequiresReadableAutobahnConfig(t *testing.T) { - _, err := prepareApplication(&config.Config{ - BaseConfig: config.BaseConfig{EVMOnlyInMemory: true}, +func TestPrepareApplicationEVMOnlyRequiresReadableAutobahnConfig(t *testing.T) { + _, _, err := prepareApplication(t.Context(), &config.Config{ + BaseConfig: config.BaseConfig{EVMOnly: true}, AutobahnConfigFile: "/missing/autobahn.json", }, abci.BaseApplication{}) @@ -138,20 +148,20 @@ func TestValidateNodeSetupConfigAllowsMockAppWithAutobahn(t *testing.T) { require.NoError(t, err) } -func TestValidateNodeSetupConfigRejectsEVMOnlyInMemoryWithoutAutobahn(t *testing.T) { +func TestValidateNodeSetupConfigRejectsEVMOnlyWithoutAutobahn(t *testing.T) { err := validateNodeSetupConfig(&config.Config{ BaseConfig: config.BaseConfig{ - EVMOnlyInMemory: true, + EVMOnly: true, }, }) require.Error(t, err) } -func TestValidateNodeSetupConfigAllowsEVMOnlyInMemoryWithAutobahn(t *testing.T) { +func TestValidateNodeSetupConfigAllowsEVMOnlyWithAutobahn(t *testing.T) { err := validateNodeSetupConfig(&config.Config{ BaseConfig: config.BaseConfig{ - EVMOnlyInMemory: true, + EVMOnly: true, }, AutobahnConfigFile: "/tmp/autobahn.json", }) @@ -159,6 +169,18 @@ func TestValidateNodeSetupConfigAllowsEVMOnlyInMemoryWithAutobahn(t *testing.T) require.NoError(t, err) } +func TestValidateNodeSetupConfigRejectsEVMOnlySeed(t *testing.T) { + err := validateNodeSetupConfig(&config.Config{ + BaseConfig: config.BaseConfig{ + Mode: config.ModeSeed, + EVMOnly: true, + }, + AutobahnConfigFile: "/tmp/autobahn.json", + }) + + require.ErrorIs(t, err, errEVMOnlySeed) +} + type checkTxCountingApp struct { abci.BaseApplication called bool diff --git a/sei-tendermint/node/node.go b/sei-tendermint/node/node.go index 7e9d9d6e85..17593ff3b2 100644 --- a/sei-tendermint/node/node.go +++ b/sei-tendermint/node/node.go @@ -20,6 +20,7 @@ import ( "google.golang.org/protobuf/proto" evmonlyrpc "github.com/sei-protocol/sei-chain/giga/evmonly/rpc" + "github.com/sei-protocol/sei-chain/sei-db/bootstrap" atypes "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/config" "github.com/sei-protocol/sei-chain/sei-tendermint/crypto" @@ -125,13 +126,14 @@ type nodeImpl struct { freezeHeight uint64 // network - router *p2p.Router - giga utils.Option[p2p.GigaRouter] - gigaBlockStore utils.Option[atypes.BlockStore] // owned here; closed after giga.Run (sync.Once) - gigaBlockStoreCloseOnce sync.Once - ServiceRestartCh utils.Option[chan []string] - nodeInfo types.NodeInfo - nodeKey types.NodeKey // our node privkey + router *p2p.Router + giga utils.Option[p2p.GigaRouter] + gigaStorageManager utils.Option[*bootstrap.GigaStorageManager] + gigaBlockStore utils.Option[atypes.BlockStore] + gigaStorageCloseOnce sync.Once + ServiceRestartCh utils.Option[chan []string] + nodeInfo types.NodeInfo + nodeKey types.NodeKey // our node privkey // services eventSinks []indexer.EventSink @@ -162,6 +164,7 @@ func makeNode( dbProvider config.DBProvider, tracerProviderOptions []trace.TracerProviderOption, consensusPolicy types.ConsensusPolicy, + gigaStorageManager utils.Option[*bootstrap.GigaStorageManager], nodeOptions ...Option, ) (_ local.NodeService, err error) { opts := resolveOptions(nodeOptions...) @@ -173,10 +176,12 @@ func makeNode( closers := []closer{convertCancelCloser(cancel)} defer func() { if err != nil { - // Close BlockStore on construct failure after it was opened. Must not + // Close Giga storage on construct failure after it was opened. Must not // live in shutdownOps (see OnStart comment on SpawnCritical). if node != nil { - _ = node.closeGigaBlockStore() + _ = node.closeGigaStorage() + } else if manager, ok := gigaStorageManager.Get(); ok { + _ = manager.Close() } err = combineCloseError(err, makeCloser(closers)) } @@ -250,11 +255,12 @@ func makeNode( } // TODO construct node here: node = &nodeImpl{ - config: cfg, - genesisDoc: genDoc, - privValidator: privValidator, - consensusPolicy: consensusPolicy, - freezeHeight: opts.freezeHeight, + config: cfg, + genesisDoc: genDoc, + privValidator: privValidator, + consensusPolicy: consensusPolicy, + freezeHeight: opts.freezeHeight, + gigaStorageManager: gigaStorageManager, nodeKey: nodeKey, @@ -297,6 +303,7 @@ func makeNode( utils.Some(proxyApp), genDoc, dbProvider, + gigaStorageManager, ) closers = append(closers, peerCloser) if err != nil { @@ -305,7 +312,7 @@ func makeNode( node.router = router node.giga = router.Giga() node.gigaBlockStore = gigaBlockStore - // BlockStore is NOT closed in OnStop: BaseService runs OnStop before + // Giga storage is NOT closed in OnStop: BaseService runs OnStop before // SpawnCritical (giga.Run) finishes, so closing there would race with // still-running persist/execute. Close paths: // - makeNode defer on construct failure @@ -519,8 +526,8 @@ func makeNode( // OnStart starts the Node. It implements service.Service. func (n *nodeImpl) OnStart(ctx context.Context) (err error) { // If Start fails before giga is spawned, BaseService does not call OnStop - // and never cancels SpawnCritical — so BlockStore would otherwise leak. - // When giga has already been spawned, its wrapper closes BlockStore after + // and never cancels SpawnCritical — so Giga storage would otherwise leak. + // When giga has already been spawned, its wrapper closes storage after // Run observes the service-context cancel issued once OnStart returns. gigaSpawned := false if n.freezeHeight > 0 { @@ -530,7 +537,7 @@ func (n *nodeImpl) OnStart(ctx context.Context) (err error) { if err == nil || gigaSpawned { return } - _ = n.closeGigaBlockStore() + _ = n.closeGigaStorage() }() // EventBus and IndexerService must be started before the handshake because @@ -665,7 +672,7 @@ func (n *nodeImpl) OnStart(ctx context.Context) (err error) { if giga, ok := n.giga.Get(); ok { gigaSpawned = true n.SpawnCritical("giga", func(ctx context.Context) error { - defer func() { _ = n.closeGigaBlockStore() }() + defer func() { _ = n.closeGigaStorage() }() return giga.Run(ctx) }) } @@ -688,7 +695,7 @@ func (n *nodeImpl) OnStart(ctx context.Context) (err error) { n.rpcEnv.NodeInfo = n.nodeInfo // Start the RPC server before the P2P server // so we can eg. receive txs for the first block - if n.config.EVMOnlyInMemory { + if n.config.EVMOnly { n.evmOnlyRPC, err = evmonlyrpc.Start(n.rpcEnv) if err != nil { return err @@ -757,14 +764,19 @@ func (n *nodeImpl) OnStop() { } } -// closeGigaBlockStore closes the Autobahn BlockStore at most once. Safe to call from -// makeNode's failure defer, OnStart's pre-giga failure path, and the giga -// SpawnCritical wrapper. -func (n *nodeImpl) closeGigaBlockStore() error { +// closeGigaStorage closes the manager-owned storage or standalone Autobahn +// block store at most once. +func (n *nodeImpl) closeGigaStorage() error { var err error - n.gigaBlockStoreCloseOnce.Do(func() { - if db, ok := n.gigaBlockStore.Get(); ok { - if err = db.Close(); err != nil { + n.gigaStorageCloseOnce.Do(func() { + if manager, ok := n.gigaStorageManager.Get(); ok { + if err = manager.Close(); err != nil { + logger.Error("failed to close Giga storage manager", "err", err) + } + return + } + if blockStore, ok := n.gigaBlockStore.Get(); ok { + if err = blockStore.Close(); err != nil { logger.Error("failed to close Autobahn BlockStore", "err", err) } } diff --git a/sei-tendermint/node/public.go b/sei-tendermint/node/public.go index 66eabfb2e8..b25b07a52b 100644 --- a/sei-tendermint/node/public.go +++ b/sei-tendermint/node/public.go @@ -3,14 +3,18 @@ package node import ( "context" + "errors" "fmt" + "github.com/sei-protocol/sei-chain/giga/evmonly" + "github.com/sei-protocol/sei-chain/sei-db/bootstrap" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" "github.com/sei-protocol/sei-chain/sei-tendermint/config" "github.com/sei-protocol/sei-chain/sei-tendermint/crypto" "github.com/sei-protocol/sei-chain/sei-tendermint/crypto/ed25519" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/evmonlyapp" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/proxy" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/privval" "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/client/local" tmtypes "github.com/sei-protocol/sei-chain/sei-tendermint/types" @@ -24,6 +28,8 @@ type options struct { freezeHeight uint64 } +var errEVMOnlySeed = errors.New("evm-only is not supported in seed mode") + // Option configures optional node behavior. type Option func(*options) @@ -53,7 +59,7 @@ func New( tracerProviderOptions []trace.TracerProviderOption, consensusPolicy tmtypes.ConsensusPolicy, nodeOptions ...Option, -) (local.NodeService, error) { +) (_ local.NodeService, err error) { if err := validateNodeSetupConfig(conf); err != nil { return nil, err } @@ -61,10 +67,19 @@ func New( if err := validateFreezeMode(conf.Mode, opts.freezeHeight); err != nil { return nil, err } - app, err := prepareApplication(conf, app) + app, storageManager, err := prepareApplication(ctx, conf, app) if err != nil { return nil, err } + storageManagerTransferred := false + defer func() { + if err == nil || storageManagerTransferred { + return + } + if manager, ok := storageManager.Get(); ok { + err = errors.Join(err, manager.Close()) + } + }() proxyApp := proxy.New(app) nodeKey, err := tmtypes.LoadOrGenNodeKey(conf.NodeKeyFile()) if err != nil { @@ -85,7 +100,7 @@ func New( if err != nil { return nil, err } - + storageManagerTransferred = true return makeNode( ctx, conf, @@ -97,6 +112,7 @@ func New( config.DefaultDBProvider, tracerProviderOptions, consensusPolicy, + storageManager, nodeOptions..., ) case config.ModeSeed: @@ -124,38 +140,56 @@ func validateFreezeMode(mode string, freezeHeight uint64) error { } func validateNodeSetupConfig(conf *config.Config) error { + if conf.EVMOnly && conf.Mode == config.ModeSeed { + return errEVMOnlySeed + } if conf.MockApp && conf.AutobahnConfigFile == "" { return fmt.Errorf("mock-app requires autobahn-config-file") } - if conf.EVMOnlyInMemory && conf.AutobahnConfigFile == "" { - return fmt.Errorf("evm-only-in-memory requires autobahn-config-file") + if conf.EVMOnly && conf.AutobahnConfigFile == "" { + return fmt.Errorf("evm-only requires autobahn-config-file") } return nil } -func prepareApplication(conf *config.Config, app abci.Application) (abci.Application, error) { - if conf.EVMOnlyInMemory { - validators, err := evmOnlyValidatorUpdates(conf.AutobahnConfigFile) +func prepareApplication( + ctx context.Context, + conf *config.Config, + app abci.Application, +) (abci.Application, utils.Option[*bootstrap.GigaStorageManager], error) { + noStorage := utils.None[*bootstrap.GigaStorageManager]() + if conf.EVMOnly { + fc, _, err := loadAutobahnCommittee(conf.AutobahnConfigFile) if err != nil { - return nil, fmt.Errorf("load EVM-only validator set: %w", err) + return nil, noStorage, fmt.Errorf("load EVM-only validator set: %w", err) } - logger.Warn("Autobahn EVM-only in-memory execution enabled; state is ephemeral and unsafe for persistent networks") - return p2p.NewEVMOnlyInMemoryApplication(config.AutobahnEVMOnlyInMemoryChainID, validators), nil + validators, err := evmOnlyValidatorUpdates(fc) + if err != nil { + return nil, noStorage, fmt.Errorf("load EVM-only validator set: %w", err) + } + manager, err := openEVMOnlyStorageManager(ctx, conf.RootDir, fc) + if err != nil { + return nil, noStorage, fmt.Errorf("open EVM-only storage: %w", err) + } + logger.Info("Autobahn EVM-only execution enabled with disk-backed Giga storage") + prepared := evmonlyapp.NewEVMOnlyApplication( + config.AutobahnEVMOnlyChainID, + validators, + manager, + evmonly.NewFlatKVChangeSetEncoder(manager.SC()), + ) + return prepared, utils.Some(manager), nil } if conf.MockApp { - return NewMockApp(app), nil + return NewMockApp(app), noStorage, nil } if conf.FastCheckTx { - return fastCheckTxApplication{Application: app}, nil + return fastCheckTxApplication{Application: app}, noStorage, nil } - return app, nil + return app, noStorage, nil } -func evmOnlyValidatorUpdates(autobahnConfigFile string) ([]abci.ValidatorUpdate, error) { - fc, _, err := loadAutobahnCommittee(autobahnConfigFile) - if err != nil { - return nil, err - } +func evmOnlyValidatorUpdates(fc *config.AutobahnFileConfig) ([]abci.ValidatorUpdate, error) { validators := make([]abci.ValidatorUpdate, len(fc.Validators)) for i, validator := range fc.Validators { key, err := ed25519.PublicKeyFromBytes(validator.ValidatorKey.Bytes()) diff --git a/sei-tendermint/node/seed.go b/sei-tendermint/node/seed.go index 02991774c9..5d7bfe840b 100644 --- a/sei-tendermint/node/seed.go +++ b/sei-tendermint/node/seed.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/sei-protocol/sei-chain/sei-db/bootstrap" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" atypes "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/config" @@ -86,6 +87,7 @@ func makeSeedNode( utils.None[*proxy.Proxy](), genDoc, dbProvider, + utils.None[*bootstrap.GigaStorageManager](), ) closers = append(closers, peerCloser) if err != nil { diff --git a/sei-tendermint/node/setup.go b/sei-tendermint/node/setup.go index 310ce54103..2a598f38d9 100644 --- a/sei-tendermint/node/setup.go +++ b/sei-tendermint/node/setup.go @@ -12,6 +12,8 @@ import ( "strings" "time" + "github.com/sei-protocol/sei-chain/sei-db/bootstrap" + seidbconfig "github.com/sei-protocol/sei-chain/sei-db/config" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/memblock" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/blockstore" @@ -280,16 +282,16 @@ func buildValidatorGigaConfig( // A warning is logged if mode and address-book membership disagree so an // operator misconfiguration is visible at startup. // -// The returned BlockStore is owned by the caller (nodeImpl): open happens here -// before the transport starts, so inbound giga connections see a fully -// replayed data.State. Close after giga.Run returns (or immediately if this -// function / subsequent construction fails). +// When managedBlockStore is absent, the returned BlockStore is owned by the +// caller and must outlive the returned router. A manager-supplied store remains +// owned by its manager and is not returned. func buildGigaRouter( cfg *config.Config, nodeKey types.NodeKey, validatorKey utils.Option[atypes.SecretKey], app *proxy.Proxy, genDoc *types.GenesisDoc, + managedBlockStore utils.Option[atypes.BlockStore], ) (p2p.GigaRouter, atypes.BlockStore, error) { fc, validatorAddrs, err := loadAutobahnCommittee(cfg.AutobahnConfigFile) if err != nil { @@ -326,21 +328,22 @@ func buildGigaRouter( // enable/disable decision through as plain config. valCfg.HashVaultDisabledUnsafe = cfg.HashVaultDisabledUnsafe logger.Info("Autobahn: starting as validator", "validators", len(valCfg.ValidatorAddrs)) - blockStore, err := openBlockStore(&valCfg.GigaRouterCommonConfig, fc.BlockDB) + blockStore, ownedBlockStore, err := selectAutobahnBlockStore( + &valCfg.GigaRouterCommonConfig, fc.BlockDB, managedBlockStore) if err != nil { return nil, nil, err } dataState, err := p2p.BuildDataState(&valCfg.GigaRouterCommonConfig, blockStore) if err != nil { - _ = blockStore.Close() + closeAutobahnBlockStore(ownedBlockStore) return nil, nil, err } giga, err := p2p.NewGigaValidatorRouter(valCfg, p2p.NodeSecretKey(nodeKey), dataState) if err != nil { - _ = blockStore.Close() + closeAutobahnBlockStore(ownedBlockStore) return nil, nil, err } - return giga, blockStore, nil + return giga, ownedBlockStore, nil } fnCfg, err := buildFullnodeGigaConfig(cfg.AutobahnConfigFile, app, genDoc) if err != nil { @@ -353,21 +356,42 @@ func buildGigaRouter( // enable/disable decision through as plain config. fnCfg.HashVaultDisabledUnsafe = cfg.HashVaultDisabledUnsafe logger.Info("Autobahn: starting as fullnode", "mode", cfg.Mode, "validators", len(validatorAddrs)) - blockStore, err := openBlockStore(fnCfg, fc.BlockDB) + blockStore, ownedBlockStore, err := selectAutobahnBlockStore(fnCfg, fc.BlockDB, managedBlockStore) if err != nil { return nil, nil, err } dataState, err := p2p.BuildDataState(fnCfg, blockStore) if err != nil { - _ = blockStore.Close() + closeAutobahnBlockStore(ownedBlockStore) return nil, nil, err } giga, err := p2p.NewGigaFullnodeRouter(fnCfg, p2p.NodeSecretKey(nodeKey), dataState) if err != nil { - _ = blockStore.Close() + closeAutobahnBlockStore(ownedBlockStore) return nil, nil, err } - return giga, blockStore, nil + return giga, ownedBlockStore, nil +} + +func selectAutobahnBlockStore( + commonConfig *p2p.GigaRouterCommonConfig, + blockDBConfig config.AutobahnBlockDBConfig, + managed utils.Option[atypes.BlockStore], +) (atypes.BlockStore, atypes.BlockStore, error) { + if blockStore, ok := managed.Get(); ok { + return blockStore, nil, nil + } + blockStore, err := openBlockStore(commonConfig, blockDBConfig) + if err != nil { + return nil, nil, err + } + return blockStore, blockStore, nil +} + +func closeAutobahnBlockStore(blockStore atypes.BlockStore) { + if blockStore != nil { + _ = blockStore.Close() + } } // preparePersistentStateDir resolves a relative PersistentStateDir against @@ -393,7 +417,7 @@ func preparePersistentStateDir(rootDir string, c *p2p.GigaRouterCommonConfig) er // openBlockStore opens littblock when PersistentStateDir is set, memblock otherwise. // preparePersistentStateDir must have run first so dir is rootified and created. -func openBlockStore(c *p2p.GigaRouterCommonConfig, blockDBCfg config.AutobahnBlockDBConfig) (atypes.BlockStore, error) { +func openBlockStore(c *p2p.GigaRouterCommonConfig, blockDBCfg config.AutobahnBlockDBConfig) (*blockstore.Store, error) { dir, ok := c.PersistentStateDir.Get() if !ok { store, err := blockstore.New(memblock.NewBlockDB()) @@ -419,6 +443,33 @@ func openBlockStore(c *p2p.GigaRouterCommonConfig, blockDBCfg config.AutobahnBlo return blockStore, nil } +// openEVMOnlyStorageManager opens the complete disk-backed Giga storage set in +// Autobahn's persistent-state directory. +func openEVMOnlyStorageManager( + ctx context.Context, + rootDir string, + fc *config.AutobahnFileConfig, +) (*bootstrap.GigaStorageManager, error) { + commonCfg := &p2p.GigaRouterCommonConfig{PersistentStateDir: fc.PersistentStateDir} + if err := preparePersistentStateDir(rootDir, commonCfg); err != nil { + return nil, err + } + directory, ok := commonCfg.PersistentStateDir.Get() + if !ok { + return nil, fmt.Errorf("EVM-only execution requires Autobahn persistent_state_dir") + } + storageConfig, err := seidbconfig.DefaultGigaStorageConfig(directory) + if err != nil { + return nil, fmt.Errorf("build EVM-only storage config: %w", err) + } + blockConfig, err := fc.BlockDB.LittBlockConfig(filepath.Join(directory, "blockdb")) + if err != nil { + return nil, fmt.Errorf("build EVM-only block DB config: %w", err) + } + storageConfig.BlockDBConfig = &blockConfig + return bootstrap.NewGigaStorageManager(ctx, storageConfig.WithFullNodeMode()) +} + // resolveMaxInboundFullnodePeers: None ⇒ default, Some(0) ⇒ reject all, // Some(n) ⇒ n. The default lives in the config package so giga_router // doesn't carry an operator-facing knob. @@ -535,6 +586,7 @@ func createRouter( app utils.Option[*proxy.Proxy], genDoc *types.GenesisDoc, dbProvider config.DBProvider, + storageManager utils.Option[*bootstrap.GigaStorageManager], ) (*p2p.Router, closer, utils.Option[atypes.BlockStore], error) { closer := func() error { return nil } noneDB := utils.None[atypes.BlockStore]() @@ -596,12 +648,21 @@ func createRouter( if !ok { return nil, closer, noneDB, fmt.Errorf("autobahn requires app") } - giga, blockStore, err := buildGigaRouter(cfg, nodeKey, validatorKey, proxyApp, genDoc) + managedBlockStore := utils.None[atypes.BlockStore]() + if manager, ok := storageManager.Get(); ok { + if manager.BlockStore() == nil { + return nil, closer, noneDB, fmt.Errorf("autobahn storage manager requires a block store") + } + managedBlockStore = utils.Some[atypes.BlockStore](manager.BlockStore()) + } + giga, blockStore, err := buildGigaRouter(cfg, nodeKey, validatorKey, proxyApp, genDoc, managedBlockStore) if err != nil { return nil, closer, noneDB, err } options.Giga = utils.Some(giga) - gigaBlockStore = utils.Some(blockStore) + if blockStore != nil { + gigaBlockStore = utils.Some(blockStore) + } } peerDB, err := dbProvider(&config.DBContext{ID: "peerstore", Config: cfg}) diff --git a/sei-tendermint/node/setup_test.go b/sei-tendermint/node/setup_test.go index 364a7be1e1..202199afef 100644 --- a/sei-tendermint/node/setup_test.go +++ b/sei-tendermint/node/setup_test.go @@ -348,6 +348,37 @@ func TestPreparePersistentStateDir_EmptyStringIsNone(t *testing.T) { require.False(t, ok, "Some(\"\") must be cleared to None for in-memory mode") } +func TestSelectAutobahnBlockStoreOwnership(t *testing.T) { + commonConfig := &p2p.GigaRouterCommonConfig{} + blockDBConfig := config.AutobahnBlockDBConfig{} + + t.Run("manager-owned", func(t *testing.T) { + managed, err := openBlockStore(commonConfig, blockDBConfig) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, managed.Close()) }) + + selected, owned, err := selectAutobahnBlockStore( + commonConfig, + blockDBConfig, + utils.Some[atypes.BlockStore](managed), + ) + require.NoError(t, err) + require.Equal(t, managed, selected) + require.Nil(t, owned) + }) + + t.Run("standalone", func(t *testing.T) { + selected, owned, err := selectAutobahnBlockStore( + commonConfig, + blockDBConfig, + utils.None[atypes.BlockStore](), + ) + require.NoError(t, err) + require.Equal(t, selected, owned) + require.NoError(t, owned.Close()) + }) +} + // Every other RouterOptions construction site substitutes rate.Inf, so this // derivation is the only place the production accept rate is exercised. func TestP2PRouterOptions_PacingAndBudgetWiring(t *testing.T) {