diff --git a/.github/workflows/merge-checks.yml b/.github/workflows/merge-checks.yml index b729df2b26..6561c429e2 100644 --- a/.github/workflows/merge-checks.yml +++ b/.github/workflows/merge-checks.yml @@ -1,7 +1,7 @@ name: Merge Checks on: - pull_request: + pull_request_target: branches: [ master ] types: [synchronize, opened, reopened, labeled, unlabeled] diff --git a/arbnode/batch_poster.go b/arbnode/batch_poster.go index 1829ae29f5..2617a9a629 100644 --- a/arbnode/batch_poster.go +++ b/arbnode/batch_poster.go @@ -168,6 +168,7 @@ type BatchPosterConfig struct { UseAccessLists bool `koanf:"use-access-lists" reload:"hot"` GasEstimateBaseFeeMultipleBips arbmath.Bips `koanf:"gas-estimate-base-fee-multiple-bips"` Dangerous BatchPosterDangerousConfig `koanf:"dangerous"` + ReorgResistanceMargin time.Duration `koanf:"reorg-resistance-margin" reload:"hot"` gasRefunder common.Address l1BlockBound l1BlockBound @@ -219,6 +220,7 @@ func BatchPosterConfigAddOptions(prefix string, f *pflag.FlagSet) { f.Duration(prefix+".l1-block-bound-bypass", DefaultBatchPosterConfig.L1BlockBoundBypass, "post batches even if not within the layer 1 future bounds if we're within this margin of the max delay") f.Bool(prefix+".use-access-lists", DefaultBatchPosterConfig.UseAccessLists, "post batches with access lists to reduce gas usage (disabled for L3s)") f.Uint64(prefix+".gas-estimate-base-fee-multiple-bips", uint64(DefaultBatchPosterConfig.GasEstimateBaseFeeMultipleBips), "for gas estimation, use this multiple of the basefee (measured in basis points) as the max fee per gas") + f.Duration(prefix+".reorg-resistance-margin", DefaultBatchPosterConfig.ReorgResistanceMargin, "do not post batch if its within this duration from layer 1 minimum bounds. Requires l1-block-bound option not be set to \"ignore\"") redislock.AddConfigOptions(prefix+".redis-lock", f) dataposter.DataPosterConfigAddOptions(prefix+".data-poster", f, dataposter.DefaultDataPosterConfig) genericconf.WalletConfigAddOptions(prefix+".parent-chain-wallet", f, DefaultBatchPosterConfig.ParentChainWallet.Pathname) @@ -248,6 +250,7 @@ var DefaultBatchPosterConfig = BatchPosterConfig{ UseAccessLists: true, RedisLock: redislock.DefaultCfg, GasEstimateBaseFeeMultipleBips: arbmath.OneInBips * 3 / 2, + ReorgResistanceMargin: 10 * time.Minute, } var DefaultBatchPosterL1WalletConfig = genericconf.WalletConfig{ @@ -1136,6 +1139,8 @@ func (b *BatchPoster) maybePostSequencerBatch(ctx context.Context) (bool, error) var l1BoundMaxTimestamp uint64 = math.MaxUint64 var l1BoundMinBlockNumber uint64 var l1BoundMinTimestamp uint64 + var l1BoundMinBlockNumberWithBypass uint64 + var l1BoundMinTimestampWithBypass uint64 hasL1Bound := config.l1BlockBound != l1BlockBoundIgnore if hasL1Bound { var l1Bound *types.Header @@ -1180,17 +1185,19 @@ func (b *BatchPoster) maybePostSequencerBatch(ctx context.Context) (bool, error) l1BoundMaxBlockNumber = arbmath.SaturatingUAdd(l1BoundBlockNumber, arbmath.BigToUintSaturating(maxTimeVariationFutureBlocks)) l1BoundMaxTimestamp = arbmath.SaturatingUAdd(l1Bound.Time, arbmath.BigToUintSaturating(maxTimeVariationFutureSeconds)) + latestHeader, err := b.l1Reader.LastHeader(ctx) + if err != nil { + return false, err + } + latestBlockNumber := arbutil.ParentHeaderToL1BlockNumber(latestHeader) + l1BoundMinBlockNumber = arbmath.SaturatingUSub(latestBlockNumber, arbmath.BigToUintSaturating(maxTimeVariationDelayBlocks)) + l1BoundMinTimestamp = arbmath.SaturatingUSub(latestHeader.Time, arbmath.BigToUintSaturating(maxTimeVariationDelaySeconds)) + if config.L1BlockBoundBypass > 0 { - latestHeader, err := b.l1Reader.LastHeader(ctx) - if err != nil { - return false, err - } - latestBlockNumber := arbutil.ParentHeaderToL1BlockNumber(latestHeader) blockNumberWithPadding := arbmath.SaturatingUAdd(latestBlockNumber, uint64(config.L1BlockBoundBypass/ethPosBlockTime)) timestampWithPadding := arbmath.SaturatingUAdd(latestHeader.Time, uint64(config.L1BlockBoundBypass/time.Second)) - - l1BoundMinBlockNumber = arbmath.SaturatingUSub(blockNumberWithPadding, arbmath.BigToUintSaturating(maxTimeVariationDelayBlocks)) - l1BoundMinTimestamp = arbmath.SaturatingUSub(timestampWithPadding, arbmath.BigToUintSaturating(maxTimeVariationDelaySeconds)) + l1BoundMinBlockNumberWithBypass = arbmath.SaturatingUSub(blockNumberWithPadding, arbmath.BigToUintSaturating(maxTimeVariationDelayBlocks)) + l1BoundMinTimestampWithBypass = arbmath.SaturatingUSub(timestampWithPadding, arbmath.BigToUintSaturating(maxTimeVariationDelaySeconds)) } } @@ -1200,13 +1207,14 @@ func (b *BatchPoster) maybePostSequencerBatch(ctx context.Context) (bool, error) log.Error("error getting message from streamer", "error", err) break } - if msg.Message.Header.BlockNumber < l1BoundMinBlockNumber || msg.Message.Header.Timestamp < l1BoundMinTimestamp { + if msg.Message.Header.BlockNumber < l1BoundMinBlockNumberWithBypass || msg.Message.Header.Timestamp < l1BoundMinTimestampWithBypass { log.Error( "disabling L1 bound as batch posting message is close to the maximum delay", "blockNumber", msg.Message.Header.BlockNumber, - "l1BoundMinBlockNumber", l1BoundMinBlockNumber, + "l1BoundMinBlockNumberWithBypass", l1BoundMinBlockNumberWithBypass, "timestamp", msg.Message.Header.Timestamp, - "l1BoundMinTimestamp", l1BoundMinTimestamp, + "l1BoundMinTimestampWithBypass", l1BoundMinTimestampWithBypass, + "l1BlockBoundBypass", config.L1BlockBoundBypass, ) l1BoundMaxBlockNumber = math.MaxUint64 l1BoundMaxTimestamp = math.MaxUint64 @@ -1242,6 +1250,24 @@ func (b *BatchPoster) maybePostSequencerBatch(ctx context.Context) (bool, error) b.building.msgCount++ } + if hasL1Bound && config.ReorgResistanceMargin > 0 { + firstMsgBlockNumber := firstMsg.Message.Header.BlockNumber + firstMsgTimeStamp := firstMsg.Message.Header.Timestamp + batchNearL1BoundMinBlockNumber := firstMsgBlockNumber <= arbmath.SaturatingUAdd(l1BoundMinBlockNumber, uint64(config.ReorgResistanceMargin/ethPosBlockTime)) + batchNearL1BoundMinTimestamp := firstMsgTimeStamp <= arbmath.SaturatingUAdd(l1BoundMinTimestamp, uint64(config.ReorgResistanceMargin/time.Second)) + if batchNearL1BoundMinTimestamp || batchNearL1BoundMinBlockNumber { + log.Error( + "Disabling batch posting due to batch being within reorg resistance margin from layer 1 minimum block or timestamp bounds", + "reorgResistanceMargin", config.ReorgResistanceMargin, + "firstMsgTimeStamp", firstMsgTimeStamp, + "l1BoundMinTimestamp", l1BoundMinTimestamp, + "firstMsgBlockNumber", firstMsgBlockNumber, + "l1BoundMinBlockNumber", l1BoundMinBlockNumber, + ) + return false, errors.New("batch is within reorg resistance margin from layer 1 minimum block or timestamp bounds") + } + } + if !forcePostBatch || !b.building.haveUsefulMessage { // the batch isn't full yet and we've posted a batch recently // don't post anything for now diff --git a/cmd/nitro/init.go b/cmd/nitro/init.go index 97678a7d23..ea48ec8784 100644 --- a/cmd/nitro/init.go +++ b/cmd/nitro/init.go @@ -408,7 +408,7 @@ func isLeveldbNotExistError(err error) bool { func openInitializeChainDb(ctx context.Context, stack *node.Node, config *NodeConfig, chainId *big.Int, cacheConfig *core.CacheConfig, persistentConfig *conf.PersistentConfig, l1Client arbutil.L1Interface, rollupAddrs chaininfo.RollupAddresses) (ethdb.Database, *core.BlockChain, error) { if !config.Init.Force { - if readOnlyDb, err := stack.OpenDatabaseWithFreezerWithExtraOptions("l2chaindata", 0, 0, "", "l2chaindata/", true, persistentConfig.Pebble.ExtraOptions("l2chaindata")); err == nil { + if readOnlyDb, err := stack.OpenDatabaseWithFreezerWithExtraOptions("l2chaindata", 0, 0, config.Persistent.Ancient, "l2chaindata/", true, persistentConfig.Pebble.ExtraOptions("l2chaindata")); err == nil { if chainConfig := gethexec.TryReadStoredChainConfig(readOnlyDb); chainConfig != nil { readOnlyDb.Close() if !arbmath.BigEquals(chainConfig.ChainID, chainId) { diff --git a/cmd/nitro/nitro.go b/cmd/nitro/nitro.go index 572e6d2f06..04bdeb3228 100644 --- a/cmd/nitro/nitro.go +++ b/cmd/nitro/nitro.go @@ -679,8 +679,6 @@ func mainImpl() int { exitCode = 1 } if nodeConfig.Init.ThenQuit { - close(sigint) - return exitCode } } @@ -694,9 +692,6 @@ func mainImpl() int { log.Info("shutting down because of sigint") } - // cause future ctrl+c's to panic - close(sigint) - return exitCode } diff --git a/das/das.go b/das/das.go index 5528323a9c..6bd02fbc75 100644 --- a/das/das.go +++ b/das/das.go @@ -45,6 +45,8 @@ type DataAvailabilityConfig struct { LocalFileStorage LocalFileStorageConfig `koanf:"local-file-storage"` S3Storage S3StorageServiceConfig `koanf:"s3-storage"` + MigrateLocalDBToFileStorage bool `koanf:"migrate-local-db-to-file-storage"` + Key KeyConfig `koanf:"key"` RPCAggregator AggregatorConfig `koanf:"rpc-aggregator"` @@ -112,6 +114,7 @@ func dataAvailabilityConfigAddOptions(prefix string, f *flag.FlagSet, r role) { LocalDBStorageConfigAddOptions(prefix+".local-db-storage", f) LocalFileStorageConfigAddOptions(prefix+".local-file-storage", f) S3ConfigAddOptions(prefix+".s3-storage", f) + f.Bool(prefix+".migrate-local-db-to-file-storage", DefaultDataAvailabilityConfig.MigrateLocalDBToFileStorage, "daserver will migrate all data on startup from local-db-storage to local-file-storage, then mark local-db-storage as unusable") // Key config for storage KeyConfigAddOptions(prefix+".key", f) diff --git a/das/db_storage_service.go b/das/db_storage_service.go index 0fbe1c2723..e3b6183c37 100644 --- a/das/db_storage_service.go +++ b/das/db_storage_service.go @@ -7,6 +7,9 @@ import ( "bytes" "context" "errors" + "fmt" + "os" + "path/filepath" "time" badger "github.com/dgraph-io/badger/v4" @@ -35,6 +38,8 @@ type LocalDBStorageConfig struct { var badgerDefaultOptions = badger.DefaultOptions("") +const migratedMarker = "MIGRATED" + var DefaultLocalDBStorageConfig = LocalDBStorageConfig{ Enable: false, DataDir: "", @@ -49,7 +54,7 @@ var DefaultLocalDBStorageConfig = LocalDBStorageConfig{ } func LocalDBStorageConfigAddOptions(prefix string, f *flag.FlagSet) { - f.Bool(prefix+".enable", DefaultLocalDBStorageConfig.Enable, "enable storage/retrieval of sequencer batch data from a database on the local filesystem") + f.Bool(prefix+".enable", DefaultLocalDBStorageConfig.Enable, "!!!DEPRECATED, USE local-file-storage!!! enable storage/retrieval of sequencer batch data from a database on the local filesystem") f.String(prefix+".data-dir", DefaultLocalDBStorageConfig.DataDir, "directory in which to store the database") f.Bool(prefix+".discard-after-timeout", DefaultLocalDBStorageConfig.DiscardAfterTimeout, "discard data after its expiry timeout") @@ -69,7 +74,17 @@ type DBStorageService struct { stopWaiter stopwaiter.StopWaiterSafe } -func NewDBStorageService(ctx context.Context, config *LocalDBStorageConfig) (StorageService, error) { +// The DBStorageService is deprecated. This function will migrate data to the target +// LocalFileStorageService if it is provided and migration hasn't already happened. +func NewDBStorageService(ctx context.Context, config *LocalDBStorageConfig, target *LocalFileStorageService) (*DBStorageService, error) { + if alreadyMigrated(config.DataDir) { + log.Warn("local-db-storage already migrated, please remove it from the daserver configuration and restart. data-dir can be cleaned up manually now") + return nil, nil + } + if target == nil { + log.Error("local-db-storage is DEPRECATED, please use use the local-file-storage and migrate-local-db-to-file-storage options. This error will be made fatal in future, continuing for now...") + } + options := badger.DefaultOptions(config.DataDir). WithNumMemtables(config.NumMemtables). WithNumLevelZeroTables(config.NumLevelZeroTables). @@ -87,9 +102,21 @@ func NewDBStorageService(ctx context.Context, config *LocalDBStorageConfig) (Sto discardAfterTimeout: config.DiscardAfterTimeout, dirPath: config.DataDir, } + + if target != nil { + if err = ret.migrateTo(ctx, target); err != nil { + return nil, fmt.Errorf("error migrating local-db-storage to %s: %w", target, err) + } + if err = ret.setMigrated(); err != nil { + return nil, fmt.Errorf("error finalizing migration of local-db-storage to %s: %w", target, err) + } + return nil, nil + } + if err := ret.stopWaiter.Start(ctx, ret); err != nil { return nil, err } + err = ret.stopWaiter.LaunchThreadSafe(func(myCtx context.Context) { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() @@ -152,6 +179,48 @@ func (dbs *DBStorageService) Put(ctx context.Context, data []byte, timeout uint6 }) } +func (dbs *DBStorageService) migrateTo(ctx context.Context, s StorageService) error { + originExpirationPolicy, err := dbs.ExpirationPolicy(ctx) + if err != nil { + return err + } + targetExpirationPolicy, err := s.ExpirationPolicy(ctx) + if err != nil { + return err + } + + if originExpirationPolicy == daprovider.KeepForever && targetExpirationPolicy == daprovider.DiscardAfterDataTimeout { + return errors.New("can't migrate from DBStorageService to target, incompatible expiration policies - can't migrate from non-expiring to expiring since non-expiring DB lacks expiry time metadata") + } + + return dbs.db.View(func(txn *badger.Txn) error { + opts := badger.DefaultIteratorOptions + it := txn.NewIterator(opts) + defer it.Close() + log.Info("Migrating from DBStorageService", "target", s) + migrationStart := time.Now() + count := 0 + for it.Rewind(); it.Valid(); it.Next() { + if count%1000 == 0 { + log.Info("Migration in progress", "migrated", count) + } + item := it.Item() + k := item.Key() + expiry := item.ExpiresAt() + err := item.Value(func(v []byte) error { + log.Trace("migrated", "key", pretty.FirstFewBytes(k), "value", pretty.FirstFewBytes(v), "expiry", expiry) + return s.Put(ctx, v, expiry) + }) + if err != nil { + return err + } + count++ + } + log.Info("Migration from DBStorageService complete", "target", s, "migrated", count, "duration", time.Since(migrationStart)) + return nil + }) +} + func (dbs *DBStorageService) Sync(ctx context.Context) error { return dbs.db.Sync() } @@ -160,6 +229,29 @@ func (dbs *DBStorageService) Close(ctx context.Context) error { return dbs.stopWaiter.StopAndWait() } +func alreadyMigrated(dirPath string) bool { + migratedMarkerFile := filepath.Join(dirPath, migratedMarker) + _, err := os.Stat(migratedMarkerFile) + if os.IsNotExist(err) { + return false + } + if err != nil { + log.Error("error checking if local-db-storage is already migrated", "err", err) + return false + } + return true +} + +func (dbs *DBStorageService) setMigrated() error { + migratedMarkerFile := filepath.Join(dbs.dirPath, migratedMarker) + file, err := os.OpenFile(migratedMarkerFile, os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + file.Close() + return nil +} + func (dbs *DBStorageService) ExpirationPolicy(ctx context.Context) (daprovider.ExpirationPolicy, error) { if dbs.discardAfterTimeout { return daprovider.DiscardAfterDataTimeout, nil diff --git a/das/factory.go b/das/factory.go index 88abc58a73..5742a39479 100644 --- a/das/factory.go +++ b/das/factory.go @@ -25,26 +25,36 @@ func CreatePersistentStorageService( ) (StorageService, *LifecycleManager, error) { storageServices := make([]StorageService, 0, 10) var lifecycleManager LifecycleManager - if config.LocalDBStorage.Enable { - s, err := NewDBStorageService(ctx, &config.LocalDBStorage) + var err error + + var fs *LocalFileStorageService + if config.LocalFileStorage.Enable { + fs, err = NewLocalFileStorageService(config.LocalFileStorage) if err != nil { return nil, nil, err } - lifecycleManager.Register(s) - storageServices = append(storageServices, s) - } - - if config.LocalFileStorage.Enable { - s, err := NewLocalFileStorageService(config.LocalFileStorage) + err = fs.start(ctx) if err != nil { return nil, nil, err } - err = s.start(ctx) + lifecycleManager.Register(fs) + storageServices = append(storageServices, fs) + } + + if config.LocalDBStorage.Enable { + var s *DBStorageService + if config.MigrateLocalDBToFileStorage { + s, err = NewDBStorageService(ctx, &config.LocalDBStorage, fs) + } else { + s, err = NewDBStorageService(ctx, &config.LocalDBStorage, nil) + } if err != nil { return nil, nil, err } - lifecycleManager.Register(s) - storageServices = append(storageServices, s) + if s != nil { + lifecycleManager.Register(s) + storageServices = append(storageServices, s) + } } if config.S3Storage.Enable { @@ -67,6 +77,10 @@ func CreatePersistentStorageService( if len(storageServices) == 1 { return storageServices[0], &lifecycleManager, nil } + if len(storageServices) == 0 { + return nil, nil, errors.New("No data-availability storage backend has been configured") + } + return nil, &lifecycleManager, nil } diff --git a/das/local_file_storage_service.go b/das/local_file_storage_service.go index 6b0a5f0070..621cf3efdb 100644 --- a/das/local_file_storage_service.go +++ b/das/local_file_storage_service.go @@ -199,7 +199,7 @@ func (s *LocalFileStorageService) Put(ctx context.Context, data []byte, expiry u } } - if !s.enableLegacyLayout && s.layout.expiryEnabled { + if !s.enableLegacyLayout { if err := createHardLink(batchPath, s.layout.expiryPath(key, expiry)); err != nil { return fmt.Errorf("couldn't create by-expiry-path index entry: %w", err) } @@ -360,11 +360,9 @@ func migrate(fl *flatLayout, tl *trieLayout) error { return err } - if tl.expiryEnabled { - expiryPath := tl.expiryPath(batch.key, uint64(batch.expiry.Unix())) - if err = createHardLink(newPath, expiryPath); err != nil { - return err - } + expiryPath := tl.expiryPath(batch.key, uint64(batch.expiry.Unix())) + if err = createHardLink(newPath, expiryPath); err != nil { + return err } migrated++ } @@ -698,10 +696,8 @@ func (l *trieLayout) startMigration() error { return err } - if l.expiryEnabled { - if err := os.MkdirAll(filepath.Join(l.root, byExpiryTimestamp+migratingSuffix), 0o700); err != nil { - return err - } + if err := os.MkdirAll(filepath.Join(l.root, byExpiryTimestamp+migratingSuffix), 0o700); err != nil { + return err } return nil @@ -726,10 +722,8 @@ func (l *trieLayout) commitMigration() error { return err } - if l.expiryEnabled { - if err := removeSuffix(byExpiryTimestamp); err != nil { - return err - } + if err := removeSuffix(byExpiryTimestamp); err != nil { + return err } syscall.Sync() diff --git a/das/local_file_storage_service_test.go b/das/local_file_storage_service_test.go index 0b2ba9749d..cc27e293e3 100644 --- a/das/local_file_storage_service_test.go +++ b/das/local_file_storage_service_test.go @@ -98,10 +98,9 @@ func TestMigrationNoExpiry(t *testing.T) { countEntries(t, &s.layout, 4) getByHashAndCheck(t, s, "a", "b", "c", "d") - _, err = s.layout.iterateBatchesByTimestamp(time.Unix(int64(now+10), 0)) - if err == nil { - Fail(t, "can't iterate by timestamp when expiry is disabled") - } + // Can still iterate by timestamp even if expiry disabled + countTimestampEntries(t, &s.layout, time.Unix(int64(now+11), 0), 4) + } func TestMigrationExpiry(t *testing.T) { diff --git a/execution/nodeInterface/virtual-contracts.go b/execution/nodeInterface/virtual-contracts.go index d72ad0da8e..d04be10857 100644 --- a/execution/nodeInterface/virtual-contracts.go +++ b/execution/nodeInterface/virtual-contracts.go @@ -141,7 +141,8 @@ func init() { return } posterCost, _ := state.L1PricingState().PosterDataCost(msg, l1pricing.BatchPosterAddress, brotliCompressionLevel) - posterCostInL2Gas := arbos.GetPosterGas(state, header.BaseFee, msg.TxRunMode, posterCost) + // Use estimate mode because this is used to raise the gas cap, so we don't want to underestimate. + posterCostInL2Gas := arbos.GetPosterGas(state, header.BaseFee, core.MessageGasEstimationMode, posterCost) *gascap = arbmath.SaturatingUAdd(*gascap, posterCostInL2Gas) } diff --git a/staker/block_validator.go b/staker/block_validator.go index 0fea05469f..94ee907da5 100644 --- a/staker/block_validator.go +++ b/staker/block_validator.go @@ -815,7 +815,6 @@ validationsLoop: v.possiblyFatal(errors.New("failed to set SendingValidation status")) } validatorPendingValidationsGauge.Inc(1) - defer validatorPendingValidationsGauge.Dec(1) var runs []validator.ValidationRun for _, moduleRoot := range wasmRoots { run := v.chosenValidator[moduleRoot].Launch(input, moduleRoot) @@ -826,6 +825,7 @@ validationsLoop: validationStatus.Runs = runs validationStatus.Cancel = cancel v.LaunchUntrackedThread(func() { + defer validatorPendingValidationsGauge.Dec(1) defer cancel() replaced = validationStatus.replaceStatus(SendingValidation, ValidationSent) if !replaced { diff --git a/system_tests/estimation_test.go b/system_tests/estimation_test.go index e7f00ca94e..284c709fad 100644 --- a/system_tests/estimation_test.go +++ b/system_tests/estimation_test.go @@ -324,3 +324,35 @@ func TestDisableL1Charging(t *testing.T) { _, err = builder.L2.Client.CallContract(ctx, ethereum.CallMsg{To: &addr, Gas: gasWithoutL1Charging, SkipL1Charging: true}, nil) Require(t, err) } + +func TestGasEstimationWithRPCGasLimit(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + builder := NewNodeBuilder(ctx).DefaultConfig(t, true) + cleanup := builder.Build(t) + defer cleanup() + + execConfigA := builder.execConfig + execConfigA.RPC.RPCGasCap = params.TxGas + testClientA, cleanupA := builder.Build2ndNode(t, &SecondNodeParams{execConfig: execConfigA}) + defer cleanupA() + addr := common.HexToAddress("0x12345678") + estimateGas, err := testClientA.Client.EstimateGas(ctx, ethereum.CallMsg{To: &addr}) + Require(t, err) + if estimateGas <= params.TxGas { + Fatal(t, "Incorrect gas estimate") + } + + _, err = testClientA.Client.CallContract(ctx, ethereum.CallMsg{To: &addr}, nil) + Require(t, err) + + execConfigB := builder.execConfig + execConfigB.RPC.RPCGasCap = params.TxGas - 1 + testClientB, cleanupB := builder.Build2ndNode(t, &SecondNodeParams{execConfig: execConfigB}) + defer cleanupB() + _, err = testClientB.Client.EstimateGas(ctx, ethereum.CallMsg{To: &addr}) + if err == nil { + Fatal(t, "EstimateGas passed with insufficient gas") + } +} diff --git a/system_tests/validation_mock_test.go b/system_tests/validation_mock_test.go index fb4f868571..1330f24882 100644 --- a/system_tests/validation_mock_test.go +++ b/system_tests/validation_mock_test.go @@ -126,6 +126,20 @@ func (r *mockExecRun) GetStepAt(position uint64) containers.PromiseInterface[*va }, nil) } +func (r *mockExecRun) GetMachineHashesWithStepSize(machineStartIndex, stepSize, maxIterations uint64) containers.PromiseInterface[[]common.Hash] { + ctx := context.Background() + hashes := make([]common.Hash, 0) + for i := uint64(0); i < maxIterations; i++ { + absoluteMachineIndex := machineStartIndex + stepSize*(i+1) + stepResult, err := r.GetStepAt(absoluteMachineIndex).Await(ctx) + if err != nil { + return containers.NewReadyPromise[[]common.Hash](nil, err) + } + hashes = append(hashes, stepResult.Hash) + } + return containers.NewReadyPromise[[]common.Hash](hashes, nil) +} + func (r *mockExecRun) GetLastStep() containers.PromiseInterface[*validator.MachineStepResult] { return r.GetStepAt(mockExecLastPos) } diff --git a/validator/client/validation_client.go b/validator/client/validation_client.go index fa6b9000f2..949260002d 100644 --- a/validator/client/validation_client.go +++ b/validator/client/validation_client.go @@ -197,6 +197,17 @@ func (r *ExecutionClientRun) GetStepAt(pos uint64) containers.PromiseInterface[* }) } +func (r *ExecutionClientRun) GetMachineHashesWithStepSize(machineStartIndex, stepSize, maxIterations uint64) containers.PromiseInterface[[]common.Hash] { + return stopwaiter.LaunchPromiseThread[[]common.Hash](r, func(ctx context.Context) ([]common.Hash, error) { + var resJson []common.Hash + err := r.client.client.CallContext(ctx, &resJson, server_api.Namespace+"_getMachineHashesWithStepSize", r.id, machineStartIndex, stepSize, maxIterations) + if err != nil { + return nil, err + } + return resJson, err + }) +} + func (r *ExecutionClientRun) GetProofAt(pos uint64) containers.PromiseInterface[[]byte] { return stopwaiter.LaunchPromiseThread[[]byte](r, func(ctx context.Context) ([]byte, error) { var resString string diff --git a/validator/interface.go b/validator/interface.go index 0324b996ed..91668a3771 100644 --- a/validator/interface.go +++ b/validator/interface.go @@ -30,6 +30,7 @@ type ExecutionSpawner interface { type ExecutionRun interface { GetStepAt(uint64) containers.PromiseInterface[*MachineStepResult] + GetMachineHashesWithStepSize(machineStartIndex, stepSize, maxIterations uint64) containers.PromiseInterface[[]common.Hash] GetLastStep() containers.PromiseInterface[*MachineStepResult] GetProofAt(uint64) containers.PromiseInterface[[]byte] PrepareRange(uint64, uint64) containers.PromiseInterface[struct{}] diff --git a/validator/server_arb/execution_run.go b/validator/server_arb/execution_run.go index 255d42ab16..8bdce145a2 100644 --- a/validator/server_arb/execution_run.go +++ b/validator/server_arb/execution_run.go @@ -7,7 +7,12 @@ import ( "context" "fmt" "sync" + "time" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/ethereum/go-ethereum/log" "github.com/offchainlabs/nitro/util/containers" "github.com/offchainlabs/nitro/util/stopwaiter" "github.com/offchainlabs/nitro/validator" @@ -55,7 +60,6 @@ func (e *executionRun) GetStepAt(position uint64) containers.PromiseInterface[*v if position == ^uint64(0) { machine, err = e.cache.GetFinalMachine(ctx) } else { - // todo cache last machine machine, err = e.cache.GetMachineAt(ctx, position) } if err != nil { @@ -79,6 +83,104 @@ func (e *executionRun) GetStepAt(position uint64) containers.PromiseInterface[*v }) } +func (e *executionRun) GetMachineHashesWithStepSize(machineStartIndex, stepSize, maxIterations uint64) containers.PromiseInterface[[]common.Hash] { + return stopwaiter.LaunchPromiseThread(e, func(ctx context.Context) ([]common.Hash, error) { + return e.machineHashesWithStepSize(ctx, machineStartIndex, stepSize, maxIterations) + }) +} + +func (e *executionRun) machineHashesWithStepSize( + ctx context.Context, + machineStartIndex, + stepSize, + maxIterations uint64, +) ([]common.Hash, error) { + if stepSize == 0 { + return nil, fmt.Errorf("step size cannot be 0") + } + if maxIterations == 0 { + return nil, fmt.Errorf("max number of iterations cannot be 0") + } + machine, err := e.cache.GetMachineAt(ctx, machineStartIndex) + if err != nil { + return nil, err + } + log.Debug(fmt.Sprintf("Advanced machine to index %d, beginning hash computation", machineStartIndex)) + + // In BOLD, the hash of a machine at index 0 is a special hash that is computed as the + // `machineFinishedHash(gs)` where `gs` is the global state of the machine at index 0. + // This is so that the hash aligns with the start state of the claimed challenge edge + // at the level above, as required by the BOLD protocol. + var machineHashes []common.Hash + if machineStartIndex == 0 { + gs := machine.GetGlobalState() + log.Debug(fmt.Sprintf("Start global state for machine index 0: %+v", gs)) + machineHashes = append(machineHashes, machineFinishedHash(gs)) + } else { + // Otherwise, we simply append the machine hash at the specified start index. + machineHashes = append(machineHashes, machine.Hash()) + } + startHash := machineHashes[0] + + // If we only want 1 hash, we can return early. + if maxIterations == 1 { + return machineHashes, nil + } + + logInterval := maxIterations / 20 // Log every 5% progress + if logInterval == 0 { + logInterval = 1 + } + + start := time.Now() + for i := uint64(0); i < maxIterations; i++ { + // The absolute program counter the machine should be in after stepping. + absoluteMachineIndex := machineStartIndex + stepSize*(i+1) + + // Advance the machine in step size increments. + if err := machine.Step(ctx, stepSize); err != nil { + return nil, fmt.Errorf("failed to step machine to position %d: %w", absoluteMachineIndex, err) + } + if i%logInterval == 0 || i == maxIterations-1 { + progressPercent := (float64(i+1) / float64(maxIterations)) * 100 + log.Info( + fmt.Sprintf( + "Computing BOLD subchallenge progress: %.2f%% - %d of %d hashes", + progressPercent, + i+1, + maxIterations, + ), + "machinePosition", i*stepSize+machineStartIndex, + "timeSinceStart", time.Since(start), + "stepSize", stepSize, + "startHash", startHash, + "machineStartIndex", machineStartIndex, + "maxIterations", maxIterations, + ) + } + machineHashes = append(machineHashes, machine.Hash()) + if uint64(len(machineHashes)) == maxIterations { + log.Info("Reached the max number of iterations for the hashes needed to open a subchallenge") + break + } + if !machine.IsRunning() { + log.Info("Machine no longer running, exiting early from hash computation loop") + break + } + } + log.Info( + "Successfully finished computing the data needed for opening a subchallenge", + "stepSize", stepSize, + "startHash", startHash, + "machineStartIndex", machineStartIndex, + "numberOfHashesComputed", len(machineHashes), + "maxIterations", maxIterations, + "finishedHash", machineHashes[len(machineHashes)-1], + "finishedGlobalState", fmt.Sprintf("%+v", machine.GetGlobalState()), + ) + return machineHashes, nil +} + func (e *executionRun) GetProofAt(position uint64) containers.PromiseInterface[[]byte] { return stopwaiter.LaunchPromiseThread[[]byte](e, func(ctx context.Context) ([]byte, error) { machine, err := e.cache.GetMachineAt(ctx, position) @@ -92,3 +194,7 @@ func (e *executionRun) GetProofAt(position uint64) containers.PromiseInterface[[ func (e *executionRun) GetLastStep() containers.PromiseInterface[*validator.MachineStepResult] { return e.GetStepAt(^uint64(0)) } + +func machineFinishedHash(gs validator.GoGlobalState) common.Hash { + return crypto.Keccak256Hash([]byte("Machine finished:"), gs.Hash().Bytes()) +} diff --git a/validator/server_arb/execution_run_test.go b/validator/server_arb/execution_run_test.go new file mode 100644 index 0000000000..bdc1eefc4d --- /dev/null +++ b/validator/server_arb/execution_run_test.go @@ -0,0 +1,206 @@ +package server_arb + +import ( + "context" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/offchainlabs/nitro/validator" +) + +type mockMachine struct { + gs validator.GoGlobalState + totalSteps uint64 +} + +func (m *mockMachine) Hash() common.Hash { + if m.gs.PosInBatch == m.totalSteps-1 { + return machineFinishedHash(m.gs) + } + return m.gs.Hash() +} + +func (m *mockMachine) GetGlobalState() validator.GoGlobalState { + return m.gs +} + +func (m *mockMachine) Step(ctx context.Context, stepSize uint64) error { + for i := uint64(0); i < stepSize; i++ { + if m.gs.PosInBatch == m.totalSteps-1 { + return nil + } + m.gs.PosInBatch += 1 + } + return nil +} + +func (m *mockMachine) CloneMachineInterface() MachineInterface { + return &mockMachine{ + gs: validator.GoGlobalState{Batch: m.gs.Batch, PosInBatch: m.gs.PosInBatch}, + totalSteps: m.totalSteps, + } +} +func (m *mockMachine) GetStepCount() uint64 { + return 0 +} +func (m *mockMachine) IsRunning() bool { + return m.gs.PosInBatch < m.totalSteps-1 +} +func (m *mockMachine) ValidForStep(uint64) bool { + return true +} +func (m *mockMachine) Status() uint8 { + if m.gs.PosInBatch == m.totalSteps-1 { + return uint8(validator.MachineStatusFinished) + } + return uint8(validator.MachineStatusRunning) +} +func (m *mockMachine) ProveNextStep() []byte { + return nil +} +func (m *mockMachine) Freeze() {} +func (m *mockMachine) Destroy() {} + +func Test_machineHashesWithStep(t *testing.T) { + t.Run("basic argument checks", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + e := &executionRun{} + machStartIndex := uint64(0) + stepSize := uint64(0) + maxIterations := uint64(0) + _, err := e.machineHashesWithStepSize(ctx, machStartIndex, stepSize, maxIterations) + if err == nil || !strings.Contains(err.Error(), "step size cannot be 0") { + t.Error("Wrong error") + } + stepSize = uint64(1) + _, err = e.machineHashesWithStepSize(ctx, machStartIndex, stepSize, maxIterations) + if err == nil || !strings.Contains(err.Error(), "number of iterations cannot be 0") { + t.Error("Wrong error") + } + }) + t.Run("machine at start index 0 hash is the finished state hash", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + mm := &mockMachine{ + gs: validator.GoGlobalState{ + Batch: 1, + }, + totalSteps: 20, + } + machStartIndex := uint64(0) + stepSize := uint64(1) + maxIterations := uint64(1) + e := &executionRun{ + cache: NewMachineCache(ctx, func(_ context.Context) (MachineInterface, error) { + return mm, nil + }, &DefaultMachineCacheConfig), + } + + hashes, err := e.machineHashesWithStepSize(ctx, machStartIndex, stepSize, maxIterations) + if err != nil { + t.Fatal(err) + } + expected := machineFinishedHash(mm.gs) + if len(hashes) != 1 { + t.Error("Wanted one hash") + } + if expected != hashes[0] { + t.Errorf("Wanted %#x, got %#x", expected, hashes[0]) + } + }) + t.Run("can step in step size increments and collect hashes", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + initialGs := validator.GoGlobalState{ + Batch: 1, + PosInBatch: 0, + } + mm := &mockMachine{ + gs: initialGs, + totalSteps: 20, + } + machStartIndex := uint64(0) + stepSize := uint64(5) + maxIterations := uint64(4) + e := &executionRun{ + cache: NewMachineCache(ctx, func(_ context.Context) (MachineInterface, error) { + return mm, nil + }, &DefaultMachineCacheConfig), + } + hashes, err := e.machineHashesWithStepSize(ctx, machStartIndex, stepSize, maxIterations) + if err != nil { + t.Fatal(err) + } + expectedHashes := make([]common.Hash, 0) + for i := uint64(0); i < 4; i++ { + if i == 0 { + expectedHashes = append(expectedHashes, machineFinishedHash(initialGs)) + continue + } + gs := validator.GoGlobalState{ + Batch: 1, + PosInBatch: uint64(i * stepSize), + } + expectedHashes = append(expectedHashes, gs.Hash()) + } + if len(hashes) != len(expectedHashes) { + t.Fatal("Wanted one hash") + } + for i := range hashes { + if expectedHashes[i] != hashes[i] { + t.Errorf("Wanted at index %d, %#x, got %#x", i, expectedHashes[i], hashes[i]) + } + } + }) + t.Run("if finishes execution early, can return a smaller number of hashes than the expected max iterations", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + initialGs := validator.GoGlobalState{ + Batch: 1, + PosInBatch: 0, + } + mm := &mockMachine{ + gs: initialGs, + totalSteps: 20, + } + machStartIndex := uint64(0) + stepSize := uint64(5) + maxIterations := uint64(10) + e := &executionRun{ + cache: NewMachineCache(ctx, func(_ context.Context) (MachineInterface, error) { + return mm, nil + }, &DefaultMachineCacheConfig), + } + + hashes, err := e.machineHashesWithStepSize(ctx, machStartIndex, stepSize, maxIterations) + if err != nil { + t.Fatal(err) + } + expectedHashes := make([]common.Hash, 0) + for i := uint64(0); i < 4; i++ { + if i == 0 { + expectedHashes = append(expectedHashes, machineFinishedHash(initialGs)) + continue + } + gs := validator.GoGlobalState{ + Batch: 1, + PosInBatch: uint64(i * stepSize), + } + expectedHashes = append(expectedHashes, gs.Hash()) + } + expectedHashes = append(expectedHashes, machineFinishedHash(validator.GoGlobalState{ + Batch: 1, + PosInBatch: mm.totalSteps - 1, + })) + if len(hashes) >= int(maxIterations) { + t.Fatal("Wanted fewer hashes than the max iterations") + } + for i := range hashes { + if expectedHashes[i] != hashes[i] { + t.Errorf("Wanted at index %d, %#x, got %#x", i, expectedHashes[i], hashes[i]) + } + } + }) +} diff --git a/validator/valnode/validation_api.go b/validator/valnode/validation_api.go index a67299b1a1..3299366821 100644 --- a/validator/valnode/validation_api.go +++ b/validator/valnode/validation_api.go @@ -148,6 +148,19 @@ func (a *ExecServerAPI) GetStepAt(ctx context.Context, execid uint64, position u return server_api.MachineStepResultToJson(res), nil } +func (a *ExecServerAPI) GetMachineHashesWithStepSize(ctx context.Context, execid, fromStep, stepSize, maxIterations uint64) ([]common.Hash, error) { + run, err := a.getRun(execid) + if err != nil { + return nil, err + } + hashesInRange := run.GetMachineHashesWithStepSize(fromStep, stepSize, maxIterations) + res, err := hashesInRange.Await(ctx) + if err != nil { + return nil, err + } + return res, nil +} + func (a *ExecServerAPI) GetProofAt(ctx context.Context, execid uint64, position uint64) (string, error) { run, err := a.getRun(execid) if err != nil {