diff --git a/Dockerfile b/Dockerfile index aba5432254..c64d07ad16 100644 --- a/Dockerfile +++ b/Dockerfile @@ -297,6 +297,8 @@ FROM nitro-node-slim AS nitro-node USER root COPY --from=prover-export /bin/jit /usr/local/bin/ COPY --from=node-builder /workspace/target/bin/daserver /usr/local/bin/ +COPY --from=node-builder /workspace/target/bin/autonomous-auctioneer /usr/local/bin/ +COPY --from=node-builder /workspace/target/bin/bidder-client /usr/local/bin/ COPY --from=node-builder /workspace/target/bin/datool /usr/local/bin/ COPY --from=nitro-legacy /home/user/target/machines /home/user/nitro-legacy/machines RUN rm -rf /workspace/target/legacy-machines/latest diff --git a/Makefile b/Makefile index 12dfb07cf8..da82678586 100644 --- a/Makefile +++ b/Makefile @@ -169,7 +169,7 @@ all: build build-replay-env test-gen-proofs @touch .make/all .PHONY: build -build: $(patsubst %,$(output_root)/bin/%, nitro deploy relay daserver datool seq-coordinator-invalidate nitro-val seq-coordinator-manager dbconv) +build: $(patsubst %,$(output_root)/bin/%, nitro deploy relay daserver autonomous-auctioneer bidder-client datool seq-coordinator-invalidate nitro-val seq-coordinator-manager dbconv) @printf $(done) .PHONY: build-node-deps @@ -311,6 +311,12 @@ $(output_root)/bin/relay: $(DEP_PREDICATE) build-node-deps $(output_root)/bin/daserver: $(DEP_PREDICATE) build-node-deps go build $(GOLANG_PARAMS) -o $@ "$(CURDIR)/cmd/daserver" +$(output_root)/bin/autonomous-auctioneer: $(DEP_PREDICATE) build-node-deps + go build $(GOLANG_PARAMS) -o $@ "$(CURDIR)/cmd/autonomous-auctioneer" + +$(output_root)/bin/bidder-client: $(DEP_PREDICATE) build-node-deps + go build $(GOLANG_PARAMS) -o $@ "$(CURDIR)/cmd/bidder-client" + $(output_root)/bin/datool: $(DEP_PREDICATE) build-node-deps go build $(GOLANG_PARAMS) -o $@ "$(CURDIR)/cmd/datool" diff --git a/cmd/autonomous-auctioneer/config.go b/cmd/autonomous-auctioneer/config.go new file mode 100644 index 0000000000..bdb5479950 --- /dev/null +++ b/cmd/autonomous-auctioneer/config.go @@ -0,0 +1,156 @@ +package main + +import ( + "fmt" + "reflect" + "time" + + flag "github.com/spf13/pflag" + + "github.com/ethereum/go-ethereum/node" + "github.com/ethereum/go-ethereum/p2p" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/offchainlabs/nitro/cmd/conf" + "github.com/offchainlabs/nitro/cmd/genericconf" + "github.com/offchainlabs/nitro/timeboost" + "github.com/offchainlabs/nitro/util/colors" +) + +type AutonomousAuctioneerConfig struct { + AuctioneerServer timeboost.AuctioneerServerConfig `koanf:"auctioneer-server"` + BidValidator timeboost.BidValidatorConfig `koanf:"bid-validator"` + Persistent conf.PersistentConfig `koanf:"persistent"` + Conf genericconf.ConfConfig `koanf:"conf" reload:"hot"` + LogLevel string `koanf:"log-level" reload:"hot"` + LogType string `koanf:"log-type" reload:"hot"` + FileLogging genericconf.FileLoggingConfig `koanf:"file-logging" reload:"hot"` + HTTP genericconf.HTTPConfig `koanf:"http"` + WS genericconf.WSConfig `koanf:"ws"` + IPC genericconf.IPCConfig `koanf:"ipc"` + Metrics bool `koanf:"metrics"` + MetricsServer genericconf.MetricsServerConfig `koanf:"metrics-server"` + PProf bool `koanf:"pprof"` + PprofCfg genericconf.PProf `koanf:"pprof-cfg"` +} + +var HTTPConfigDefault = genericconf.HTTPConfig{ + Addr: "", + Port: genericconf.HTTPConfigDefault.Port, + API: []string{}, + RPCPrefix: genericconf.HTTPConfigDefault.RPCPrefix, + CORSDomain: genericconf.HTTPConfigDefault.CORSDomain, + VHosts: genericconf.HTTPConfigDefault.VHosts, + ServerTimeouts: genericconf.HTTPConfigDefault.ServerTimeouts, +} + +var WSConfigDefault = genericconf.WSConfig{ + Addr: "", + Port: genericconf.WSConfigDefault.Port, + API: []string{}, + RPCPrefix: genericconf.WSConfigDefault.RPCPrefix, + Origins: genericconf.WSConfigDefault.Origins, + ExposeAll: genericconf.WSConfigDefault.ExposeAll, +} + +var IPCConfigDefault = genericconf.IPCConfig{ + Path: "", +} + +var AutonomousAuctioneerConfigDefault = AutonomousAuctioneerConfig{ + Conf: genericconf.ConfConfigDefault, + LogLevel: "INFO", + LogType: "plaintext", + HTTP: HTTPConfigDefault, + WS: WSConfigDefault, + IPC: IPCConfigDefault, + Metrics: false, + MetricsServer: genericconf.MetricsServerConfigDefault, + PProf: false, + Persistent: conf.PersistentConfigDefault, + PprofCfg: genericconf.PProfDefault, +} + +func AuctioneerConfigAddOptions(f *flag.FlagSet) { + timeboost.AuctioneerServerConfigAddOptions("auctioneer-server", f) + timeboost.BidValidatorConfigAddOptions("bid-validator", f) + conf.PersistentConfigAddOptions("persistent", f) + genericconf.ConfConfigAddOptions("conf", f) + f.String("log-level", AutonomousAuctioneerConfigDefault.LogLevel, "log level, valid values are CRIT, ERROR, WARN, INFO, DEBUG, TRACE") + f.String("log-type", AutonomousAuctioneerConfigDefault.LogType, "log type (plaintext or json)") + genericconf.FileLoggingConfigAddOptions("file-logging", f) + genericconf.HTTPConfigAddOptions("http", f) + genericconf.WSConfigAddOptions("ws", f) + genericconf.IPCConfigAddOptions("ipc", f) + f.Bool("metrics", AutonomousAuctioneerConfigDefault.Metrics, "enable metrics") + genericconf.MetricsServerAddOptions("metrics-server", f) + f.Bool("pprof", AutonomousAuctioneerConfigDefault.PProf, "enable pprof") + genericconf.PProfAddOptions("pprof-cfg", f) +} + +func (c *AutonomousAuctioneerConfig) ShallowClone() *AutonomousAuctioneerConfig { + config := &AutonomousAuctioneerConfig{} + *config = *c + return config +} + +func (c *AutonomousAuctioneerConfig) CanReload(new *AutonomousAuctioneerConfig) error { + var check func(node, other reflect.Value, path string) + var err error + + check = func(node, value reflect.Value, path string) { + if node.Kind() != reflect.Struct { + return + } + + for i := 0; i < node.NumField(); i++ { + fieldTy := node.Type().Field(i) + if !fieldTy.IsExported() { + continue + } + hot := fieldTy.Tag.Get("reload") == "hot" + dot := path + "." + fieldTy.Name + + first := node.Field(i).Interface() + other := value.Field(i).Interface() + + if !hot && !reflect.DeepEqual(first, other) { + err = fmt.Errorf("illegal change to %v%v%v", colors.Red, dot, colors.Clear) + } else { + check(node.Field(i), value.Field(i), dot) + } + } + } + + check(reflect.ValueOf(c).Elem(), reflect.ValueOf(new).Elem(), "config") + return err +} + +func (c *AutonomousAuctioneerConfig) GetReloadInterval() time.Duration { + return c.Conf.ReloadInterval +} + +func (c *AutonomousAuctioneerConfig) Validate() error { + return nil +} + +var DefaultAuctioneerStackConfig = node.Config{ + DataDir: node.DefaultDataDir(), + HTTPPort: node.DefaultHTTPPort, + AuthAddr: node.DefaultAuthHost, + AuthPort: node.DefaultAuthPort, + AuthVirtualHosts: node.DefaultAuthVhosts, + HTTPModules: []string{timeboost.AuctioneerNamespace}, + HTTPHost: "localhost", + HTTPVirtualHosts: []string{"localhost"}, + HTTPTimeouts: rpc.DefaultHTTPTimeouts, + WSHost: "localhost", + WSPort: node.DefaultWSPort, + WSModules: []string{timeboost.AuctioneerNamespace}, + GraphQLVirtualHosts: []string{"localhost"}, + P2P: p2p.Config{ + ListenAddr: "", + NoDiscovery: true, + NoDial: true, + }, +} diff --git a/cmd/autonomous-auctioneer/main.go b/cmd/autonomous-auctioneer/main.go new file mode 100644 index 0000000000..eb22d0e177 --- /dev/null +++ b/cmd/autonomous-auctioneer/main.go @@ -0,0 +1,222 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + flag "github.com/spf13/pflag" + + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/metrics" + "github.com/ethereum/go-ethereum/metrics/exp" + "github.com/ethereum/go-ethereum/node" + + "github.com/offchainlabs/nitro/cmd/genericconf" + "github.com/offchainlabs/nitro/cmd/util/confighelpers" + "github.com/offchainlabs/nitro/timeboost" +) + +func printSampleUsage(name string) { + fmt.Printf("Sample usage: %s --help \n", name) +} + +func main() { + os.Exit(mainImpl()) +} + +// Checks metrics and PProf flag, runs them if enabled. +// Note: they are separate so one can enable/disable them as they wish, the only +// requirement is that they can't run on the same address and port. +func startMetrics(cfg *AutonomousAuctioneerConfig) error { + mAddr := fmt.Sprintf("%v:%v", cfg.MetricsServer.Addr, cfg.MetricsServer.Port) + pAddr := fmt.Sprintf("%v:%v", cfg.PprofCfg.Addr, cfg.PprofCfg.Port) + if cfg.Metrics && !metrics.Enabled { + return fmt.Errorf("metrics must be enabled via command line by adding --metrics, json config has no effect") + } + if cfg.Metrics && cfg.PProf && mAddr == pAddr { + return fmt.Errorf("metrics and pprof cannot be enabled on the same address:port: %s", mAddr) + } + if cfg.Metrics { + go metrics.CollectProcessMetrics(time.Second) + exp.Setup(fmt.Sprintf("%v:%v", cfg.MetricsServer.Addr, cfg.MetricsServer.Port)) + } + if cfg.PProf { + genericconf.StartPprof(pAddr) + } + return nil +} + +func mainImpl() int { + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + + args := os.Args[1:] + nodeConfig, err := parseAuctioneerArgs(ctx, args) + if err != nil { + confighelpers.PrintErrorAndExit(err, printSampleUsage) + panic(err) + } + stackConf := DefaultAuctioneerStackConfig + stackConf.DataDir = "" // ephemeral + nodeConfig.HTTP.Apply(&stackConf) + nodeConfig.WS.Apply(&stackConf) + nodeConfig.IPC.Apply(&stackConf) + stackConf.P2P.ListenAddr = "" + stackConf.P2P.NoDial = true + stackConf.P2P.NoDiscovery = true + vcsRevision, strippedRevision, vcsTime := confighelpers.GetVersion() + stackConf.Version = strippedRevision + + pathResolver := func(workdir string) func(string) string { + if workdir == "" { + workdir, err = os.Getwd() + if err != nil { + log.Warn("Failed to get workdir", "err", err) + } + } + return func(path string) string { + if filepath.IsAbs(path) { + return path + } + return filepath.Join(workdir, path) + } + } + + err = genericconf.InitLog(nodeConfig.LogType, nodeConfig.LogLevel, &nodeConfig.FileLogging, pathResolver(nodeConfig.Persistent.LogDir)) + if err != nil { + fmt.Fprintf(os.Stderr, "Error initializing logging: %v\n", err) + return 1 + } + if stackConf.JWTSecret == "" && stackConf.AuthAddr != "" { + filename := pathResolver(nodeConfig.Persistent.GlobalConfig)("jwtsecret") + if err := genericconf.TryCreatingJWTSecret(filename); err != nil { + log.Error("Failed to prepare jwt secret file", "err", err) + return 1 + } + stackConf.JWTSecret = filename + } + + liveNodeConfig := genericconf.NewLiveConfig[*AutonomousAuctioneerConfig](args, nodeConfig, parseAuctioneerArgs) + liveNodeConfig.SetOnReloadHook(func(oldCfg *AutonomousAuctioneerConfig, newCfg *AutonomousAuctioneerConfig) error { + + return genericconf.InitLog(newCfg.LogType, newCfg.LogLevel, &newCfg.FileLogging, pathResolver(nodeConfig.Persistent.LogDir)) + }) + + timeboost.EnsureBidValidatorExposedViaRPC(&stackConf) + + if err := startMetrics(nodeConfig); err != nil { + log.Error("Error starting metrics", "error", err) + return 1 + } + + fatalErrChan := make(chan error, 10) + + if nodeConfig.AuctioneerServer.Enable && nodeConfig.BidValidator.Enable { + log.Crit("Both auctioneer and bid validator are enabled, only one can be enabled at a time") + return 1 + } + + if nodeConfig.AuctioneerServer.Enable { + log.Info("Running Arbitrum express lane auctioneer", "revision", vcsRevision, "vcs.time", vcsTime) + auctioneer, err := timeboost.NewAuctioneerServer( + ctx, + func() *timeboost.AuctioneerServerConfig { return &liveNodeConfig.Get().AuctioneerServer }, + ) + if err != nil { + log.Error("Error creating new auctioneer", "error", err) + return 1 + } + auctioneer.Start(ctx) + } else if nodeConfig.BidValidator.Enable { + log.Info("Running Arbitrum express lane bid validator", "revision", vcsRevision, "vcs.time", vcsTime) + stack, err := node.New(&stackConf) + if err != nil { + flag.Usage() + log.Crit("failed to initialize geth stack", "err", err) + } + bidValidator, err := timeboost.NewBidValidator( + ctx, + stack, + func() *timeboost.BidValidatorConfig { return &liveNodeConfig.Get().BidValidator }, + ) + if err != nil { + log.Error("Error creating new bid validator", "error", err) + return 1 + } + if err = bidValidator.Initialize(ctx); err != nil { + log.Error("error initializing bid validator", "err", err) + return 1 + } + err = stack.Start() + if err != nil { + fatalErrChan <- fmt.Errorf("error starting stack: %w", err) + } + defer stack.Close() + bidValidator.Start(ctx) + } + + liveNodeConfig.Start(ctx) + defer liveNodeConfig.StopAndWait() + + sigint := make(chan os.Signal, 1) + signal.Notify(sigint, os.Interrupt, syscall.SIGTERM) + + exitCode := 0 + select { + case err := <-fatalErrChan: + log.Error("shutting down due to fatal error", "err", err) + defer log.Error("shut down due to fatal error", "err", err) + exitCode = 1 + case <-sigint: + log.Info("shutting down because of sigint") + } + // cause future ctrl+c's to panic + close(sigint) + return exitCode +} + +func parseAuctioneerArgs(ctx context.Context, args []string) (*AutonomousAuctioneerConfig, error) { + f := flag.NewFlagSet("", flag.ContinueOnError) + + AuctioneerConfigAddOptions(f) + + k, err := confighelpers.BeginCommonParse(f, args) + if err != nil { + return nil, err + } + + err = confighelpers.ApplyOverrides(f, k) + if err != nil { + return nil, err + } + + var cfg AutonomousAuctioneerConfig + if err := confighelpers.EndCommonParse(k, &cfg); err != nil { + return nil, err + } + + // Don't print wallet passwords + if cfg.Conf.Dump { + err = confighelpers.DumpConfig(k, map[string]interface{}{ + "l1.wallet.password": "", + "l1.wallet.private-key": "", + "l2.dev-wallet.password": "", + "l2.dev-wallet.private-key": "", + }) + if err != nil { + return nil, err + } + } + + // Don't pass around wallet contents with normal configuration + err = cfg.Validate() + if err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/cmd/bidder-client/main.go b/cmd/bidder-client/main.go new file mode 100644 index 0000000000..722717b6bc --- /dev/null +++ b/cmd/bidder-client/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "context" + "errors" + "fmt" + "math/big" + "os" + + flag "github.com/spf13/pflag" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" + + "github.com/offchainlabs/nitro/cmd/util/confighelpers" + "github.com/offchainlabs/nitro/timeboost" +) + +func printSampleUsage(name string) { + fmt.Printf("Sample usage: %s --help \n", name) +} + +func main() { + if err := mainImpl(); err != nil { + log.Error("Error running bidder-client", "err", err) + os.Exit(1) + } + os.Exit(0) +} + +func mainImpl() error { + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + + args := os.Args[1:] + bidderClientConfig, err := parseBidderClientArgs(ctx, args) + if err != nil { + confighelpers.PrintErrorAndExit(err, printSampleUsage) + return err + } + + configFetcher := func() *timeboost.BidderClientConfig { + return bidderClientConfig + } + + bidderClient, err := timeboost.NewBidderClient(ctx, configFetcher) + if err != nil { + return err + } + + if bidderClientConfig.DepositGwei > 0 && bidderClientConfig.BidGwei > 0 { + return errors.New("--deposit-gwei and --bid-gwei can't both be set, either make a deposit or a bid") + } + + if bidderClientConfig.DepositGwei > 0 { + err = bidderClient.Deposit(ctx, big.NewInt(int64(bidderClientConfig.DepositGwei)*1_000_000_000)) + if err == nil { + log.Info("Depsoit successful") + } + return err + } + + if bidderClientConfig.BidGwei > 0 { + bidderClient.Start(ctx) + bid, err := bidderClient.Bid(ctx, big.NewInt(int64(bidderClientConfig.BidGwei)*1_000_000_000), common.Address{}) + if err == nil { + log.Info("Bid submitted successfully", "bid", bid) + } + return err + } + + return errors.New("select one of --deposit-gwei or --bid-gwei") +} + +func parseBidderClientArgs(ctx context.Context, args []string) (*timeboost.BidderClientConfig, error) { + f := flag.NewFlagSet("", flag.ContinueOnError) + + timeboost.BidderClientConfigAddOptions(f) + + k, err := confighelpers.BeginCommonParse(f, args) + if err != nil { + return nil, err + } + + err = confighelpers.ApplyOverrides(f, k) + if err != nil { + return nil, err + } + + var cfg timeboost.BidderClientConfig + if err := confighelpers.EndCommonParse(k, &cfg); err != nil { + return nil, err + } + + return &cfg, nil +} diff --git a/cmd/nitro/nitro.go b/cmd/nitro/nitro.go index a4536e11d0..5e94bb0abb 100644 --- a/cmd/nitro/nitro.go +++ b/cmd/nitro/nitro.go @@ -653,6 +653,16 @@ func mainImpl() int { } } + execNodeConfig := execNode.ConfigFetcher() + if execNodeConfig.Sequencer.Enable && execNodeConfig.Sequencer.Timeboost.Enable { + execNode.Sequencer.StartExpressLane( + ctx, + execNode.Backend.APIBackend(), + execNode.FilterSystem, + common.HexToAddress(execNodeConfig.Sequencer.Timeboost.AuctionContractAddress), + common.HexToAddress(execNodeConfig.Sequencer.Timeboost.AuctioneerAddress)) + } + err = nil select { case err = <-fatalErrChan: diff --git a/contracts b/contracts index b140ed63ac..bec7d629c5 160000 --- a/contracts +++ b/contracts @@ -1 +1 @@ -Subproject commit b140ed63acdb53cb906ffd1fa3c36fdbd474364e +Subproject commit bec7d629c5f4a9dc4ec786e9d6e99734a11d109b diff --git a/execution/gethexec/api.go b/execution/gethexec/api.go index 713d1496f9..a11cc91d86 100644 --- a/execution/gethexec/api.go +++ b/execution/gethexec/api.go @@ -21,6 +21,7 @@ import ( "github.com/offchainlabs/nitro/arbos/arbosState" "github.com/offchainlabs/nitro/arbos/retryables" + "github.com/offchainlabs/nitro/timeboost" "github.com/offchainlabs/nitro/util/arbmath" ) @@ -36,6 +37,34 @@ func (a *ArbAPI) CheckPublisherHealth(ctx context.Context) error { return a.txPublisher.CheckHealth(ctx) } +type ArbTimeboostAuctioneerAPI struct { + txPublisher TransactionPublisher +} + +func NewArbTimeboostAuctioneerAPI(publisher TransactionPublisher) *ArbTimeboostAuctioneerAPI { + return &ArbTimeboostAuctioneerAPI{publisher} +} + +func (a *ArbTimeboostAuctioneerAPI) SubmitAuctionResolutionTransaction(ctx context.Context, tx *types.Transaction) error { + return a.txPublisher.PublishAuctionResolutionTransaction(ctx, tx) +} + +type ArbTimeboostAPI struct { + txPublisher TransactionPublisher +} + +func NewArbTimeboostAPI(publisher TransactionPublisher) *ArbTimeboostAPI { + return &ArbTimeboostAPI{publisher} +} + +func (a *ArbTimeboostAPI) SendExpressLaneTransaction(ctx context.Context, msg *timeboost.JsonExpressLaneSubmission) error { + goMsg, err := timeboost.JsonSubmissionToGo(msg) + if err != nil { + return err + } + return a.txPublisher.PublishExpressLaneTransaction(ctx, goMsg) +} + type ArbDebugAPI struct { blockchain *core.BlockChain blockRangeBound uint64 diff --git a/execution/gethexec/arb_interface.go b/execution/gethexec/arb_interface.go index dbf9c24015..375d650359 100644 --- a/execution/gethexec/arb_interface.go +++ b/execution/gethexec/arb_interface.go @@ -9,9 +9,13 @@ import ( "github.com/ethereum/go-ethereum/arbitrum_types" "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core/types" + + "github.com/offchainlabs/nitro/timeboost" ) type TransactionPublisher interface { + PublishAuctionResolutionTransaction(ctx context.Context, tx *types.Transaction) error + PublishExpressLaneTransaction(ctx context.Context, msg *timeboost.ExpressLaneSubmission) error PublishTransaction(ctx context.Context, tx *types.Transaction, options *arbitrum_types.ConditionalOptions) error CheckHealth(ctx context.Context) error Initialize(context.Context) error @@ -41,6 +45,18 @@ func (a *ArbInterface) PublishTransaction(ctx context.Context, tx *types.Transac return a.txPublisher.PublishTransaction(ctx, tx, options) } +func (a *ArbInterface) PublishExpressLaneTransaction(ctx context.Context, msg *timeboost.JsonExpressLaneSubmission) error { + goMsg, err := timeboost.JsonSubmissionToGo(msg) + if err != nil { + return err + } + return a.txPublisher.PublishExpressLaneTransaction(ctx, goMsg) +} + +func (a *ArbInterface) PublishAuctionResolutionTransaction(ctx context.Context, tx *types.Transaction) error { + return a.txPublisher.PublishAuctionResolutionTransaction(ctx, tx) +} + // might be used before Initialize func (a *ArbInterface) BlockChain() *core.BlockChain { return a.blockchain diff --git a/execution/gethexec/express_lane_service.go b/execution/gethexec/express_lane_service.go new file mode 100644 index 0000000000..a5618ee719 --- /dev/null +++ b/execution/gethexec/express_lane_service.go @@ -0,0 +1,410 @@ +// Copyright 2024-2025, Offchain Labs, Inc. +// For license information, see https://github.com/nitro/blob/master/LICENSE + +package gethexec + +import ( + "context" + "fmt" + "math" + "math/big" + "sync" + "time" + + "github.com/pkg/errors" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/arbitrum" + "github.com/ethereum/go-ethereum/arbitrum_types" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/lru" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/eth/filters" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/offchainlabs/nitro/solgen/go/express_lane_auctiongen" + "github.com/offchainlabs/nitro/timeboost" + "github.com/offchainlabs/nitro/util/arbmath" + "github.com/offchainlabs/nitro/util/stopwaiter" +) + +type expressLaneControl struct { + sequence uint64 + controller common.Address +} + +type transactionPublisher interface { + PublishTimeboostedTransaction(context.Context, *types.Transaction, *arbitrum_types.ConditionalOptions) error +} + +type expressLaneService struct { + stopwaiter.StopWaiter + sync.RWMutex + transactionPublisher transactionPublisher + auctionContractAddr common.Address + apiBackend *arbitrum.APIBackend + initialTimestamp time.Time + roundDuration time.Duration + auctionClosing time.Duration + chainConfig *params.ChainConfig + logs chan []*types.Log + auctionContract *express_lane_auctiongen.ExpressLaneAuction + roundControl *lru.Cache[uint64, *expressLaneControl] // thread safe + messagesBySequenceNumber map[uint64]*timeboost.ExpressLaneSubmission +} + +type contractAdapter struct { + *filters.FilterAPI + bind.ContractTransactor // We leave this member unset as it is not used. + + apiBackend *arbitrum.APIBackend +} + +func (a *contractAdapter) FilterLogs(ctx context.Context, q ethereum.FilterQuery) ([]types.Log, error) { + logPointers, err := a.GetLogs(ctx, filters.FilterCriteria(q)) + if err != nil { + return nil, err + } + logs := make([]types.Log, 0, len(logPointers)) + for _, log := range logPointers { + logs = append(logs, *log) + } + return logs, nil +} + +func (a *contractAdapter) SubscribeFilterLogs(ctx context.Context, q ethereum.FilterQuery, ch chan<- types.Log) (ethereum.Subscription, error) { + panic("contractAdapter doesn't implement SubscribeFilterLogs - shouldn't be needed") +} + +func (a *contractAdapter) CodeAt(ctx context.Context, contract common.Address, blockNumber *big.Int) ([]byte, error) { + panic("contractAdapter doesn't implement CodeAt - shouldn't be needed") +} + +func (a *contractAdapter) CallContract(ctx context.Context, call ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) { + var num rpc.BlockNumber = rpc.LatestBlockNumber + if blockNumber != nil { + num = rpc.BlockNumber(blockNumber.Int64()) + } + + state, header, err := a.apiBackend.StateAndHeaderByNumber(ctx, num) + if err != nil { + return nil, err + } + + msg := &core.Message{ + From: call.From, + To: call.To, + Value: big.NewInt(0), + GasLimit: math.MaxUint64, + GasPrice: big.NewInt(0), + GasFeeCap: big.NewInt(0), + GasTipCap: big.NewInt(0), + Data: call.Data, + AccessList: call.AccessList, + SkipAccountChecks: true, + TxRunMode: core.MessageEthcallMode, // Indicate this is an eth_call + SkipL1Charging: true, // Skip L1 data fees + } + + evm := a.apiBackend.GetEVM(ctx, msg, state, header, &vm.Config{NoBaseFee: true}, nil) + gp := new(core.GasPool).AddGas(math.MaxUint64) + result, err := core.ApplyMessage(evm, msg, gp) + if err != nil { + return nil, err + } + + return result.ReturnData, nil +} + +func newExpressLaneService( + transactionPublisher transactionPublisher, + apiBackend *arbitrum.APIBackend, + filterSystem *filters.FilterSystem, + auctionContractAddr common.Address, + bc *core.BlockChain, +) (*expressLaneService, error) { + chainConfig := bc.Config() + + var contractBackend bind.ContractBackend = &contractAdapter{filters.NewFilterAPI(filterSystem), nil, apiBackend} + + auctionContract, err := express_lane_auctiongen.NewExpressLaneAuction(auctionContractAddr, contractBackend) + if err != nil { + return nil, err + } + + retries := 0 + +pending: + var roundTimingInfo timeboost.RoundTimingInfo + roundTimingInfo, err = auctionContract.RoundTimingInfo(&bind.CallOpts{}) + if err != nil { + const maxRetries = 5 + if errors.Is(err, bind.ErrNoCode) && retries < maxRetries { + wait := time.Millisecond * 250 * (1 << retries) + log.Info("ExpressLaneAuction contract not ready, will retry afer wait", "err", err, "auctionContractAddr", auctionContractAddr, "wait", wait, "maxRetries", maxRetries) + retries++ + time.Sleep(wait) + goto pending + } + return nil, err + } + if err = roundTimingInfo.Validate(nil); err != nil { + return nil, err + } + initialTimestamp := time.Unix(roundTimingInfo.OffsetTimestamp, 0) + roundDuration := arbmath.SaturatingCast[time.Duration](roundTimingInfo.RoundDurationSeconds) * time.Second + auctionClosingDuration := arbmath.SaturatingCast[time.Duration](roundTimingInfo.AuctionClosingSeconds) * time.Second + return &expressLaneService{ + transactionPublisher: transactionPublisher, + auctionContract: auctionContract, + apiBackend: apiBackend, + chainConfig: chainConfig, + initialTimestamp: initialTimestamp, + auctionClosing: auctionClosingDuration, + roundControl: lru.NewCache[uint64, *expressLaneControl](8), // Keep 8 rounds cached. + auctionContractAddr: auctionContractAddr, + roundDuration: roundDuration, + logs: make(chan []*types.Log, 10_000), + messagesBySequenceNumber: make(map[uint64]*timeboost.ExpressLaneSubmission), + }, nil +} + +func (es *expressLaneService) Start(ctxIn context.Context) { + es.StopWaiter.Start(ctxIn, es) + + // Log every new express lane auction round. + es.LaunchThread(func(ctx context.Context) { + log.Info("Watching for new express lane rounds") + waitTime := timeboost.TimeTilNextRound(es.initialTimestamp, es.roundDuration) + // Wait until the next round starts + select { + case <-ctx.Done(): + return + case <-time.After(waitTime): + // First tick happened, now set up regular ticks + } + + ticker := time.NewTicker(es.roundDuration) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case t := <-ticker.C: + round := timeboost.CurrentRound(es.initialTimestamp, es.roundDuration) + log.Info( + "New express lane auction round", + "round", round, + "timestamp", t, + ) + es.Lock() + // Reset the sequence numbers map for the new round. + es.messagesBySequenceNumber = make(map[uint64]*timeboost.ExpressLaneSubmission) + es.Unlock() + } + } + }) + es.LaunchThread(func(ctx context.Context) { + log.Info("Monitoring express lane auction contract") + // Monitor for auction resolutions from the auction manager smart contract + // and set the express lane controller for the upcoming round accordingly. + latestBlock, err := es.apiBackend.HeaderByNumber(ctx, rpc.LatestBlockNumber) + if err != nil { + // TODO: Should not be a crit. + log.Crit("Could not get latest header", "err", err) + } + fromBlock := latestBlock.Number.Uint64() + for { + select { + case <-ctx.Done(): + return + case <-time.After(time.Millisecond * 250): + latestBlock, err := es.apiBackend.HeaderByNumber(ctx, rpc.LatestBlockNumber) + if err != nil { + log.Crit("Could not get latest header", "err", err) + } + toBlock := latestBlock.Number.Uint64() + if fromBlock == toBlock { + continue + } + filterOpts := &bind.FilterOpts{ + Context: ctx, + Start: fromBlock, + End: &toBlock, + } + it, err := es.auctionContract.FilterAuctionResolved(filterOpts, nil, nil, nil) + if err != nil { + log.Error("Could not filter auction resolutions event", "error", err) + continue + } + for it.Next() { + log.Info( + "New express lane controller assigned", + "round", it.Event.Round, + "controller", it.Event.FirstPriceExpressLaneController, + ) + es.roundControl.Add(it.Event.Round, &expressLaneControl{ + controller: it.Event.FirstPriceExpressLaneController, + sequence: 0, + }) + } + setExpressLaneIterator, err := es.auctionContract.FilterSetExpressLaneController(filterOpts, nil, nil, nil) + if err != nil { + log.Error("Could not filter express lane controller transfer event", "error", err) + continue + } + for setExpressLaneIterator.Next() { + round := setExpressLaneIterator.Event.Round + roundInfo, ok := es.roundControl.Get(round) + if !ok { + log.Warn("Could not find round info for express lane controller transfer event", "round", round) + continue + } + prevController := setExpressLaneIterator.Event.PreviousExpressLaneController + if roundInfo.controller != prevController { + log.Warn("New express lane controller did not match previous controller", + "round", round, + "previous", setExpressLaneIterator.Event.PreviousExpressLaneController, + "new", setExpressLaneIterator.Event.NewExpressLaneController) + continue + } + newController := setExpressLaneIterator.Event.NewExpressLaneController + es.roundControl.Add(round, &expressLaneControl{ + controller: newController, + sequence: 0, + }) + } + fromBlock = toBlock + } + } + }) +} + +func (es *expressLaneService) currentRoundHasController() bool { + currRound := timeboost.CurrentRound(es.initialTimestamp, es.roundDuration) + control, ok := es.roundControl.Get(currRound) + if !ok { + return false + } + return control.controller != (common.Address{}) +} + +func (es *expressLaneService) isWithinAuctionCloseWindow(arrivalTime time.Time) bool { + // Calculate the next round start time + elapsedTime := arrivalTime.Sub(es.initialTimestamp) + elapsedRounds := elapsedTime / es.roundDuration + nextRoundStart := es.initialTimestamp.Add((elapsedRounds + 1) * es.roundDuration) + // Calculate the time to the next round + timeToNextRound := nextRoundStart.Sub(arrivalTime) + // Check if the arrival timestamp is within AUCTION_CLOSING_DURATION of TIME_TO_NEXT_ROUND + return timeToNextRound <= es.auctionClosing +} + +// Sequence express lane submission skips validation of the express lane message itself, +// as the core validator logic is handled in `validateExpressLaneTx“ +func (es *expressLaneService) sequenceExpressLaneSubmission( + ctx context.Context, + msg *timeboost.ExpressLaneSubmission, +) error { + // no service lock needed since roundControl is thread-safe + control, ok := es.roundControl.Get(msg.Round) + if !ok { + return timeboost.ErrNoOnchainController + } + + es.Lock() + defer es.Unlock() + // Check if the submission nonce is too low. + if msg.SequenceNumber < control.sequence { + return timeboost.ErrSequenceNumberTooLow + } + // Check if a duplicate submission exists already, and reject if so. + if _, exists := es.messagesBySequenceNumber[msg.SequenceNumber]; exists { + return timeboost.ErrDuplicateSequenceNumber + } + // Log an informational warning if the message's sequence number is in the future. + if msg.SequenceNumber > control.sequence { + log.Info("Received express lane submission with future sequence number", "SequenceNumber", msg.SequenceNumber) + } + // Put into the sequence number map. + es.messagesBySequenceNumber[msg.SequenceNumber] = msg + + for { + // Get the next message in the sequence. + nextMsg, exists := es.messagesBySequenceNumber[control.sequence] + if !exists { + break + } + if err := es.transactionPublisher.PublishTimeboostedTransaction( + ctx, + nextMsg.Transaction, + msg.Options, + ); err != nil { + // If the tx failed, clear it from the sequence map. + delete(es.messagesBySequenceNumber, msg.SequenceNumber) + return err + } + // Increase the global round sequence number. + control.sequence += 1 + } + es.roundControl.Add(msg.Round, control) + return nil +} + +func (es *expressLaneService) validateExpressLaneTx(msg *timeboost.ExpressLaneSubmission) error { + if msg == nil || msg.Transaction == nil || msg.Signature == nil { + return timeboost.ErrMalformedData + } + if msg.ChainId.Cmp(es.chainConfig.ChainID) != 0 { + return errors.Wrapf(timeboost.ErrWrongChainId, "express lane tx chain ID %d does not match current chain ID %d", msg.ChainId, es.chainConfig.ChainID) + } + if msg.AuctionContractAddress != es.auctionContractAddr { + return errors.Wrapf(timeboost.ErrWrongAuctionContract, "msg auction contract address %s does not match sequencer auction contract address %s", msg.AuctionContractAddress, es.auctionContractAddr) + } + currentRound := timeboost.CurrentRound(es.initialTimestamp, es.roundDuration) + if msg.Round != currentRound { + return errors.Wrapf(timeboost.ErrBadRoundNumber, "express lane tx round %d does not match current round %d", msg.Round, currentRound) + } + if !es.currentRoundHasController() { + return timeboost.ErrNoOnchainController + } + // Reconstruct the message being signed over and recover the sender address. + signingMessage, err := msg.ToMessageBytes() + if err != nil { + return timeboost.ErrMalformedData + } + if len(msg.Signature) != 65 { + return errors.Wrap(timeboost.ErrMalformedData, "signature length is not 65") + } + // Recover the public key. + prefixed := crypto.Keccak256(append([]byte(fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(signingMessage))), signingMessage...)) + sigItem := make([]byte, len(msg.Signature)) + copy(sigItem, msg.Signature) + + // Signature verification expects the last byte of the signature to have 27 subtracted, + // as it represents the recovery ID. If the last byte is greater than or equal to 27, it indicates a recovery ID that hasn't been adjusted yet, + // it's needed for internal signature verification logic. + if sigItem[len(sigItem)-1] >= 27 { + sigItem[len(sigItem)-1] -= 27 + } + pubkey, err := crypto.SigToPub(prefixed, sigItem) + if err != nil { + return timeboost.ErrMalformedData + } + sender := crypto.PubkeyToAddress(*pubkey) + control, ok := es.roundControl.Get(msg.Round) + if !ok { + return timeboost.ErrNoOnchainController + } + if sender != control.controller { + return timeboost.ErrNotExpressLaneController + } + return nil +} diff --git a/execution/gethexec/express_lane_service_test.go b/execution/gethexec/express_lane_service_test.go new file mode 100644 index 0000000000..bbb2c3c240 --- /dev/null +++ b/execution/gethexec/express_lane_service_test.go @@ -0,0 +1,529 @@ +// Copyright 2021-2022, Offchain Labs, Inc. +// For license information, see https://github.com/nitro/blob/master/LICENSE + +package gethexec + +import ( + "context" + "crypto/ecdsa" + "errors" + "fmt" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/arbitrum_types" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/lru" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" + + "github.com/offchainlabs/nitro/timeboost" +) + +var testPriv *ecdsa.PrivateKey + +func init() { + privKey, err := crypto.HexToECDSA("93be75cc4df7acbb636b6abe6de2c0446235ac1dc7da9f290a70d83f088b486d") + if err != nil { + panic(err) + } + testPriv = privKey +} + +func Test_expressLaneService_validateExpressLaneTx(t *testing.T) { + tests := []struct { + name string + es *expressLaneService + sub *timeboost.ExpressLaneSubmission + expectedErr error + control expressLaneControl + valid bool + }{ + { + name: "nil msg", + sub: nil, + es: &expressLaneService{ + roundControl: lru.NewCache[uint64, *expressLaneControl](8), + }, + expectedErr: timeboost.ErrMalformedData, + }, + { + name: "nil tx", + sub: &timeboost.ExpressLaneSubmission{}, + es: &expressLaneService{ + roundControl: lru.NewCache[uint64, *expressLaneControl](8), + }, + expectedErr: timeboost.ErrMalformedData, + }, + { + name: "nil sig", + sub: &timeboost.ExpressLaneSubmission{ + Transaction: &types.Transaction{}, + }, + es: &expressLaneService{ + roundControl: lru.NewCache[uint64, *expressLaneControl](8), + }, + expectedErr: timeboost.ErrMalformedData, + }, + { + name: "wrong chain id", + es: &expressLaneService{ + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + }, + roundControl: lru.NewCache[uint64, *expressLaneControl](8), + }, + sub: &timeboost.ExpressLaneSubmission{ + ChainId: big.NewInt(2), + Transaction: &types.Transaction{}, + Signature: []byte{'a'}, + }, + expectedErr: timeboost.ErrWrongChainId, + }, + { + name: "wrong auction contract", + es: &expressLaneService{ + auctionContractAddr: common.Address{'a'}, + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + }, + roundControl: lru.NewCache[uint64, *expressLaneControl](8), + }, + sub: &timeboost.ExpressLaneSubmission{ + ChainId: big.NewInt(1), + AuctionContractAddress: common.Address{'b'}, + Transaction: &types.Transaction{}, + Signature: []byte{'b'}, + }, + expectedErr: timeboost.ErrWrongAuctionContract, + }, + { + name: "no onchain controller", + es: &expressLaneService{ + auctionContractAddr: common.Address{'a'}, + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + }, + roundControl: lru.NewCache[uint64, *expressLaneControl](8), + }, + sub: &timeboost.ExpressLaneSubmission{ + ChainId: big.NewInt(1), + AuctionContractAddress: common.Address{'a'}, + Transaction: &types.Transaction{}, + Signature: []byte{'b'}, + }, + expectedErr: timeboost.ErrNoOnchainController, + }, + { + name: "bad round number", + es: &expressLaneService{ + auctionContractAddr: common.Address{'a'}, + initialTimestamp: time.Now(), + roundDuration: time.Minute, + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + }, + roundControl: lru.NewCache[uint64, *expressLaneControl](8), + }, + control: expressLaneControl{ + controller: common.Address{'b'}, + }, + sub: &timeboost.ExpressLaneSubmission{ + ChainId: big.NewInt(1), + AuctionContractAddress: common.Address{'a'}, + Transaction: &types.Transaction{}, + Signature: []byte{'b'}, + Round: 100, + }, + expectedErr: timeboost.ErrBadRoundNumber, + }, + { + name: "malformed signature", + es: &expressLaneService{ + auctionContractAddr: common.Address{'a'}, + initialTimestamp: time.Now(), + roundDuration: time.Minute, + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + }, + roundControl: lru.NewCache[uint64, *expressLaneControl](8), + }, + control: expressLaneControl{ + controller: common.Address{'b'}, + }, + sub: &timeboost.ExpressLaneSubmission{ + ChainId: big.NewInt(1), + AuctionContractAddress: common.Address{'a'}, + Transaction: types.NewTransaction(0, common.Address{}, big.NewInt(0), 0, big.NewInt(0), nil), + Signature: []byte{'b'}, + Round: 0, + }, + expectedErr: timeboost.ErrMalformedData, + }, + { + name: "wrong signature", + es: &expressLaneService{ + auctionContractAddr: common.HexToAddress("0x2Aef36410182881a4b13664a1E079762D7F716e6"), + initialTimestamp: time.Now(), + roundDuration: time.Minute, + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + }, + roundControl: lru.NewCache[uint64, *expressLaneControl](8), + }, + control: expressLaneControl{ + controller: common.Address{'b'}, + }, + sub: buildInvalidSignatureSubmission(t, common.HexToAddress("0x2Aef36410182881a4b13664a1E079762D7F716e6")), + expectedErr: timeboost.ErrNotExpressLaneController, + }, + { + name: "not express lane controller", + es: &expressLaneService{ + auctionContractAddr: common.HexToAddress("0x2Aef36410182881a4b13664a1E079762D7F716e6"), + initialTimestamp: time.Now(), + roundDuration: time.Minute, + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + }, + roundControl: lru.NewCache[uint64, *expressLaneControl](8), + }, + control: expressLaneControl{ + controller: common.Address{'b'}, + }, + sub: buildValidSubmission(t, common.HexToAddress("0x2Aef36410182881a4b13664a1E079762D7F716e6"), testPriv), + expectedErr: timeboost.ErrNotExpressLaneController, + }, + { + name: "OK", + es: &expressLaneService{ + auctionContractAddr: common.HexToAddress("0x2Aef36410182881a4b13664a1E079762D7F716e6"), + initialTimestamp: time.Now(), + roundDuration: time.Minute, + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + }, + roundControl: lru.NewCache[uint64, *expressLaneControl](8), + }, + control: expressLaneControl{ + controller: crypto.PubkeyToAddress(testPriv.PublicKey), + }, + sub: buildValidSubmission(t, common.HexToAddress("0x2Aef36410182881a4b13664a1E079762D7F716e6"), testPriv), + valid: true, + }, + } + + for _, _tt := range tests { + tt := _tt + t.Run(tt.name, func(t *testing.T) { + if tt.sub != nil { + tt.es.roundControl.Add(tt.sub.Round, &tt.control) + } + err := tt.es.validateExpressLaneTx(tt.sub) + if tt.valid { + require.NoError(t, err) + return + } + require.ErrorIs(t, err, tt.expectedErr) + }) + } +} + +type stubPublisher struct { + els *expressLaneService + publishedTxOrder []uint64 +} + +func makeStubPublisher(els *expressLaneService) *stubPublisher { + return &stubPublisher{ + els: els, + publishedTxOrder: make([]uint64, 0), + } +} + +func (s *stubPublisher) PublishTimeboostedTransaction(parentCtx context.Context, tx *types.Transaction, options *arbitrum_types.ConditionalOptions) error { + if tx == nil { + return errors.New("oops, bad tx") + } + control, _ := s.els.roundControl.Get(0) + s.publishedTxOrder = append(s.publishedTxOrder, control.sequence) + return nil + +} + +func Test_expressLaneService_sequenceExpressLaneSubmission_nonceTooLow(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + els := &expressLaneService{ + messagesBySequenceNumber: make(map[uint64]*timeboost.ExpressLaneSubmission), + roundControl: lru.NewCache[uint64, *expressLaneControl](8), + } + stubPublisher := makeStubPublisher(els) + els.transactionPublisher = stubPublisher + els.roundControl.Add(0, &expressLaneControl{ + sequence: 1, + }) + msg := &timeboost.ExpressLaneSubmission{ + SequenceNumber: 0, + } + + err := els.sequenceExpressLaneSubmission(ctx, msg) + require.ErrorIs(t, err, timeboost.ErrSequenceNumberTooLow) +} + +func Test_expressLaneService_sequenceExpressLaneSubmission_duplicateNonce(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + els := &expressLaneService{ + roundControl: lru.NewCache[uint64, *expressLaneControl](8), + messagesBySequenceNumber: make(map[uint64]*timeboost.ExpressLaneSubmission), + } + stubPublisher := makeStubPublisher(els) + els.transactionPublisher = stubPublisher + els.roundControl.Add(0, &expressLaneControl{ + sequence: 1, + }) + msg := &timeboost.ExpressLaneSubmission{ + SequenceNumber: 2, + } + err := els.sequenceExpressLaneSubmission(ctx, msg) + require.NoError(t, err) + // Because the message is for a future sequence number, it + // should get queued, but not yet published. + require.Equal(t, 0, len(stubPublisher.publishedTxOrder)) + // Sending it again should give us an error. + err = els.sequenceExpressLaneSubmission(ctx, msg) + require.ErrorIs(t, err, timeboost.ErrDuplicateSequenceNumber) +} + +func Test_expressLaneService_sequenceExpressLaneSubmission_outOfOrder(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + els := &expressLaneService{ + roundControl: lru.NewCache[uint64, *expressLaneControl](8), + messagesBySequenceNumber: make(map[uint64]*timeboost.ExpressLaneSubmission), + } + stubPublisher := makeStubPublisher(els) + els.transactionPublisher = stubPublisher + + els.roundControl.Add(0, &expressLaneControl{ + sequence: 1, + }) + + messages := []*timeboost.ExpressLaneSubmission{ + { + SequenceNumber: 10, + Transaction: &types.Transaction{}, + }, + { + SequenceNumber: 5, + Transaction: &types.Transaction{}, + }, + { + SequenceNumber: 1, + Transaction: &types.Transaction{}, + }, + { + SequenceNumber: 4, + Transaction: &types.Transaction{}, + }, + { + SequenceNumber: 2, + Transaction: &types.Transaction{}, + }, + } + for _, msg := range messages { + err := els.sequenceExpressLaneSubmission(ctx, msg) + require.NoError(t, err) + } + // We should have only published 2, as we are missing sequence number 3. + require.Equal(t, 2, len(stubPublisher.publishedTxOrder)) + require.Equal(t, len(messages), len(els.messagesBySequenceNumber)) + + err := els.sequenceExpressLaneSubmission(ctx, &timeboost.ExpressLaneSubmission{SequenceNumber: 3, Transaction: &types.Transaction{}}) + require.NoError(t, err) + require.Equal(t, 5, len(stubPublisher.publishedTxOrder)) +} + +func Test_expressLaneService_sequenceExpressLaneSubmission_erroredTx(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + els := &expressLaneService{ + roundControl: lru.NewCache[uint64, *expressLaneControl](8), + messagesBySequenceNumber: make(map[uint64]*timeboost.ExpressLaneSubmission), + } + els.roundControl.Add(0, &expressLaneControl{ + sequence: 1, + }) + stubPublisher := makeStubPublisher(els) + els.transactionPublisher = stubPublisher + + messages := []*timeboost.ExpressLaneSubmission{ + { + SequenceNumber: 1, + Transaction: &types.Transaction{}, + }, + { + SequenceNumber: 3, + Transaction: &types.Transaction{}, + }, + { + SequenceNumber: 2, + Transaction: nil, + }, + { + SequenceNumber: 2, + Transaction: &types.Transaction{}, + }, + } + for _, msg := range messages { + if msg.Transaction == nil { + err := els.sequenceExpressLaneSubmission(ctx, msg) + require.ErrorContains(t, err, "oops, bad tx") + } else { + err := els.sequenceExpressLaneSubmission(ctx, msg) + require.NoError(t, err) + } + } + // One tx out of the four should have failed, so we should have only published 3. + require.Equal(t, 3, len(stubPublisher.publishedTxOrder)) + require.Equal(t, []uint64{1, 2, 3}, stubPublisher.publishedTxOrder) +} + +func TestIsWithinAuctionCloseWindow(t *testing.T) { + initialTimestamp := time.Date(2024, 8, 8, 15, 0, 0, 0, time.UTC) + roundDuration := 1 * time.Minute + auctionClosing := 15 * time.Second + + es := &expressLaneService{ + initialTimestamp: initialTimestamp, + roundDuration: roundDuration, + auctionClosing: auctionClosing, + } + + tests := []struct { + name string + arrivalTime time.Time + expectedBool bool + }{ + { + name: "Right before auction close window", + arrivalTime: initialTimestamp.Add(44 * time.Second), // 16 seconds left to the next round + expectedBool: false, + }, + { + name: "On the edge of auction close window", + arrivalTime: initialTimestamp.Add(45 * time.Second), // Exactly 15 seconds left to the next round + expectedBool: true, + }, + { + name: "Outside auction close window", + arrivalTime: initialTimestamp.Add(30 * time.Second), // 30 seconds left to the next round + expectedBool: false, + }, + { + name: "Exactly at the next round", + arrivalTime: initialTimestamp.Add(time.Minute), // At the start of the next round + expectedBool: false, + }, + { + name: "Just before the start of the next round", + arrivalTime: initialTimestamp.Add(time.Minute - 1*time.Second), // 1 second left to the next round + expectedBool: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := es.isWithinAuctionCloseWindow(tt.arrivalTime) + if actual != tt.expectedBool { + t.Errorf("isWithinAuctionCloseWindow(%v) = %v; want %v", tt.arrivalTime, actual, tt.expectedBool) + } + }) + } +} + +func Benchmark_expressLaneService_validateExpressLaneTx(b *testing.B) { + b.StopTimer() + addr := crypto.PubkeyToAddress(testPriv.PublicKey) + es := &expressLaneService{ + auctionContractAddr: common.HexToAddress("0x2Aef36410182881a4b13664a1E079762D7F716e6"), + initialTimestamp: time.Now(), + roundDuration: time.Minute, + roundControl: lru.NewCache[uint64, *expressLaneControl](8), + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + }, + } + es.roundControl.Add(0, &expressLaneControl{ + sequence: 1, + controller: addr, + }) + sub := buildValidSubmission(b, common.HexToAddress("0x2Aef36410182881a4b13664a1E079762D7F716e6"), testPriv) + b.StartTimer() + for i := 0; i < b.N; i++ { + err := es.validateExpressLaneTx(sub) + require.NoError(b, err) + } +} + +func buildSignature(privateKey *ecdsa.PrivateKey, data []byte) ([]byte, error) { + prefixedData := crypto.Keccak256(append([]byte(fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(data))), data...)) + signature, err := crypto.Sign(prefixedData, privateKey) + if err != nil { + return nil, err + } + return signature, nil +} + +func buildInvalidSignatureSubmission( + t *testing.T, + auctionContractAddr common.Address, +) *timeboost.ExpressLaneSubmission { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + b := &timeboost.ExpressLaneSubmission{ + ChainId: big.NewInt(1), + AuctionContractAddress: auctionContractAddr, + Transaction: types.NewTransaction(0, common.Address{}, big.NewInt(0), 0, big.NewInt(0), nil), + Signature: make([]byte, 65), + Round: 0, + } + other := &timeboost.ExpressLaneSubmission{ + ChainId: big.NewInt(2), + AuctionContractAddress: auctionContractAddr, + Transaction: types.NewTransaction(320, common.Address{}, big.NewInt(0), 0, big.NewInt(0), nil), + Signature: make([]byte, 65), + Round: 30, + } + otherData, err := other.ToMessageBytes() + require.NoError(t, err) + signature, err := buildSignature(privateKey, otherData) + require.NoError(t, err) + b.Signature = signature + return b +} + +func buildValidSubmission( + t testing.TB, + auctionContractAddr common.Address, + privKey *ecdsa.PrivateKey, +) *timeboost.ExpressLaneSubmission { + b := &timeboost.ExpressLaneSubmission{ + ChainId: big.NewInt(1), + AuctionContractAddress: auctionContractAddr, + Transaction: types.NewTransaction(0, common.Address{}, big.NewInt(0), 0, big.NewInt(0), nil), + Signature: make([]byte, 65), + Round: 0, + } + data, err := b.ToMessageBytes() + require.NoError(t, err) + signature, err := buildSignature(privKey, data) + require.NoError(t, err) + b.Signature = signature + return b +} diff --git a/execution/gethexec/forwarder.go b/execution/gethexec/forwarder.go index 8e64508e6c..e7a829a431 100644 --- a/execution/gethexec/forwarder.go +++ b/execution/gethexec/forwarder.go @@ -23,6 +23,7 @@ import ( "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/rpc" + "github.com/offchainlabs/nitro/timeboost" "github.com/offchainlabs/nitro/util/redisutil" "github.com/offchainlabs/nitro/util/stopwaiter" ) @@ -148,6 +149,50 @@ func (f *TxForwarder) PublishTransaction(inctx context.Context, tx *types.Transa return errors.New("failed to publish transaction to any of the forwarding targets") } +func (f *TxForwarder) PublishExpressLaneTransaction(inctx context.Context, msg *timeboost.ExpressLaneSubmission) error { + if !f.enabled.Load() { + return ErrNoSequencer + } + ctx, cancelFunc := f.ctxWithTimeout() + defer cancelFunc() + for pos, rpcClient := range f.rpcClients { + err := sendExpressLaneTransactionRPC(ctx, rpcClient, msg) + if err == nil || !f.tryNewForwarderErrors.MatchString(err.Error()) { + return err + } + log.Warn("error forwarding transaction to a backup target", "target", f.targets[pos], "err", err) + } + return errors.New("failed to publish transaction to any of the forwarding targets") +} + +func sendExpressLaneTransactionRPC(ctx context.Context, rpcClient *rpc.Client, msg *timeboost.ExpressLaneSubmission) error { + jsonMsg, err := msg.ToJson() + if err != nil { + return err + } + return rpcClient.CallContext(ctx, nil, "timeboost_sendExpressLaneTransaction", jsonMsg) +} + +func (f *TxForwarder) PublishAuctionResolutionTransaction(inctx context.Context, tx *types.Transaction) error { + if !f.enabled.Load() { + return ErrNoSequencer + } + ctx, cancelFunc := f.ctxWithTimeout() + defer cancelFunc() + for pos, rpcClient := range f.rpcClients { + err := sendAuctionResolutionTransactionRPC(ctx, rpcClient, tx) + if err == nil || !f.tryNewForwarderErrors.MatchString(err.Error()) { + return err + } + log.Warn("error forwarding transaction to a backup target", "target", f.targets[pos], "err", err) + } + return errors.New("failed to publish transaction to any of the forwarding targets") +} + +func sendAuctionResolutionTransactionRPC(ctx context.Context, rpcClient *rpc.Client, tx *types.Transaction) error { + return rpcClient.CallContext(ctx, nil, "auctioneer_submitAuctionResolutionTransaction", tx) +} + const cacheUpstreamHealth = 2 * time.Second const maxHealthTimeout = 10 * time.Second @@ -244,6 +289,14 @@ func (f *TxDropper) PublishTransaction(ctx context.Context, tx *types.Transactio return txDropperErr } +func (f *TxDropper) PublishExpressLaneTransaction(ctx context.Context, msg *timeboost.ExpressLaneSubmission) error { + return txDropperErr +} + +func (f *TxDropper) PublishAuctionResolutionTransaction(ctx context.Context, tx *types.Transaction) error { + return txDropperErr +} + func (f *TxDropper) CheckHealth(ctx context.Context) error { return txDropperErr } @@ -287,6 +340,22 @@ func (f *RedisTxForwarder) PublishTransaction(ctx context.Context, tx *types.Tra return forwarder.PublishTransaction(ctx, tx, options) } +func (f *RedisTxForwarder) PublishExpressLaneTransaction(ctx context.Context, msg *timeboost.ExpressLaneSubmission) error { + forwarder := f.getForwarder() + if forwarder == nil { + return ErrNoSequencer + } + return forwarder.PublishExpressLaneTransaction(ctx, msg) +} + +func (f *RedisTxForwarder) PublishAuctionResolutionTransaction(ctx context.Context, tx *types.Transaction) error { + forwarder := f.getForwarder() + if forwarder == nil { + return ErrNoSequencer + } + return forwarder.PublishAuctionResolutionTransaction(ctx, tx) +} + func (f *RedisTxForwarder) CheckHealth(ctx context.Context) error { forwarder := f.getForwarder() if forwarder == nil { diff --git a/execution/gethexec/node.go b/execution/gethexec/node.go index 11d173a21e..bc1d18d1ee 100644 --- a/execution/gethexec/node.go +++ b/execution/gethexec/node.go @@ -270,6 +270,19 @@ func CreateExecutionNode( Service: NewArbAPI(txPublisher), Public: false, }} + apis = append(apis, rpc.API{ + Namespace: "auctioneer", + Version: "1.0", + Service: NewArbTimeboostAuctioneerAPI(txPublisher), + Public: false, + Authenticated: true, // Only exposed via JWT Auth to the auctioneer. + }) + apis = append(apis, rpc.API{ + Namespace: "timeboost", + Version: "1.0", + Service: NewArbTimeboostAPI(txPublisher), + Public: false, + }) apis = append(apis, rpc.API{ Namespace: "arbdebug", Version: "1.0", diff --git a/execution/gethexec/sequencer.go b/execution/gethexec/sequencer.go index 92d440e8cb..c49be51ab5 100644 --- a/execution/gethexec/sequencer.go +++ b/execution/gethexec/sequencer.go @@ -26,6 +26,7 @@ import ( "github.com/ethereum/go-ethereum/core/txpool" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto/kzg4844" + "github.com/ethereum/go-ethereum/eth/filters" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/metrics" "github.com/ethereum/go-ethereum/params" @@ -36,6 +37,7 @@ import ( "github.com/offchainlabs/nitro/arbos/l1pricing" "github.com/offchainlabs/nitro/arbutil" "github.com/offchainlabs/nitro/execution" + "github.com/offchainlabs/nitro/timeboost" "github.com/offchainlabs/nitro/util/arbmath" "github.com/offchainlabs/nitro/util/containers" "github.com/offchainlabs/nitro/util/headerreader" @@ -77,10 +79,25 @@ type SequencerConfig struct { ExpectedSurplusSoftThreshold string `koanf:"expected-surplus-soft-threshold" reload:"hot"` ExpectedSurplusHardThreshold string `koanf:"expected-surplus-hard-threshold" reload:"hot"` EnableProfiling bool `koanf:"enable-profiling" reload:"hot"` + Timeboost TimeboostConfig `koanf:"timeboost"` expectedSurplusSoftThreshold int expectedSurplusHardThreshold int } +type TimeboostConfig struct { + Enable bool `koanf:"enable"` + AuctionContractAddress string `koanf:"auction-contract-address"` + AuctioneerAddress string `koanf:"auctioneer-address"` + ExpressLaneAdvantage time.Duration `koanf:"express-lane-advantage"` +} + +var DefaultTimeboostConfig = TimeboostConfig{ + Enable: false, + AuctionContractAddress: "", + AuctioneerAddress: "", + ExpressLaneAdvantage: time.Millisecond * 200, +} + func (c *SequencerConfig) Validate() error { for _, address := range c.SenderWhitelist { if len(address) == 0 { @@ -107,6 +124,19 @@ func (c *SequencerConfig) Validate() error { if c.MaxTxDataSize > arbostypes.MaxL2MessageSize-50000 { return errors.New("max-tx-data-size too large for MaxL2MessageSize") } + return c.Timeboost.Validate() +} + +func (c *TimeboostConfig) Validate() error { + if !c.Enable { + return nil + } + if len(c.AuctionContractAddress) > 0 && !common.IsHexAddress(c.AuctionContractAddress) { + return fmt.Errorf("invalid timeboost.auction-contract-address \"%v\"", c.AuctionContractAddress) + } + if len(c.AuctioneerAddress) > 0 && !common.IsHexAddress(c.AuctioneerAddress) { + return fmt.Errorf("invalid timeboost.auctioneer-address \"%v\"", c.AuctioneerAddress) + } return nil } @@ -130,6 +160,7 @@ var DefaultSequencerConfig = SequencerConfig{ ExpectedSurplusSoftThreshold: "default", ExpectedSurplusHardThreshold: "default", EnableProfiling: false, + Timeboost: DefaultTimeboostConfig, } func SequencerConfigAddOptions(prefix string, f *flag.FlagSet) { @@ -139,6 +170,8 @@ func SequencerConfigAddOptions(prefix string, f *flag.FlagSet) { f.Duration(prefix+".max-acceptable-timestamp-delta", DefaultSequencerConfig.MaxAcceptableTimestampDelta, "maximum acceptable time difference between the local time and the latest L1 block's timestamp") f.StringSlice(prefix+".sender-whitelist", DefaultSequencerConfig.SenderWhitelist, "comma separated whitelist of authorized senders (if empty, everyone is allowed)") AddOptionsForSequencerForwarderConfig(prefix+".forwarder", f) + TimeboostAddOptions(prefix+".timeboost", f) + f.Int(prefix+".queue-size", DefaultSequencerConfig.QueueSize, "size of the pending tx queue") f.Duration(prefix+".queue-timeout", DefaultSequencerConfig.QueueTimeout, "maximum amount of time transaction can wait in queue") f.Int(prefix+".nonce-cache-size", DefaultSequencerConfig.NonceCacheSize, "size of the tx sender nonce cache") @@ -150,6 +183,13 @@ func SequencerConfigAddOptions(prefix string, f *flag.FlagSet) { f.Bool(prefix+".enable-profiling", DefaultSequencerConfig.EnableProfiling, "enable CPU profiling and tracing") } +func TimeboostAddOptions(prefix string, f *flag.FlagSet) { + f.Bool(prefix+".enable", DefaultTimeboostConfig.Enable, "enable timeboost based on express lane auctions") + f.String(prefix+".auction-contract-address", DefaultTimeboostConfig.AuctionContractAddress, "Address of the proxy pointing to the ExpressLaneAuction contract") + f.String(prefix+".auctioneer-address", DefaultTimeboostConfig.AuctioneerAddress, "Address of the Timeboost Autonomous Auctioneer") + f.Duration(prefix+".express-lane-advantage", DefaultTimeboostConfig.ExpressLaneAdvantage, "specify the express lane advantage") +} + type txQueueItem struct { tx *types.Transaction txSize int // size in bytes of the marshalled transaction @@ -289,18 +329,43 @@ func (c nonceFailureCache) Add(err NonceError, queueItem txQueueItem) { } } +type synchronizedTxQueue struct { + queue containers.Queue[txQueueItem] + mutex sync.RWMutex +} + +func (q *synchronizedTxQueue) Push(item txQueueItem) { + q.mutex.Lock() + q.queue.Push(item) + q.mutex.Unlock() +} + +func (q *synchronizedTxQueue) Pop() txQueueItem { + q.mutex.Lock() + defer q.mutex.Unlock() + return q.queue.Pop() + +} + +func (q *synchronizedTxQueue) Len() int { + q.mutex.RLock() + defer q.mutex.RUnlock() + return q.queue.Len() +} + type Sequencer struct { stopwaiter.StopWaiter - execEngine *ExecutionEngine - txQueue chan txQueueItem - txRetryQueue containers.Queue[txQueueItem] - l1Reader *headerreader.HeaderReader - config SequencerConfigFetcher - senderWhitelist map[common.Address]struct{} - nonceCache *nonceCache - nonceFailures *nonceFailureCache - onForwarderSet chan struct{} + execEngine *ExecutionEngine + txQueue chan txQueueItem + txRetryQueue synchronizedTxQueue + l1Reader *headerreader.HeaderReader + config SequencerConfigFetcher + senderWhitelist map[common.Address]struct{} + nonceCache *nonceCache + nonceFailures *nonceFailureCache + expressLaneService *expressLaneService + onForwarderSet chan struct{} L1BlockAndTimeMutex sync.Mutex l1BlockNumber atomic.Uint64 @@ -313,9 +378,11 @@ type Sequencer struct { pauseChan chan struct{} forwarder *TxForwarder - expectedSurplusMutex sync.RWMutex - expectedSurplus int64 - expectedSurplusUpdated bool + expectedSurplusMutex sync.RWMutex + expectedSurplus int64 + expectedSurplusUpdated bool + auctioneerAddr common.Address + timeboostAuctionResolutionTxQueue chan txQueueItem } func NewSequencer(execEngine *ExecutionEngine, l1Reader *headerreader.HeaderReader, configFetcher SequencerConfigFetcher) (*Sequencer, error) { @@ -331,15 +398,16 @@ func NewSequencer(execEngine *ExecutionEngine, l1Reader *headerreader.HeaderRead senderWhitelist[common.HexToAddress(address)] = struct{}{} } s := &Sequencer{ - execEngine: execEngine, - txQueue: make(chan txQueueItem, config.QueueSize), - l1Reader: l1Reader, - config: configFetcher, - senderWhitelist: senderWhitelist, - nonceCache: newNonceCache(config.NonceCacheSize), - l1Timestamp: 0, - pauseChan: nil, - onForwarderSet: make(chan struct{}, 1), + execEngine: execEngine, + txQueue: make(chan txQueueItem, config.QueueSize), + l1Reader: l1Reader, + config: configFetcher, + senderWhitelist: senderWhitelist, + nonceCache: newNonceCache(config.NonceCacheSize), + l1Timestamp: 0, + pauseChan: nil, + onForwarderSet: make(chan struct{}, 1), + timeboostAuctionResolutionTxQueue: make(chan txQueueItem, 10), // There should never be more than 1 outstanding auction resolutions } s.nonceFailures = &nonceFailureCache{ containers.NewLruCacheWithOnEvict(config.NonceCacheSize, s.onNonceFailureEvict), @@ -387,6 +455,14 @@ func ctxWithTimeout(ctx context.Context, timeout time.Duration) (context.Context } func (s *Sequencer) PublishTransaction(parentCtx context.Context, tx *types.Transaction, options *arbitrum_types.ConditionalOptions) error { + return s.publishTransactionImpl(parentCtx, tx, options, false /* delay tx if express lane is active */) +} + +func (s *Sequencer) PublishTimeboostedTransaction(parentCtx context.Context, tx *types.Transaction, options *arbitrum_types.ConditionalOptions) error { + return s.publishTransactionImpl(parentCtx, tx, options, true) +} + +func (s *Sequencer) publishTransactionImpl(parentCtx context.Context, tx *types.Transaction, options *arbitrum_types.ConditionalOptions, isExpressLaneController bool) error { config := s.config() // Only try to acquire Rlock and check for hard threshold if l1reader is not nil // And hard threshold was enabled, this prevents spamming of read locks when not needed @@ -431,6 +507,12 @@ func (s *Sequencer) PublishTransaction(parentCtx context.Context, tx *types.Tran return err } + if s.config().Timeboost.Enable && s.expressLaneService != nil { + if !isExpressLaneController && s.expressLaneService.currentRoundHasController() { + time.Sleep(s.config().Timeboost.ExpressLaneAdvantage) + } + } + queueTimeout := config.QueueTimeout queueCtx, cancelFunc := ctxWithTimeout(parentCtx, queueTimeout) defer cancelFunc() @@ -470,6 +552,62 @@ func (s *Sequencer) PublishTransaction(parentCtx context.Context, tx *types.Tran } } +func (s *Sequencer) PublishExpressLaneTransaction(ctx context.Context, msg *timeboost.ExpressLaneSubmission) error { + if !s.config().Timeboost.Enable { + return errors.New("timeboost not enabled") + } + if s.expressLaneService == nil { + return errors.New("express lane service not enabled") + } + if err := s.expressLaneService.validateExpressLaneTx(msg); err != nil { + return err + } + return s.expressLaneService.sequenceExpressLaneSubmission(ctx, msg) +} + +func (s *Sequencer) PublishAuctionResolutionTransaction(ctx context.Context, tx *types.Transaction) error { + if !s.config().Timeboost.Enable { + return errors.New("timeboost not enabled") + } + arrivalTime := time.Now() + auctioneerAddr := s.auctioneerAddr + if auctioneerAddr == (common.Address{}) { + return errors.New("invalid auctioneer address") + } + if tx.To() == nil { + return errors.New("transaction has no recipient") + } + if *tx.To() != s.expressLaneService.auctionContractAddr { + return errors.New("transaction recipient is not the auction contract") + } + signer := types.LatestSigner(s.execEngine.bc.Config()) + sender, err := types.Sender(signer, tx) + if err != nil { + return err + } + if sender != auctioneerAddr { + return fmt.Errorf("sender %#x is not the auctioneer address %#x", sender, auctioneerAddr) + } + if !s.expressLaneService.isWithinAuctionCloseWindow(arrivalTime) { + return fmt.Errorf("transaction arrival time not within auction closure window: %v", arrivalTime) + } + txBytes, err := tx.MarshalBinary() + if err != nil { + return err + } + log.Info("Prioritizing auction resolution transaction from auctioneer", "txHash", tx.Hash().Hex()) + s.timeboostAuctionResolutionTxQueue <- txQueueItem{ + tx: tx, + txSize: len(txBytes), + options: nil, + resultChan: make(chan error, 1), + returnedResult: &atomic.Bool{}, + ctx: context.TODO(), + firstAppearance: time.Now(), + } + return nil +} + func (s *Sequencer) preTxFilter(_ *params.ChainConfig, header *types.Header, statedb *state.StateDB, _ *arbosState.ArbosState, tx *types.Transaction, options *arbitrum_types.ConditionalOptions, sender common.Address, l1Info *arbos.L1Info) error { if s.nonceCache.Caching() { stateNonce := s.nonceCache.Get(header, statedb, sender) @@ -794,7 +932,12 @@ func (s *Sequencer) createBlock(ctx context.Context) (returnValue bool) { for { var queueItem txQueueItem + if s.txRetryQueue.Len() > 0 { + // The txRetryQueue is not modeled as a channel because it is only added to from + // this function (Sequencer.createBlock). So it is sufficient to check its + // len at the start of this loop, since items can't be added to it asynchronously, + // which is not true for the main txQueue or timeboostAuctionResolutionQueue. queueItem = s.txRetryQueue.Pop() } else if len(queueItems) == 0 { var nextNonceExpiryChan <-chan time.Time @@ -803,6 +946,8 @@ func (s *Sequencer) createBlock(ctx context.Context) (returnValue bool) { } select { case queueItem = <-s.txQueue: + case queueItem = <-s.timeboostAuctionResolutionTxQueue: + log.Info("Popped the auction resolution tx", "txHash", queueItem.tx.Hash()) case <-nextNonceExpiryChan: // No need to stop the previous timer since it already elapsed nextNonceExpiryTimer = s.expireNonceFailures() @@ -821,6 +966,8 @@ func (s *Sequencer) createBlock(ctx context.Context) (returnValue bool) { done := false select { case queueItem = <-s.txQueue: + case queueItem = <-s.timeboostAuctionResolutionTxQueue: + log.Info("Popped the auction resolution tx", "txHash", queueItem.tx.Hash()) default: done = true } @@ -1112,7 +1259,6 @@ func (s *Sequencer) Start(ctxIn context.Context) error { } } }) - } s.CallIteratively(func(ctx context.Context) time.Duration { @@ -1128,13 +1274,43 @@ func (s *Sequencer) Start(ctxIn context.Context) error { return nil } +func (s *Sequencer) StartExpressLane(ctx context.Context, apiBackend *arbitrum.APIBackend, filterSystem *filters.FilterSystem, auctionContractAddr common.Address, auctioneerAddr common.Address) { + if !s.config().Timeboost.Enable { + log.Crit("Timeboost is not enabled, but StartExpressLane was called") + } + + els, err := newExpressLaneService( + s, + apiBackend, + filterSystem, + auctionContractAddr, + s.execEngine.bc, + ) + if err != nil { + log.Crit("Failed to create express lane service", "err", err, "auctionContractAddr", auctionContractAddr) + } + s.auctioneerAddr = auctioneerAddr + s.expressLaneService = els + s.expressLaneService.Start(ctx) +} + func (s *Sequencer) StopAndWait() { s.StopWaiter.StopAndWait() - if s.txRetryQueue.Len() == 0 && len(s.txQueue) == 0 && s.nonceFailures.Len() == 0 { + if s.config().Timeboost.Enable && s.expressLaneService != nil { + s.expressLaneService.StopAndWait() + } + if s.txRetryQueue.Len() == 0 && + len(s.txQueue) == 0 && + s.nonceFailures.Len() == 0 && + len(s.timeboostAuctionResolutionTxQueue) == 0 { return } // this usually means that coordinator's safe-shutdown-delay is too low - log.Warn("Sequencer has queued items while shutting down", "txQueue", len(s.txQueue), "retryQueue", s.txRetryQueue.Len(), "nonceFailures", s.nonceFailures.Len()) + log.Warn("Sequencer has queued items while shutting down", + "txQueue", len(s.txQueue), + "retryQueue", s.txRetryQueue.Len(), + "nonceFailures", s.nonceFailures.Len(), + "timeboostAuctionResolutionTxQueue", len(s.timeboostAuctionResolutionTxQueue)) _, forwarder := s.GetPauseAndForwarder() if forwarder != nil { var wg sync.WaitGroup @@ -1155,6 +1331,8 @@ func (s *Sequencer) StopAndWait() { select { case item = <-s.txQueue: source = "txQueue" + case item = <-s.timeboostAuctionResolutionTxQueue: + source = "timeboostAuctionResolutionTxQueue" default: break emptyqueues } diff --git a/execution/gethexec/tx_pre_checker.go b/execution/gethexec/tx_pre_checker.go index e7ef20bae9..4c1270fa0d 100644 --- a/execution/gethexec/tx_pre_checker.go +++ b/execution/gethexec/tx_pre_checker.go @@ -20,6 +20,7 @@ import ( "github.com/offchainlabs/nitro/arbos/arbosState" "github.com/offchainlabs/nitro/arbos/l1pricing" + "github.com/offchainlabs/nitro/timeboost" "github.com/offchainlabs/nitro/util/arbmath" "github.com/offchainlabs/nitro/util/headerreader" ) @@ -224,3 +225,40 @@ func (c *TxPreChecker) PublishTransaction(ctx context.Context, tx *types.Transac } return c.TransactionPublisher.PublishTransaction(ctx, tx, options) } + +func (c *TxPreChecker) PublishExpressLaneTransaction(ctx context.Context, msg *timeboost.ExpressLaneSubmission) error { + if msg == nil || msg.Transaction == nil { + return timeboost.ErrMalformedData + } + block := c.bc.CurrentBlock() + statedb, err := c.bc.StateAt(block.Root) + if err != nil { + return err + } + arbos, err := arbosState.OpenSystemArbosState(statedb, nil, true) + if err != nil { + return err + } + err = PreCheckTx(c.bc, c.bc.Config(), block, statedb, arbos, msg.Transaction, msg.Options, c.config()) + if err != nil { + return err + } + return c.TransactionPublisher.PublishExpressLaneTransaction(ctx, msg) +} + +func (c *TxPreChecker) PublishAuctionResolutionTransaction(ctx context.Context, tx *types.Transaction) error { + block := c.bc.CurrentBlock() + statedb, err := c.bc.StateAt(block.Root) + if err != nil { + return err + } + arbos, err := arbosState.OpenSystemArbosState(statedb, nil, true) + if err != nil { + return err + } + err = PreCheckTx(c.bc, c.bc.Config(), block, statedb, arbos, tx, nil, c.config()) + if err != nil { + return err + } + return c.TransactionPublisher.PublishAuctionResolutionTransaction(ctx, tx) +} diff --git a/go-ethereum b/go-ethereum index 46fee83ed9..ed53c04acc 160000 --- a/go-ethereum +++ b/go-ethereum @@ -1 +1 @@ -Subproject commit 46fee83ed96f765f16a39b0a2733190c67294e27 +Subproject commit ed53c04acc1637bbe1e07725fff82066c6687512 diff --git a/go.mod b/go.mod index 34b04121f0..2ef5cef441 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ replace github.com/ethereum/go-ethereum => ./go-ethereum require ( cloud.google.com/go/storage v1.43.0 + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible github.com/Shopify/toxiproxy v2.1.4+incompatible github.com/alicebob/miniredis/v2 v2.32.1 @@ -28,22 +29,27 @@ require ( github.com/gobwas/httphead v0.1.0 github.com/gobwas/ws v1.2.1 github.com/gobwas/ws-examples v0.0.0-20190625122829-a9e8908d9484 + github.com/golang-jwt/jwt/v4 v4.5.0 github.com/google/btree v1.1.2 github.com/google/go-cmp v0.6.0 github.com/google/uuid v1.6.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/holiman/uint256 v1.2.4 + github.com/jmoiron/sqlx v1.4.0 github.com/knadh/koanf v1.4.0 github.com/mailru/easygo v0.0.0-20190618140210-3c14a0dc985f + github.com/mattn/go-sqlite3 v1.14.22 github.com/mitchellh/mapstructure v1.4.1 github.com/pkg/errors v0.9.1 github.com/r3labs/diff/v3 v3.0.1 github.com/redis/go-redis/v9 v9.6.1 github.com/rivo/tview v0.0.0-20240307173318-e804876934a1 github.com/spf13/pflag v1.0.5 + github.com/stretchr/testify v1.9.0 github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 github.com/wealdtech/go-merkletree v1.0.0 golang.org/x/crypto v0.24.0 + golang.org/x/sync v0.7.0 golang.org/x/sys v0.21.0 golang.org/x/term v0.21.0 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d @@ -60,19 +66,28 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/glog v1.2.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/s2a-go v0.1.7 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.12.5 // indirect + github.com/onsi/ginkgo v1.16.5 // indirect + github.com/onsi/gomega v1.18.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect go.opentelemetry.io/otel v1.24.0 // indirect go.opentelemetry.io/otel/metric v1.24.0 // indirect go.opentelemetry.io/otel/trace v1.24.0 // indirect golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect + golang.org/x/text v0.16.0 // indirect + golang.org/x/time v0.5.0 // indirect google.golang.org/genproto v0.0.0-20240624140628-dc46fd24d27d // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240624140628-dc46fd24d27d // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect google.golang.org/grpc v1.64.1 // indirect ) @@ -129,10 +144,6 @@ require ( github.com/gobwas/pool v0.2.1 // indirect github.com/gofrs/flock v0.8.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v4 v4.5.0 // indirect - github.com/golang/glog v1.2.0 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb // indirect github.com/google/flatbuffers v1.12.1 // indirect github.com/google/go-github/v62 v62.0.0 @@ -161,6 +172,7 @@ require ( github.com/mmcloughlin/addchain v0.4.0 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/opentracing/opentracing-go v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.14.0 // indirect github.com/prometheus/client_model v0.4.0 // indirect github.com/prometheus/common v0.37.0 // indirect @@ -185,9 +197,5 @@ require ( golang.org/x/mod v0.17.0 // indirect golang.org/x/net v0.26.0 // indirect golang.org/x/oauth2 v0.22.0 - golang.org/x/sync v0.7.0 - golang.org/x/text v0.16.0 // indirect - golang.org/x/time v0.5.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect rsc.io/tmplfunc v0.0.3 // indirect ) diff --git a/go.sum b/go.sum index bbb38af6ac..3f03f6b95b 100644 --- a/go.sum +++ b/go.sum @@ -45,10 +45,14 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9 cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs= cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible h1:1G1pk05UrOh0NlF1oeaaix1x8XzrfjIDK47TY0Zehcw= @@ -262,7 +266,10 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= @@ -355,6 +362,7 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/google/pprof v0.0.0-20231023181126-ff6d637d2a7b h1:RMpPgZTSApbPf7xaVel+QkoGPRLFLrwFO89uDUHEGf0= github.com/google/pprof v0.0.0-20231023181126-ff6d637d2a7b/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= @@ -413,11 +421,14 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= @@ -443,6 +454,7 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/knadh/koanf v1.4.0 h1:/k0Bh49SqLyLNfte9r6cvuZWrApOQhglOmhIU3L/zDw= @@ -463,6 +475,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leanovate/gopter v0.2.9 h1:fQjYxZaynp97ozCzfOyOuAGOU4aU/z37zf/tOujFk7c= github.com/leanovate/gopter v0.2.9/go.mod h1:U2L/78B+KVFIx2VmW6onHJQzXtFb+p5y3y2Sh+Jxxv8= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easygo v0.0.0-20190618140210-3c14a0dc985f h1:4+gHs0jJFJ06bfN8PshnM6cHcxGjRUVRLo5jndDiKRQ= @@ -477,6 +491,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= @@ -508,18 +524,24 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk= -github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.14.0 h1:2mOpI4JVVPBN+WQRa0WKH2eXR+Ey+uK4n7Zj0aYpIQA= github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= @@ -600,6 +622,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -734,6 +757,7 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= @@ -809,6 +833,7 @@ golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -895,6 +920,7 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= @@ -903,7 +929,6 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= diff --git a/system_tests/express_lane_timeboost_test.go b/system_tests/express_lane_timeboost_test.go new file mode 100644 index 0000000000..88bc2cced1 --- /dev/null +++ b/system_tests/express_lane_timeboost_test.go @@ -0,0 +1,15 @@ +package arbtest + +import ( + "context" + "testing" + + "github.com/offchainlabs/nitro/util/redisutil" +) + +func TestBidValidatorAuctioneerRedisStream(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + redisURL := redisutil.CreateTestRedis(ctx, t) + _ = redisURL +} diff --git a/system_tests/timeboost_test.go b/system_tests/timeboost_test.go new file mode 100644 index 0000000000..7a30fccd95 --- /dev/null +++ b/system_tests/timeboost_test.go @@ -0,0 +1,726 @@ +package arbtest + +import ( + "context" + "crypto/ecdsa" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/crypto/secp256k1" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/node" + "github.com/ethereum/go-ethereum/p2p" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/offchainlabs/nitro/arbnode" + "github.com/offchainlabs/nitro/cmd/genericconf" + "github.com/offchainlabs/nitro/execution/gethexec" + "github.com/offchainlabs/nitro/pubsub" + "github.com/offchainlabs/nitro/solgen/go/express_lane_auctiongen" + "github.com/offchainlabs/nitro/solgen/go/mocksgen" + "github.com/offchainlabs/nitro/timeboost" + "github.com/offchainlabs/nitro/timeboost/bindings" + "github.com/offchainlabs/nitro/util/arbmath" + "github.com/offchainlabs/nitro/util/containers" + "github.com/offchainlabs/nitro/util/redisutil" + "github.com/offchainlabs/nitro/util/stopwaiter" +) + +func TestSequencerFeed_ExpressLaneAuction_ExpressLaneTxsHaveAdvantage(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tmpDir, err := os.MkdirTemp("", "*") + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, os.RemoveAll(tmpDir)) + }) + jwtSecretPath := filepath.Join(tmpDir, "sequencer.jwt") + + seq, seqClient, seqInfo, auctionContractAddr, cleanupSeq := setupExpressLaneAuction(t, tmpDir, ctx, jwtSecretPath) + defer cleanupSeq() + chainId, err := seqClient.ChainID(ctx) + Require(t, err) + + auctionContract, err := express_lane_auctiongen.NewExpressLaneAuction(auctionContractAddr, seqClient) + Require(t, err) + info, err := auctionContract.RoundTimingInfo(&bind.CallOpts{}) + Require(t, err) + bobPriv := seqInfo.Accounts["Bob"].PrivateKey + + // Prepare a client that can submit txs to the sequencer via the express lane. + seqDial, err := rpc.Dial(seq.Stack.HTTPEndpoint()) + Require(t, err) + expressLaneClient := newExpressLaneClient( + bobPriv, + chainId, + time.Unix(info.OffsetTimestamp, 0), + arbmath.SaturatingCast[time.Duration](info.RoundDurationSeconds)*time.Second, + auctionContractAddr, + seqDial, + ) + expressLaneClient.Start(ctx) + + // During the express lane around, Bob sends txs always 150ms later than Alice, but Alice's + // txs end up getting delayed by 200ms as she is not the express lane controller. + // In the end, Bob's txs should be ordered before Alice's during the round. + var wg sync.WaitGroup + wg.Add(2) + ownerAddr := seqInfo.GetAddress("Owner") + aliceData := &types.DynamicFeeTx{ + To: &ownerAddr, + Gas: seqInfo.TransferGas, + GasFeeCap: new(big.Int).Set(seqInfo.GasPrice), + Value: big.NewInt(1e12), + Nonce: 3, + Data: nil, + } + aliceTx := seqInfo.SignTxAs("Alice", aliceData) + go func(w *sync.WaitGroup) { + defer w.Done() + err = seqClient.SendTransaction(ctx, aliceTx) + Require(t, err) + }(&wg) + + bobData := &types.DynamicFeeTx{ + To: &ownerAddr, + Gas: seqInfo.TransferGas, + GasFeeCap: new(big.Int).Set(seqInfo.GasPrice), + Value: big.NewInt(1e12), + Nonce: 3, + Data: nil, + } + bobBoostableTx := seqInfo.SignTxAs("Bob", bobData) + go func(w *sync.WaitGroup) { + defer w.Done() + time.Sleep(time.Millisecond * 10) + err = expressLaneClient.SendTransaction(ctx, bobBoostableTx) + Require(t, err) + }(&wg) + wg.Wait() + + // After round is done, verify that Bob beats Alice in the final sequence. + aliceReceipt, err := seqClient.TransactionReceipt(ctx, aliceTx.Hash()) + Require(t, err) + aliceBlock := aliceReceipt.BlockNumber.Uint64() + bobReceipt, err := seqClient.TransactionReceipt(ctx, bobBoostableTx.Hash()) + Require(t, err) + bobBlock := bobReceipt.BlockNumber.Uint64() + + if aliceBlock < bobBlock { + t.Fatal("Alice's tx should not have been sequenced before Bob's in different blocks") + } else if aliceBlock == bobBlock { + if aliceReceipt.TransactionIndex < bobReceipt.TransactionIndex { + t.Fatal("Bob should have been sequenced before Alice with express lane") + } + } +} + +func TestSequencerFeed_ExpressLaneAuction_InnerPayloadNoncesAreRespected(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tmpDir, err := os.MkdirTemp("", "*") + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, os.RemoveAll(tmpDir)) + }) + jwtSecretPath := filepath.Join(tmpDir, "sequencer.jwt") + seq, seqClient, seqInfo, auctionContractAddr, cleanupSeq := setupExpressLaneAuction(t, tmpDir, ctx, jwtSecretPath) + defer cleanupSeq() + chainId, err := seqClient.ChainID(ctx) + Require(t, err) + + auctionContract, err := express_lane_auctiongen.NewExpressLaneAuction(auctionContractAddr, seqClient) + Require(t, err) + info, err := auctionContract.RoundTimingInfo(&bind.CallOpts{}) + Require(t, err) + bobPriv := seqInfo.Accounts["Bob"].PrivateKey + + // Prepare a client that can submit txs to the sequencer via the express lane. + seqDial, err := rpc.Dial(seq.Stack.HTTPEndpoint()) + Require(t, err) + expressLaneClient := newExpressLaneClient( + bobPriv, + chainId, + time.Unix(int64(info.OffsetTimestamp), 0), + arbmath.SaturatingCast[time.Duration](info.RoundDurationSeconds)*time.Second, + auctionContractAddr, + seqDial, + ) + expressLaneClient.Start(ctx) + + // We first generate an account for Charlie and transfer some balance to him. + seqInfo.GenerateAccount("Charlie") + TransferBalance(t, "Owner", "Charlie", arbmath.BigMulByUint(oneEth, 500), seqInfo, seqClient, ctx) + + // During the express lane, Bob sends txs that do not belong to him, but he is the express lane controller so they + // will go through the express lane. + // These tx payloads are sent with nonces out of order, and those with nonces too high should fail. + var wg sync.WaitGroup + wg.Add(2) + ownerAddr := seqInfo.GetAddress("Owner") + aliceData := &types.DynamicFeeTx{ + To: &ownerAddr, + Gas: seqInfo.TransferGas, + GasFeeCap: new(big.Int).Set(seqInfo.GasPrice), + Value: big.NewInt(1e12), + Nonce: 3, + Data: nil, + } + aliceTx := seqInfo.SignTxAs("Alice", aliceData) + go func(w *sync.WaitGroup) { + defer w.Done() + err = seqClient.SendTransaction(ctx, aliceTx) + Require(t, err) + }(&wg) + + txData := &types.DynamicFeeTx{ + To: &ownerAddr, + Gas: seqInfo.TransferGas, + Value: big.NewInt(1e12), + Nonce: 1, + GasFeeCap: aliceTx.GasFeeCap(), + Data: nil, + } + charlie1 := seqInfo.SignTxAs("Charlie", txData) + txData = &types.DynamicFeeTx{ + To: &ownerAddr, + Gas: seqInfo.TransferGas, + Value: big.NewInt(1e12), + Nonce: 0, + GasFeeCap: aliceTx.GasFeeCap(), + Data: nil, + } + charlie0 := seqInfo.SignTxAs("Charlie", txData) + var err2 error + go func(w *sync.WaitGroup) { + defer w.Done() + time.Sleep(time.Millisecond * 10) + // Send the express lane txs with nonces out of order + err2 = expressLaneClient.SendTransaction(ctx, charlie1) + err = expressLaneClient.SendTransaction(ctx, charlie0) + Require(t, err) + }(&wg) + wg.Wait() + if err2 == nil { + t.Fatal("Charlie should not be able to send tx with nonce 1") + } + // After round is done, verify that Charlie beats Alice in the final sequence, and that the emitted txs + // for Charlie are correct. + aliceReceipt, err := seqClient.TransactionReceipt(ctx, aliceTx.Hash()) + Require(t, err) + aliceBlock := aliceReceipt.BlockNumber.Uint64() + charlieReceipt, err := seqClient.TransactionReceipt(ctx, charlie0.Hash()) + Require(t, err) + charlieBlock := charlieReceipt.BlockNumber.Uint64() + + if aliceBlock < charlieBlock { + t.Fatal("Alice's tx should not have been sequenced before Charlie's in different blocks") + } else if aliceBlock == charlieBlock { + if aliceReceipt.TransactionIndex < charlieReceipt.TransactionIndex { + t.Fatal("Charlie should have been sequenced before Alice with express lane") + } + } +} + +func setupExpressLaneAuction( + t *testing.T, + dbDirPath string, + ctx context.Context, + jwtSecretPath string, +) (*arbnode.Node, *ethclient.Client, *BlockchainTestInfo, common.Address, func()) { + + builderSeq := NewNodeBuilder(ctx).DefaultConfig(t, true) + + seqPort := getRandomPort(t) + seqAuthPort := getRandomPort(t) + builderSeq.l2StackConfig.HTTPHost = "localhost" + builderSeq.l2StackConfig.HTTPPort = seqPort + builderSeq.l2StackConfig.HTTPModules = []string{"eth", "arb", "debug", "timeboost"} + builderSeq.l2StackConfig.AuthPort = seqAuthPort + builderSeq.l2StackConfig.AuthModules = []string{"eth", "arb", "debug", "timeboost", "auctioneer"} + builderSeq.l2StackConfig.JWTSecret = jwtSecretPath + builderSeq.nodeConfig.Feed.Output = *newBroadcasterConfigTest() + builderSeq.execConfig.Sequencer.Enable = true + builderSeq.execConfig.Sequencer.Timeboost = gethexec.TimeboostConfig{ + Enable: false, // We need to start without timeboost initially to create the auction contract + ExpressLaneAdvantage: time.Second * 5, + } + cleanupSeq := builderSeq.Build(t) + seqInfo, seqNode, seqClient := builderSeq.L2Info, builderSeq.L2.ConsensusNode, builderSeq.L2.Client + + // Send an L2 tx in the background every two seconds to keep the chain moving. + go func() { + tick := time.NewTicker(time.Second * 2) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + tx := seqInfo.PrepareTx("Owner", "Owner", seqInfo.TransferGas, big.NewInt(1), nil) + err := seqClient.SendTransaction(ctx, tx) + t.Log("Failed to send test tx", err) + } + } + }() + + // Set up the auction contracts on L2. + // Deploy the express lane auction contract and erc20 to the parent chain. + ownerOpts := seqInfo.GetDefaultTransactOpts("Owner", ctx) + erc20Addr, tx, erc20, err := bindings.DeployMockERC20(&ownerOpts, seqClient) + Require(t, err) + if _, err = bind.WaitMined(ctx, seqClient, tx); err != nil { + t.Fatal(err) + } + tx, err = erc20.Initialize(&ownerOpts, "LANE", "LNE", 18) + Require(t, err) + if _, err = bind.WaitMined(ctx, seqClient, tx); err != nil { + t.Fatal(err) + } + + // Fund the auction contract. + seqInfo.GenerateAccount("AuctionContract") + TransferBalance(t, "Owner", "AuctionContract", arbmath.BigMulByUint(oneEth, 500), seqInfo, seqClient, ctx) + + // Mint some tokens to Alice and Bob. + seqInfo.GenerateAccount("Alice") + seqInfo.GenerateAccount("Bob") + TransferBalance(t, "Faucet", "Alice", arbmath.BigMulByUint(oneEth, 500), seqInfo, seqClient, ctx) + TransferBalance(t, "Faucet", "Bob", arbmath.BigMulByUint(oneEth, 500), seqInfo, seqClient, ctx) + aliceOpts := seqInfo.GetDefaultTransactOpts("Alice", ctx) + bobOpts := seqInfo.GetDefaultTransactOpts("Bob", ctx) + tx, err = erc20.Mint(&ownerOpts, aliceOpts.From, big.NewInt(100)) + Require(t, err) + if _, err = bind.WaitMined(ctx, seqClient, tx); err != nil { + t.Fatal(err) + } + tx, err = erc20.Mint(&ownerOpts, bobOpts.From, big.NewInt(100)) + Require(t, err) + if _, err = bind.WaitMined(ctx, seqClient, tx); err != nil { + t.Fatal(err) + } + + // Calculate the number of seconds until the next minute + // and the next timestamp that is a multiple of a minute. + now := time.Now() + roundDuration := time.Minute + // Correctly calculate the remaining time until the next minute + waitTime := roundDuration - time.Duration(now.Second())*time.Second - time.Duration(now.Nanosecond())*time.Nanosecond + // Get the current Unix timestamp at the start of the minute + initialTimestamp := big.NewInt(now.Add(waitTime).Unix()) + initialTimestampUnix := time.Unix(initialTimestamp.Int64(), 0) + + // Deploy the auction manager contract. + auctionContractAddr, tx, _, err := express_lane_auctiongen.DeployExpressLaneAuction(&ownerOpts, seqClient) + Require(t, err) + if _, err = bind.WaitMined(ctx, seqClient, tx); err != nil { + t.Fatal(err) + } + + proxyAddr, tx, _, err := mocksgen.DeploySimpleProxy(&ownerOpts, seqClient, auctionContractAddr) + Require(t, err) + if _, err = bind.WaitMined(ctx, seqClient, tx); err != nil { + t.Fatal(err) + } + auctionContract, err := express_lane_auctiongen.NewExpressLaneAuction(proxyAddr, seqClient) + Require(t, err) + + auctioneerAddr := seqInfo.GetDefaultTransactOpts("AuctionContract", ctx).From + beneficiary := auctioneerAddr + biddingToken := erc20Addr + bidRoundSeconds := uint64(60) + auctionClosingSeconds := uint64(15) + reserveSubmissionSeconds := uint64(15) + minReservePrice := big.NewInt(1) // 1 wei. + roleAdmin := auctioneerAddr + tx, err = auctionContract.Initialize( + &ownerOpts, + express_lane_auctiongen.InitArgs{ + Auctioneer: auctioneerAddr, + BiddingToken: biddingToken, + Beneficiary: beneficiary, + RoundTimingInfo: express_lane_auctiongen.RoundTimingInfo{ + OffsetTimestamp: initialTimestamp.Int64(), + RoundDurationSeconds: bidRoundSeconds, + AuctionClosingSeconds: auctionClosingSeconds, + ReserveSubmissionSeconds: reserveSubmissionSeconds, + }, + MinReservePrice: minReservePrice, + AuctioneerAdmin: roleAdmin, + MinReservePriceSetter: roleAdmin, + ReservePriceSetter: roleAdmin, + BeneficiarySetter: roleAdmin, + RoundTimingSetter: roleAdmin, + MasterAdmin: roleAdmin, + }, + ) + Require(t, err) + if _, err = bind.WaitMined(ctx, seqClient, tx); err != nil { + t.Fatal(err) + } + t.Log("Deployed all the auction manager stuff", auctionContractAddr) + // We approve the spending of the erc20 for the autonomous auction contract and bid receiver + // for both Alice and Bob. + bidReceiverAddr := common.HexToAddress("0x2424242424242424242424242424242424242424") + maxUint256 := big.NewInt(1) + maxUint256.Lsh(maxUint256, 256).Sub(maxUint256, big.NewInt(1)) + + tx, err = erc20.Approve( + &aliceOpts, proxyAddr, maxUint256, + ) + Require(t, err) + if _, err = bind.WaitMined(ctx, seqClient, tx); err != nil { + t.Fatal(err) + } + tx, err = erc20.Approve( + &aliceOpts, bidReceiverAddr, maxUint256, + ) + Require(t, err) + if _, err = bind.WaitMined(ctx, seqClient, tx); err != nil { + t.Fatal(err) + } + tx, err = erc20.Approve( + &bobOpts, proxyAddr, maxUint256, + ) + Require(t, err) + if _, err = bind.WaitMined(ctx, seqClient, tx); err != nil { + t.Fatal(err) + } + tx, err = erc20.Approve( + &bobOpts, bidReceiverAddr, maxUint256, + ) + Require(t, err) + if _, err = bind.WaitMined(ctx, seqClient, tx); err != nil { + t.Fatal(err) + } + + // This is hacky- we are manually starting the ExpressLaneService here instead of letting it be started + // by the sequencer. This is due to needing to deploy the auction contract first. + builderSeq.execConfig.Sequencer.Timeboost.Enable = true + builderSeq.L2.ExecNode.Sequencer.StartExpressLane(ctx, builderSeq.L2.ExecNode.Backend.APIBackend(), builderSeq.L2.ExecNode.FilterSystem, proxyAddr, seqInfo.GetAddress("AuctionContract")) + t.Log("Started express lane service in sequencer") + + // Set up an autonomous auction contract service that runs in the background in this test. + redisURL := redisutil.CreateTestRedis(ctx, t) + + // Set up the auctioneer RPC service. + bidValidatorPort := getRandomPort(t) + bidValidatorWsPort := getRandomPort(t) + stackConf := node.Config{ + DataDir: "", // ephemeral. + HTTPPort: bidValidatorPort, + HTTPHost: "localhost", + HTTPModules: []string{timeboost.AuctioneerNamespace}, + HTTPVirtualHosts: []string{"localhost"}, + HTTPTimeouts: rpc.DefaultHTTPTimeouts, + WSHost: "localhost", + WSPort: bidValidatorWsPort, + WSModules: []string{timeboost.AuctioneerNamespace}, + GraphQLVirtualHosts: []string{"localhost"}, + P2P: p2p.Config{ + ListenAddr: "", + NoDial: true, + NoDiscovery: true, + }, + } + stack, err := node.New(&stackConf) + Require(t, err) + cfg := &timeboost.BidValidatorConfig{ + SequencerEndpoint: fmt.Sprintf("http://localhost:%d", seqPort), + AuctionContractAddress: proxyAddr.Hex(), + RedisURL: redisURL, + ProducerConfig: pubsub.TestProducerConfig, + } + fetcher := func() *timeboost.BidValidatorConfig { + return cfg + } + bidValidator, err := timeboost.NewBidValidator( + ctx, stack, fetcher, + ) + Require(t, err) + Require(t, stack.Start()) + Require(t, bidValidator.Initialize(ctx)) + bidValidator.Start(ctx) + + auctioneerCfg := &timeboost.AuctioneerServerConfig{ + SequencerEndpoint: fmt.Sprintf("http://localhost:%d", seqAuthPort), + AuctionContractAddress: proxyAddr.Hex(), + RedisURL: redisURL, + ConsumerConfig: pubsub.TestConsumerConfig, + SequencerJWTPath: jwtSecretPath, + DbDirectory: dbDirPath, + Wallet: genericconf.WalletConfig{ + PrivateKey: fmt.Sprintf("00%x", seqInfo.Accounts["AuctionContract"].PrivateKey.D.Bytes()), + }, + } + auctioneerFetcher := func() *timeboost.AuctioneerServerConfig { + return auctioneerCfg + } + am, err := timeboost.NewAuctioneerServer( + ctx, + auctioneerFetcher, + ) + Require(t, err) + am.Start(ctx) + + // Set up a bidder client for Alice and Bob. + alicePriv := seqInfo.Accounts["Alice"].PrivateKey + cfgFetcherAlice := func() *timeboost.BidderClientConfig { + return &timeboost.BidderClientConfig{ + AuctionContractAddress: proxyAddr.Hex(), + BidValidatorEndpoint: fmt.Sprintf("http://localhost:%d", bidValidatorPort), + ArbitrumNodeEndpoint: fmt.Sprintf("http://localhost:%d", seqPort), + Wallet: genericconf.WalletConfig{ + PrivateKey: fmt.Sprintf("00%x", alicePriv.D.Bytes()), + }, + } + } + alice, err := timeboost.NewBidderClient( + ctx, + cfgFetcherAlice, + ) + Require(t, err) + + bobPriv := seqInfo.Accounts["Bob"].PrivateKey + cfgFetcherBob := func() *timeboost.BidderClientConfig { + return &timeboost.BidderClientConfig{ + AuctionContractAddress: proxyAddr.Hex(), + BidValidatorEndpoint: fmt.Sprintf("http://localhost:%d", bidValidatorPort), + ArbitrumNodeEndpoint: fmt.Sprintf("http://localhost:%d", seqPort), + Wallet: genericconf.WalletConfig{ + PrivateKey: fmt.Sprintf("00%x", bobPriv.D.Bytes()), + }, + } + } + bob, err := timeboost.NewBidderClient( + ctx, + cfgFetcherBob, + ) + Require(t, err) + + alice.Start(ctx) + bob.Start(ctx) + + // Wait until the initial round. + info, err := auctionContract.RoundTimingInfo(&bind.CallOpts{}) + Require(t, err) + timeToWait := time.Until(initialTimestampUnix) + t.Logf("Waiting until the initial round %v and %v, current time %v", timeToWait, initialTimestampUnix, time.Now()) + <-time.After(timeToWait) + + t.Log("Started auction master stack and bid clients") + Require(t, alice.Deposit(ctx, big.NewInt(5))) + Require(t, bob.Deposit(ctx, big.NewInt(5))) + + // Wait until the next timeboost round + a few milliseconds. + now = time.Now() + waitTime = roundDuration - time.Duration(now.Second())*time.Second - time.Duration(now.Nanosecond()) + t.Logf("Alice and Bob are now deposited into the autonomous auction contract, waiting %v for bidding round..., timestamp %v", waitTime, time.Now()) + time.Sleep(waitTime) + t.Logf("Reached the bidding round at %v", time.Now()) + time.Sleep(time.Second * 5) + + // We are now in the bidding round, both issue their bids. Bob will win. + t.Logf("Alice and Bob now submitting their bids at %v", time.Now()) + aliceBid, err := alice.Bid(ctx, big.NewInt(1), aliceOpts.From) + Require(t, err) + bobBid, err := bob.Bid(ctx, big.NewInt(2), bobOpts.From) + Require(t, err) + t.Logf("Alice bid %+v", aliceBid) + t.Logf("Bob bid %+v", bobBid) + + // Subscribe to auction resolutions and wait for Bob to win the auction. + winner, winnerRound := awaitAuctionResolved(t, ctx, seqClient, auctionContract) + + // Verify Bob owns the express lane this round. + if winner != bobOpts.From { + t.Fatal("Bob should have won the express lane auction") + } + t.Log("Bob won the express lane auction for upcoming round, now waiting for that round to start...") + + // Wait until the round that Bob owns the express lane for. + now = time.Now() + waitTime = roundDuration - time.Duration(now.Second())*time.Second - time.Duration(now.Nanosecond()) + time.Sleep(waitTime) + + currRound := timeboost.CurrentRound(time.Unix(int64(info.OffsetTimestamp), 0), roundDuration) + t.Log("curr round", currRound) + if currRound != winnerRound { + now = time.Now() + waitTime = roundDuration - time.Duration(now.Second())*time.Second - time.Duration(now.Nanosecond()) + t.Log("Not express lane round yet, waiting for next round", waitTime) + time.Sleep(waitTime) + } + filterOpts := &bind.FilterOpts{ + Context: ctx, + Start: 0, + End: nil, + } + it, err := auctionContract.FilterAuctionResolved(filterOpts, nil, nil, nil) + Require(t, err) + bobWon := false + for it.Next() { + if it.Event.FirstPriceBidder == bobOpts.From { + bobWon = true + } + } + if !bobWon { + t.Fatal("Bob should have won the auction") + } + return seqNode, seqClient, seqInfo, proxyAddr, cleanupSeq +} + +func awaitAuctionResolved( + t *testing.T, + ctx context.Context, + client *ethclient.Client, + contract *express_lane_auctiongen.ExpressLaneAuction, +) (common.Address, uint64) { + fromBlock, err := client.BlockNumber(ctx) + Require(t, err) + ticker := time.NewTicker(time.Millisecond * 100) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return common.Address{}, 0 + case <-ticker.C: + latestBlock, err := client.HeaderByNumber(ctx, nil) + if err != nil { + t.Log("Could not get latest header", err) + continue + } + toBlock := latestBlock.Number.Uint64() + if fromBlock == toBlock { + continue + } + filterOpts := &bind.FilterOpts{ + Context: ctx, + Start: fromBlock, + End: &toBlock, + } + it, err := contract.FilterAuctionResolved(filterOpts, nil, nil, nil) + if err != nil { + t.Log("Could not filter auction resolutions", err) + continue + } + for it.Next() { + return it.Event.FirstPriceBidder, it.Event.Round + } + fromBlock = toBlock + } + } +} + +type expressLaneClient struct { + stopwaiter.StopWaiter + sync.Mutex + privKey *ecdsa.PrivateKey + chainId *big.Int + initialRoundTimestamp time.Time + roundDuration time.Duration + auctionContractAddr common.Address + client *rpc.Client + sequence uint64 +} + +func newExpressLaneClient( + privKey *ecdsa.PrivateKey, + chainId *big.Int, + initialRoundTimestamp time.Time, + roundDuration time.Duration, + auctionContractAddr common.Address, + client *rpc.Client, +) *expressLaneClient { + return &expressLaneClient{ + privKey: privKey, + chainId: chainId, + initialRoundTimestamp: initialRoundTimestamp, + roundDuration: roundDuration, + auctionContractAddr: auctionContractAddr, + client: client, + sequence: 0, + } +} + +func (elc *expressLaneClient) Start(ctxIn context.Context) { + elc.StopWaiter.Start(ctxIn, elc) +} + +func (elc *expressLaneClient) SendTransaction(ctx context.Context, transaction *types.Transaction) error { + elc.Lock() + defer elc.Unlock() + encodedTx, err := transaction.MarshalBinary() + if err != nil { + return err + } + msg := &timeboost.JsonExpressLaneSubmission{ + ChainId: (*hexutil.Big)(elc.chainId), + Round: hexutil.Uint64(timeboost.CurrentRound(elc.initialRoundTimestamp, elc.roundDuration)), + AuctionContractAddress: elc.auctionContractAddr, + Transaction: encodedTx, + SequenceNumber: hexutil.Uint64(elc.sequence), + Signature: hexutil.Bytes{}, + } + msgGo, err := timeboost.JsonSubmissionToGo(msg) + if err != nil { + return err + } + signingMsg, err := msgGo.ToMessageBytes() + if err != nil { + return err + } + signature, err := signSubmission(signingMsg, elc.privKey) + if err != nil { + return err + } + msg.Signature = signature + promise := elc.sendExpressLaneRPC(msg) + if _, err := promise.Await(ctx); err != nil { + return err + } + elc.sequence += 1 + return nil +} + +func (elc *expressLaneClient) sendExpressLaneRPC(msg *timeboost.JsonExpressLaneSubmission) containers.PromiseInterface[struct{}] { + return stopwaiter.LaunchPromiseThread(elc, func(ctx context.Context) (struct{}, error) { + err := elc.client.CallContext(ctx, nil, "timeboost_sendExpressLaneTransaction", msg) + return struct{}{}, err + }) +} + +func signSubmission(message []byte, key *ecdsa.PrivateKey) ([]byte, error) { + prefixed := crypto.Keccak256(append([]byte(fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(message))), message...)) + sig, err := secp256k1.Sign(prefixed, math.PaddedBigBytes(key.D, 32)) + if err != nil { + return nil, err + } + sig[64] += 27 + return sig, nil +} + +func getRandomPort(t testing.TB) int { + listener, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + defer listener.Close() + return listener.Addr().(*net.TCPAddr).Port +} diff --git a/timeboost/auctioneer.go b/timeboost/auctioneer.go new file mode 100644 index 0000000000..6c18890188 --- /dev/null +++ b/timeboost/auctioneer.go @@ -0,0 +1,469 @@ +// Copyright 2024-2025, Offchain Labs, Inc. +// For license information, see https://github.com/nitro/blob/master/LICENSE + +package timeboost + +import ( + "context" + "fmt" + "math/big" + "net/http" + "os" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/pkg/errors" + "github.com/spf13/pflag" + "golang.org/x/crypto/sha3" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/metrics" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/offchainlabs/nitro/cmd/genericconf" + "github.com/offchainlabs/nitro/cmd/util" + "github.com/offchainlabs/nitro/pubsub" + "github.com/offchainlabs/nitro/solgen/go/express_lane_auctiongen" + "github.com/offchainlabs/nitro/util/arbmath" + "github.com/offchainlabs/nitro/util/redisutil" + "github.com/offchainlabs/nitro/util/stopwaiter" +) + +// domainValue holds the Keccak256 hash of the string "TIMEBOOST_BID". +// It is intended to be immutable after initialization. +var domainValue []byte + +const ( + AuctioneerNamespace = "auctioneer" + validatedBidsRedisStream = "validated_bids" +) + +var ( + receivedBidsCounter = metrics.NewRegisteredCounter("arb/auctioneer/bids/received", nil) + validatedBidsCounter = metrics.NewRegisteredCounter("arb/auctioneer/bids/validated", nil) + FirstBidValueGauge = metrics.NewRegisteredGauge("arb/auctioneer/bids/firstbidvalue", nil) + SecondBidValueGauge = metrics.NewRegisteredGauge("arb/auctioneer/bids/secondbidvalue", nil) +) + +func init() { + hash := sha3.NewLegacyKeccak256() + hash.Write([]byte("TIMEBOOST_BID")) + domainValue = hash.Sum(nil) +} + +type AuctioneerServerConfigFetcher func() *AuctioneerServerConfig + +type AuctioneerServerConfig struct { + Enable bool `koanf:"enable"` + RedisURL string `koanf:"redis-url"` + ConsumerConfig pubsub.ConsumerConfig `koanf:"consumer-config"` + // Timeout on polling for existence of each redis stream. + StreamTimeout time.Duration `koanf:"stream-timeout"` + Wallet genericconf.WalletConfig `koanf:"wallet"` + SequencerEndpoint string `koanf:"sequencer-endpoint"` + SequencerJWTPath string `koanf:"sequencer-jwt-path"` + AuctionContractAddress string `koanf:"auction-contract-address"` + DbDirectory string `koanf:"db-directory"` + AuctionResolutionWaitTime time.Duration `koanf:"auction-resolution-wait-time"` +} + +var DefaultAuctioneerServerConfig = AuctioneerServerConfig{ + Enable: true, + RedisURL: "", + ConsumerConfig: pubsub.DefaultConsumerConfig, + StreamTimeout: 10 * time.Minute, + AuctionResolutionWaitTime: 2 * time.Second, +} + +var TestAuctioneerServerConfig = AuctioneerServerConfig{ + Enable: true, + RedisURL: "", + ConsumerConfig: pubsub.TestConsumerConfig, + StreamTimeout: time.Minute, + AuctionResolutionWaitTime: 2 * time.Second, +} + +func AuctioneerServerConfigAddOptions(prefix string, f *pflag.FlagSet) { + f.Bool(prefix+".enable", DefaultAuctioneerServerConfig.Enable, "enable auctioneer server") + f.String(prefix+".redis-url", DefaultAuctioneerServerConfig.RedisURL, "url of redis server") + pubsub.ConsumerConfigAddOptions(prefix+".consumer-config", f) + f.Duration(prefix+".stream-timeout", DefaultAuctioneerServerConfig.StreamTimeout, "Timeout on polling for existence of redis streams") + genericconf.WalletConfigAddOptions(prefix+".wallet", f, "wallet for auctioneer server") + f.String(prefix+".sequencer-endpoint", DefaultAuctioneerServerConfig.SequencerEndpoint, "sequencer RPC endpoint") + f.String(prefix+".sequencer-jwt-path", DefaultAuctioneerServerConfig.SequencerJWTPath, "sequencer jwt file path") + f.String(prefix+".auction-contract-address", DefaultAuctioneerServerConfig.AuctionContractAddress, "express lane auction contract address") + f.String(prefix+".db-directory", DefaultAuctioneerServerConfig.DbDirectory, "path to database directory for persisting validated bids in a sqlite file") + f.Duration(prefix+".auction-resolution-wait-time", DefaultAuctioneerServerConfig.AuctionResolutionWaitTime, "wait time after auction closing before resolving the auction") +} + +// AuctioneerServer is a struct that represents an autonomous auctioneer. +// It is responsible for receiving bids, validating them, and resolving auctions. +type AuctioneerServer struct { + stopwaiter.StopWaiter + consumer *pubsub.Consumer[*JsonValidatedBid, error] + txOpts *bind.TransactOpts + chainId *big.Int + sequencerRpc *rpc.Client + client *ethclient.Client + auctionContract *express_lane_auctiongen.ExpressLaneAuction + auctionContractAddr common.Address + bidsReceiver chan *JsonValidatedBid + bidCache *bidCache + initialRoundTimestamp time.Time + auctionClosingDuration time.Duration + roundDuration time.Duration + streamTimeout time.Duration + auctionResolutionWaitTime time.Duration + database *SqliteDatabase +} + +// NewAuctioneerServer creates a new autonomous auctioneer struct. +func NewAuctioneerServer(ctx context.Context, configFetcher AuctioneerServerConfigFetcher) (*AuctioneerServer, error) { + cfg := configFetcher() + if cfg.RedisURL == "" { + return nil, fmt.Errorf("redis url cannot be empty") + } + if cfg.AuctionContractAddress == "" { + return nil, fmt.Errorf("auction contract address cannot be empty") + } + if cfg.DbDirectory == "" { + return nil, errors.New("database directory is empty") + } + if cfg.SequencerJWTPath == "" { + return nil, errors.New("no sequencer jwt path specified") + } + database, err := NewDatabase(cfg.DbDirectory) + if err != nil { + return nil, err + } + auctionContractAddr := common.HexToAddress(cfg.AuctionContractAddress) + redisClient, err := redisutil.RedisClientFromURL(cfg.RedisURL) + if err != nil { + return nil, err + } + c, err := pubsub.NewConsumer[*JsonValidatedBid, error](redisClient, validatedBidsRedisStream, &cfg.ConsumerConfig) + if err != nil { + return nil, fmt.Errorf("creating consumer for validation: %w", err) + } + sequencerJwtStr, err := os.ReadFile(cfg.SequencerJWTPath) + if err != nil { + return nil, err + } + sequencerJwt, err := hexutil.Decode(string(sequencerJwtStr)) + if err != nil { + return nil, err + } + client, err := rpc.DialOptions(ctx, cfg.SequencerEndpoint, rpc.WithHTTPAuth(func(h http.Header) error { + claims := jwt.MapClaims{ + // Required claim for Ethereum RPC API auth. "iat" stands for issued at + // and it must be a unix timestamp that is +/- 5 seconds from the current + // timestamp at the moment the server verifies this value. + "iat": time.Now().Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(sequencerJwt) + if err != nil { + return errors.Wrap(err, "could not produce signed JWT token") + } + h.Set("Authorization", fmt.Sprintf("Bearer %s", tokenString)) + return nil + })) + if err != nil { + return nil, err + } + sequencerClient := ethclient.NewClient(client) + chainId, err := sequencerClient.ChainID(ctx) + if err != nil { + return nil, err + } + txOpts, _, err := util.OpenWallet("auctioneer-server", &cfg.Wallet, chainId) + if err != nil { + return nil, errors.Wrap(err, "opening wallet") + } + auctionContract, err := express_lane_auctiongen.NewExpressLaneAuction(auctionContractAddr, sequencerClient) + if err != nil { + return nil, err + } + var roundTimingInfo RoundTimingInfo + roundTimingInfo, err = auctionContract.RoundTimingInfo(&bind.CallOpts{}) + if err != nil { + return nil, err + } + if err = roundTimingInfo.Validate(&cfg.AuctionResolutionWaitTime); err != nil { + return nil, err + } + auctionClosingDuration := arbmath.SaturatingCast[time.Duration](roundTimingInfo.AuctionClosingSeconds) * time.Second + initialTimestamp := time.Unix(roundTimingInfo.OffsetTimestamp, 0) + roundDuration := arbmath.SaturatingCast[time.Duration](roundTimingInfo.RoundDurationSeconds) * time.Second + return &AuctioneerServer{ + txOpts: txOpts, + sequencerRpc: client, + chainId: chainId, + client: sequencerClient, + database: database, + consumer: c, + auctionContract: auctionContract, + auctionContractAddr: auctionContractAddr, + bidsReceiver: make(chan *JsonValidatedBid, 100_000), // TODO(Terence): Is 100k enough? Make this configurable? + bidCache: newBidCache(), + initialRoundTimestamp: initialTimestamp, + auctionClosingDuration: auctionClosingDuration, + roundDuration: roundDuration, + auctionResolutionWaitTime: cfg.AuctionResolutionWaitTime, + }, nil +} + +func (a *AuctioneerServer) Start(ctx_in context.Context) { + a.StopWaiter.Start(ctx_in, a) + // Channel that consumer uses to indicate its readiness. + readyStream := make(chan struct{}, 1) + a.consumer.Start(ctx_in) + // Channel for single consumer, once readiness is indicated in this, + // consumer will start consuming iteratively. + ready := make(chan struct{}, 1) + a.StopWaiter.LaunchThread(func(ctx context.Context) { + for { + if pubsub.StreamExists(ctx, a.consumer.StreamName(), a.consumer.RedisClient()) { + ready <- struct{}{} + readyStream <- struct{}{} + return + } + select { + case <-ctx.Done(): + log.Info("Context done while checking redis stream existance", "error", ctx.Err().Error()) + return + case <-time.After(time.Millisecond * 100): + } + } + }) + a.StopWaiter.LaunchThread(func(ctx context.Context) { + select { + case <-ctx.Done(): + log.Info("Context done while waiting a redis stream to be ready", "error", ctx.Err().Error()) + return + case <-ready: // Wait until the stream exists and start consuming iteratively. + } + log.Info("Stream exists, now attempting to consume data from it") + a.StopWaiter.CallIteratively(func(ctx context.Context) time.Duration { + req, err := a.consumer.Consume(ctx) + if err != nil { + log.Error("Consuming request", "error", err) + return 0 + } + if req == nil { + // There's nothing in the queue. + return time.Millisecond * 250 + } + // Forward the message over a channel for processing elsewhere in + // another thread, so as to not block this consumption thread. + a.bidsReceiver <- req.Value + + // We received the message, then we ack with a nil error. + if err := a.consumer.SetResult(ctx, req.ID, nil); err != nil { + log.Error("Error setting result for request", "id", req.ID, "result", nil, "error", err) + return 0 + } + return 0 + }) + }) + a.StopWaiter.LaunchThread(func(ctx context.Context) { + for { + select { + case <-readyStream: + log.Trace("At least one stream is ready") + return // Don't block Start if at least one of the stream is ready. + case <-time.After(a.streamTimeout): + log.Error("Waiting for redis streams timed out") + return + case <-ctx.Done(): + log.Info("Context done while waiting redis streams to be ready, failed to start") + return + } + } + }) + + // Bid receiver thread. + a.StopWaiter.LaunchThread(func(ctx context.Context) { + for { + select { + case bid := <-a.bidsReceiver: + log.Info("Consumed validated bid", "bidder", bid.Bidder, "amount", bid.Amount, "round", bid.Round) + a.bidCache.add(JsonValidatedBidToGo(bid)) + // Persist the validated bid to the database as a non-blocking operation. + go a.persistValidatedBid(bid) + case <-ctx.Done(): + log.Info("Context done while waiting redis streams to be ready, failed to start") + return + } + } + }) + + // Auction resolution thread. + a.StopWaiter.LaunchThread(func(ctx context.Context) { + ticker := newAuctionCloseTicker(a.roundDuration, a.auctionClosingDuration) + go ticker.start() + for { + select { + case <-ctx.Done(): + log.Error("Context closed, autonomous auctioneer shutting down") + return + case auctionClosingTime := <-ticker.c: + log.Info("New auction closing time reached", "closingTime", auctionClosingTime, "totalBids", a.bidCache.size()) + time.Sleep(a.auctionResolutionWaitTime) + if err := a.resolveAuction(ctx); err != nil { + log.Error("Could not resolve auction for round", "error", err) + } + // Clear the bid cache. + a.bidCache = newBidCache() + } + } + }) +} + +// Resolves the auction by calling the smart contract with the top two bids. +func (a *AuctioneerServer) resolveAuction(ctx context.Context) error { + upcomingRound := CurrentRound(a.initialRoundTimestamp, a.roundDuration) + 1 + result := a.bidCache.topTwoBids() + first := result.firstPlace + second := result.secondPlace + var tx *types.Transaction + var err error + opts := copyTxOpts(a.txOpts) + opts.NoSend = true + switch { + case first != nil && second != nil: // Both bids are present + tx, err = a.auctionContract.ResolveMultiBidAuction( + opts, + express_lane_auctiongen.Bid{ + ExpressLaneController: first.ExpressLaneController, + Amount: first.Amount, + Signature: first.Signature, + }, + express_lane_auctiongen.Bid{ + ExpressLaneController: second.ExpressLaneController, + Amount: second.Amount, + Signature: second.Signature, + }, + ) + FirstBidValueGauge.Update(first.Amount.Int64()) + SecondBidValueGauge.Update(second.Amount.Int64()) + log.Info("Resolving auction with two bids", "round", upcomingRound) + + case first != nil: // Single bid is present + tx, err = a.auctionContract.ResolveSingleBidAuction( + opts, + express_lane_auctiongen.Bid{ + ExpressLaneController: first.ExpressLaneController, + Amount: first.Amount, + Signature: first.Signature, + }, + ) + FirstBidValueGauge.Update(first.Amount.Int64()) + log.Info("Resolving auction with single bid", "round", upcomingRound) + + case second == nil: // No bids received + log.Info("No bids received for auction resolution", "round", upcomingRound) + return nil + } + if err != nil { + log.Error("Error resolving auction", "error", err) + return err + } + + currentRound := CurrentRound(a.initialRoundTimestamp, a.roundDuration) + roundEndTime := a.initialRoundTimestamp.Add(arbmath.SaturatingCast[time.Duration](currentRound) * a.roundDuration) + retryInterval := 1 * time.Second + + if err := retryUntil(ctx, func() error { + if err := a.sequencerRpc.CallContext(ctx, nil, "auctioneer_submitAuctionResolutionTransaction", tx); err != nil { + log.Error("Error submitting auction resolution to privileged sequencer endpoint", "error", err) + return err + } + + // Wait for the transaction to be mined + receipt, err := bind.WaitMined(ctx, a.client, tx) + if err != nil { + log.Error("Error waiting for transaction to be mined", "error", err) + return err + } + + // Check if the transaction was successful + if tx == nil || receipt == nil || receipt.Status != types.ReceiptStatusSuccessful { + if tx != nil { + log.Error("Transaction failed or did not finalize successfully", "txHash", tx.Hash().Hex()) + } + return errors.New("transaction failed or did not finalize successfully") + } + + return nil + }, retryInterval, roundEndTime); err != nil { + return err + } + + log.Info("Auction resolved successfully", "txHash", tx.Hash().Hex()) + return nil +} + +// retryUntil retries a given operation defined by the closure until the specified duration +// has passed or the operation succeeds. It waits for the specified retry interval between +// attempts. The function returns an error if all attempts fail. +func retryUntil(ctx context.Context, operation func() error, retryInterval time.Duration, endTime time.Time) error { + for { + // Execute the operation + if err := operation(); err == nil { + return nil + } + + if ctx.Err() != nil { + return ctx.Err() + } + + if time.Now().After(endTime) { + break + } + + time.Sleep(retryInterval) + } + return errors.New("operation failed after multiple attempts") +} + +func (a *AuctioneerServer) persistValidatedBid(bid *JsonValidatedBid) { + if err := a.database.InsertBid(JsonValidatedBidToGo(bid)); err != nil { + log.Error("Could not persist validated bid to database", "err", err, "bidder", bid.Bidder, "amount", bid.Amount.String()) + } +} + +func copyTxOpts(opts *bind.TransactOpts) *bind.TransactOpts { + if opts == nil { + return nil + } + copied := &bind.TransactOpts{ + From: opts.From, + Context: opts.Context, + NoSend: opts.NoSend, + Signer: opts.Signer, + GasLimit: opts.GasLimit, + } + + if opts.Nonce != nil { + copied.Nonce = new(big.Int).Set(opts.Nonce) + } + if opts.Value != nil { + copied.Value = new(big.Int).Set(opts.Value) + } + if opts.GasPrice != nil { + copied.GasPrice = new(big.Int).Set(opts.GasPrice) + } + if opts.GasFeeCap != nil { + copied.GasFeeCap = new(big.Int).Set(opts.GasFeeCap) + } + if opts.GasTipCap != nil { + copied.GasTipCap = new(big.Int).Set(opts.GasTipCap) + } + return copied +} diff --git a/timeboost/auctioneer_test.go b/timeboost/auctioneer_test.go new file mode 100644 index 0000000000..3e5e24a829 --- /dev/null +++ b/timeboost/auctioneer_test.go @@ -0,0 +1,226 @@ +package timeboost + +import ( + "context" + "errors" + "fmt" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/node" + "github.com/ethereum/go-ethereum/p2p" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/offchainlabs/nitro/cmd/genericconf" + "github.com/offchainlabs/nitro/pubsub" + "github.com/offchainlabs/nitro/util/redisutil" +) + +func TestBidValidatorAuctioneerRedisStream(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + testSetup := setupAuctionTest(t, ctx) + redisURL := redisutil.CreateTestRedis(ctx, t) + tmpDir, err := os.MkdirTemp("", "*") + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, os.RemoveAll(tmpDir)) + }) + jwtFilePath := filepath.Join(tmpDir, "jwt.key") + jwtSecret := common.BytesToHash([]byte("jwt")) + require.NoError(t, os.WriteFile(jwtFilePath, []byte(hexutil.Encode(jwtSecret[:])), 0600)) + + // Set up multiple bid validators that will receive bids via RPC using a bidder client. + // They inject their validated bids into a Redis stream that a single auctioneer instance + // will then consume. + numBidValidators := 3 + bidValidators := make([]*BidValidator, numBidValidators) + for i := 0; i < numBidValidators; i++ { + randHttp := getRandomPort(t) + stackConf := node.Config{ + DataDir: "", // ephemeral. + HTTPPort: randHttp, + HTTPModules: []string{AuctioneerNamespace}, + HTTPHost: "localhost", + HTTPVirtualHosts: []string{"localhost"}, + HTTPTimeouts: rpc.DefaultHTTPTimeouts, + WSPort: getRandomPort(t), + WSModules: []string{AuctioneerNamespace}, + WSHost: "localhost", + GraphQLVirtualHosts: []string{"localhost"}, + P2P: p2p.Config{ + ListenAddr: "", + NoDial: true, + NoDiscovery: true, + }, + } + stack, err := node.New(&stackConf) + require.NoError(t, err) + cfg := &BidValidatorConfig{ + SequencerEndpoint: testSetup.endpoint, + AuctionContractAddress: testSetup.expressLaneAuctionAddr.Hex(), + RedisURL: redisURL, + ProducerConfig: pubsub.TestProducerConfig, + } + fetcher := func() *BidValidatorConfig { + return cfg + } + bidValidator, err := NewBidValidator( + ctx, + stack, + fetcher, + ) + require.NoError(t, err) + require.NoError(t, bidValidator.Initialize(ctx)) + require.NoError(t, stack.Start()) + bidValidator.Start(ctx) + bidValidators[i] = bidValidator + } + t.Log("Started multiple bid validators") + + // Set up a single auctioneer instance that can consume messages produced + // by the bid validators from a redis stream. + cfg := &AuctioneerServerConfig{ + SequencerEndpoint: testSetup.endpoint, + SequencerJWTPath: jwtFilePath, + AuctionContractAddress: testSetup.expressLaneAuctionAddr.Hex(), + RedisURL: redisURL, + ConsumerConfig: pubsub.TestConsumerConfig, + DbDirectory: tmpDir, + Wallet: genericconf.WalletConfig{ + PrivateKey: fmt.Sprintf("%x", testSetup.accounts[0].privKey.D.Bytes()), + }, + } + fetcher := func() *AuctioneerServerConfig { + return cfg + } + am, err := NewAuctioneerServer( + ctx, + fetcher, + ) + require.NoError(t, err) + am.Start(ctx) + t.Log("Started auctioneer") + + // Now, we set up bidder clients for Alice, Bob, and Charlie. + aliceAddr := testSetup.accounts[1].txOpts.From + bobAddr := testSetup.accounts[2].txOpts.From + charlieAddr := testSetup.accounts[3].txOpts.From + alice := setupBidderClient(t, ctx, testSetup.accounts[1], testSetup, bidValidators[0].stack.HTTPEndpoint()) + bob := setupBidderClient(t, ctx, testSetup.accounts[2], testSetup, bidValidators[1].stack.HTTPEndpoint()) + charlie := setupBidderClient(t, ctx, testSetup.accounts[3], testSetup, bidValidators[2].stack.HTTPEndpoint()) + require.NoError(t, alice.Deposit(ctx, big.NewInt(20))) + require.NoError(t, bob.Deposit(ctx, big.NewInt(20))) + require.NoError(t, charlie.Deposit(ctx, big.NewInt(20))) + + info, err := alice.auctionContract.RoundTimingInfo(&bind.CallOpts{}) + require.NoError(t, err) + timeToWait := time.Until(time.Unix(int64(info.OffsetTimestamp), 0)) + t.Logf("Waiting for %v to start the bidding round, %v", timeToWait, time.Now()) + <-time.After(timeToWait) + time.Sleep(time.Millisecond * 250) // Add 1/4 of a second of wait so that we are definitely within a round. + + // Alice, Bob, and Charlie will submit bids to the three different bid validators instances. + start := time.Now() + for i := 1; i <= 5; i++ { + _, err = alice.Bid(ctx, big.NewInt(int64(i)), aliceAddr) + require.NoError(t, err) + _, err = bob.Bid(ctx, big.NewInt(int64(i)+1), bobAddr) // Bob bids 1 wei higher than Alice. + require.NoError(t, err) + _, err = charlie.Bid(ctx, big.NewInt(int64(i)+2), charlieAddr) // Charlie bids 2 wei higher than the Alice. + require.NoError(t, err) + } + + // We expect that a final submission from each fails, as the bid limit is exceeded. + _, err = alice.Bid(ctx, big.NewInt(6), aliceAddr) + require.ErrorContains(t, err, ErrTooManyBids.Error()) + _, err = bob.Bid(ctx, big.NewInt(7), bobAddr) // Bob bids 1 wei higher than Alice. + require.ErrorContains(t, err, ErrTooManyBids.Error()) + _, err = charlie.Bid(ctx, big.NewInt(8), charlieAddr) // Charlie bids 2 wei higher than the Bob. + require.ErrorContains(t, err, ErrTooManyBids.Error()) + + t.Log("Submitted bids", time.Now(), time.Since(start)) + time.Sleep(time.Second * 15) + + // We verify that the auctioneer has consumed all validated bids from the single Redis stream. + // We also verify the top two bids are those we expect. + require.Equal(t, 3, len(am.bidCache.bidsByExpressLaneControllerAddr)) + result := am.bidCache.topTwoBids() + require.Equal(t, big.NewInt(7), result.firstPlace.Amount) // Best bid should be Charlie's last bid 7 + require.Equal(t, charlieAddr, result.firstPlace.Bidder) + require.Equal(t, big.NewInt(6), result.secondPlace.Amount) // Second best bid should be Bob's last bid of 6 + require.Equal(t, bobAddr, result.secondPlace.Bidder) +} + +func TestRetryUntil(t *testing.T) { + t.Run("Success", func(t *testing.T) { + var currentAttempt int + successAfter := 3 + retryInterval := 100 * time.Millisecond + endTime := time.Now().Add(500 * time.Millisecond) + + err := retryUntil(context.Background(), mockOperation(successAfter, ¤tAttempt), retryInterval, endTime) + if err != nil { + t.Errorf("expected success, got error: %v", err) + } + if currentAttempt != successAfter { + t.Errorf("expected %d attempts, got %d", successAfter, currentAttempt) + } + }) + + t.Run("Timeout", func(t *testing.T) { + var currentAttempt int + successAfter := 5 + retryInterval := 100 * time.Millisecond + endTime := time.Now().Add(300 * time.Millisecond) + + err := retryUntil(context.Background(), mockOperation(successAfter, ¤tAttempt), retryInterval, endTime) + if err == nil { + t.Errorf("expected timeout error, got success") + } + if currentAttempt == successAfter { + t.Errorf("expected failure, but operation succeeded") + } + }) + + t.Run("ContextCancel", func(t *testing.T) { + var currentAttempt int + successAfter := 5 + retryInterval := 100 * time.Millisecond + endTime := time.Now().Add(500 * time.Millisecond) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(200 * time.Millisecond) + cancel() + }() + + err := retryUntil(ctx, mockOperation(successAfter, ¤tAttempt), retryInterval, endTime) + if err == nil { + t.Errorf("expected context cancellation error, got success") + } + if currentAttempt >= successAfter { + t.Errorf("expected failure due to context cancellation, but operation succeeded") + } + }) +} + +// Mock operation function to simulate different scenarios +func mockOperation(successAfter int, currentAttempt *int) func() error { + return func() error { + *currentAttempt++ + if *currentAttempt >= successAfter { + return nil + } + return errors.New("operation failed") + } +} diff --git a/timeboost/bid_cache.go b/timeboost/bid_cache.go new file mode 100644 index 0000000000..4031ab9a0b --- /dev/null +++ b/timeboost/bid_cache.go @@ -0,0 +1,69 @@ +package timeboost + +import ( + "sync" + + "github.com/ethereum/go-ethereum/common" +) + +type bidCache struct { + sync.RWMutex + bidsByExpressLaneControllerAddr map[common.Address]*ValidatedBid +} + +func newBidCache() *bidCache { + return &bidCache{ + bidsByExpressLaneControllerAddr: make(map[common.Address]*ValidatedBid), + } +} + +func (bc *bidCache) add(bid *ValidatedBid) { + bc.Lock() + defer bc.Unlock() + bc.bidsByExpressLaneControllerAddr[bid.ExpressLaneController] = bid +} + +// TwoTopBids returns the top two bids for the given chain ID and round +type auctionResult struct { + firstPlace *ValidatedBid + secondPlace *ValidatedBid +} + +func (bc *bidCache) size() int { + bc.RLock() + defer bc.RUnlock() + return len(bc.bidsByExpressLaneControllerAddr) + +} + +// topTwoBids returns the top two bids in the cache. +func (bc *bidCache) topTwoBids() *auctionResult { + bc.RLock() + defer bc.RUnlock() + + result := &auctionResult{} + + for _, bid := range bc.bidsByExpressLaneControllerAddr { + if result.firstPlace == nil { + result.firstPlace = bid + } else if bid.Amount.Cmp(result.firstPlace.Amount) > 0 { + result.secondPlace = result.firstPlace + result.firstPlace = bid + } else if bid.Amount.Cmp(result.firstPlace.Amount) == 0 { + if bid.BigIntHash().Cmp(result.firstPlace.BigIntHash()) > 0 { + result.secondPlace = result.firstPlace + result.firstPlace = bid + } else if result.secondPlace == nil || bid.BigIntHash().Cmp(result.secondPlace.BigIntHash()) > 0 { + result.secondPlace = bid + } + } else if result.secondPlace == nil || bid.Amount.Cmp(result.secondPlace.Amount) > 0 { + result.secondPlace = bid + } else if bid.Amount.Cmp(result.secondPlace.Amount) == 0 { + if bid.BigIntHash().Cmp(result.secondPlace.BigIntHash()) > 0 { + result.secondPlace = bid + } + } + } + + return result +} diff --git a/timeboost/bid_cache_test.go b/timeboost/bid_cache_test.go new file mode 100644 index 0000000000..8266fca202 --- /dev/null +++ b/timeboost/bid_cache_test.go @@ -0,0 +1,214 @@ +package timeboost + +import ( + "context" + "fmt" + "math/big" + "net" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/node" + "github.com/ethereum/go-ethereum/p2p" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/offchainlabs/nitro/pubsub" + "github.com/offchainlabs/nitro/util/redisutil" +) + +func TestTopTwoBids(t *testing.T) { + t.Parallel() + tests := []struct { + name string + bids map[common.Address]*ValidatedBid + expected *auctionResult + }{ + { + name: "single bid", + bids: map[common.Address]*ValidatedBid{ + common.HexToAddress("0x1"): {Amount: big.NewInt(100), ChainId: big.NewInt(1), ExpressLaneController: common.HexToAddress("0x1")}, + }, + expected: &auctionResult{ + firstPlace: &ValidatedBid{Amount: big.NewInt(100), ChainId: big.NewInt(1), ExpressLaneController: common.HexToAddress("0x1")}, + secondPlace: nil, + }, + }, + { + name: "two bids with different amounts", + bids: map[common.Address]*ValidatedBid{ + common.HexToAddress("0x1"): {Amount: big.NewInt(100), ChainId: big.NewInt(1), ExpressLaneController: common.HexToAddress("0x1")}, + common.HexToAddress("0x2"): {Amount: big.NewInt(200), ChainId: big.NewInt(1), ExpressLaneController: common.HexToAddress("0x2")}, + }, + expected: &auctionResult{ + firstPlace: &ValidatedBid{Amount: big.NewInt(200), ChainId: big.NewInt(1), ExpressLaneController: common.HexToAddress("0x2")}, + secondPlace: &ValidatedBid{Amount: big.NewInt(100), ChainId: big.NewInt(1), ExpressLaneController: common.HexToAddress("0x1")}, + }, + }, + { + name: "two bids same amount but different hashes", + bids: map[common.Address]*ValidatedBid{ + common.HexToAddress("0x1"): {Amount: big.NewInt(100), ChainId: big.NewInt(1), Bidder: common.HexToAddress("0x1"), ExpressLaneController: common.HexToAddress("0x1")}, + common.HexToAddress("0x2"): {Amount: big.NewInt(100), ChainId: big.NewInt(2), Bidder: common.HexToAddress("0x2"), ExpressLaneController: common.HexToAddress("0x2")}, + }, + expected: &auctionResult{ + firstPlace: &ValidatedBid{Amount: big.NewInt(100), ChainId: big.NewInt(2), Bidder: common.HexToAddress("0x2"), ExpressLaneController: common.HexToAddress("0x2")}, + secondPlace: &ValidatedBid{Amount: big.NewInt(100), ChainId: big.NewInt(1), Bidder: common.HexToAddress("0x1"), ExpressLaneController: common.HexToAddress("0x1")}, + }, + }, + { + name: "many bids but all same amount", + bids: map[common.Address]*ValidatedBid{ + common.HexToAddress("0x1"): {Amount: big.NewInt(300), ChainId: big.NewInt(1), ExpressLaneController: common.HexToAddress("0x1")}, + common.HexToAddress("0x2"): {Amount: big.NewInt(100), ChainId: big.NewInt(1), ExpressLaneController: common.HexToAddress("0x2")}, + common.HexToAddress("0x3"): {Amount: big.NewInt(200), ChainId: big.NewInt(1), ExpressLaneController: common.HexToAddress("0x3")}, + }, + expected: &auctionResult{ + firstPlace: &ValidatedBid{Amount: big.NewInt(300), ChainId: big.NewInt(1), ExpressLaneController: common.HexToAddress("0x1")}, + secondPlace: &ValidatedBid{Amount: big.NewInt(200), ChainId: big.NewInt(1), ExpressLaneController: common.HexToAddress("0x3")}, + }, + }, + { + name: "many bids with some tied and others with different amounts", + bids: map[common.Address]*ValidatedBid{ + common.HexToAddress("0x1"): {Amount: big.NewInt(300), ChainId: big.NewInt(1), ExpressLaneController: common.HexToAddress("0x1")}, + common.HexToAddress("0x2"): {Amount: big.NewInt(100), ChainId: big.NewInt(1), ExpressLaneController: common.HexToAddress("0x2")}, + common.HexToAddress("0x3"): {Amount: big.NewInt(200), ChainId: big.NewInt(1), ExpressLaneController: common.HexToAddress("0x3")}, + common.HexToAddress("0x4"): {Amount: big.NewInt(200), ChainId: big.NewInt(1), Bidder: common.HexToAddress("0x1"), ExpressLaneController: common.HexToAddress("0x4")}, + }, + expected: &auctionResult{ + firstPlace: &ValidatedBid{Amount: big.NewInt(300), ChainId: big.NewInt(1), ExpressLaneController: common.HexToAddress("0x1")}, + secondPlace: &ValidatedBid{Amount: big.NewInt(200), ChainId: big.NewInt(1), Bidder: common.HexToAddress("0x1"), ExpressLaneController: common.HexToAddress("0x4")}, + }, + }, + { + name: "many bids and tied for second place", + bids: map[common.Address]*ValidatedBid{ + common.HexToAddress("0x1"): {Amount: big.NewInt(300), ChainId: big.NewInt(1), ExpressLaneController: common.HexToAddress("0x1")}, + common.HexToAddress("0x2"): {Amount: big.NewInt(200), ChainId: big.NewInt(1), ExpressLaneController: common.HexToAddress("0x2")}, + common.HexToAddress("0x3"): {Amount: big.NewInt(200), ChainId: big.NewInt(1), Bidder: common.HexToAddress("0x1"), ExpressLaneController: common.HexToAddress("0x3")}, + }, + expected: &auctionResult{ + firstPlace: &ValidatedBid{Amount: big.NewInt(300), ChainId: big.NewInt(1), ExpressLaneController: common.HexToAddress("0x1")}, + secondPlace: &ValidatedBid{Amount: big.NewInt(200), ChainId: big.NewInt(1), Bidder: common.HexToAddress("0x1"), ExpressLaneController: common.HexToAddress("0x3")}, + }, + }, + { + name: "all bids with the same amount", + bids: map[common.Address]*ValidatedBid{ + common.HexToAddress("0x1"): {Amount: big.NewInt(100), ChainId: big.NewInt(1), Bidder: common.HexToAddress("0x1"), ExpressLaneController: common.HexToAddress("0x1")}, + common.HexToAddress("0x2"): {Amount: big.NewInt(100), ChainId: big.NewInt(2), Bidder: common.HexToAddress("0x2"), ExpressLaneController: common.HexToAddress("0x2")}, + common.HexToAddress("0x3"): {Amount: big.NewInt(100), ChainId: big.NewInt(3), Bidder: common.HexToAddress("0x3"), ExpressLaneController: common.HexToAddress("0x3")}, + }, + expected: &auctionResult{ + firstPlace: &ValidatedBid{Amount: big.NewInt(100), ChainId: big.NewInt(3), Bidder: common.HexToAddress("0x3"), ExpressLaneController: common.HexToAddress("0x3")}, + secondPlace: &ValidatedBid{Amount: big.NewInt(100), ChainId: big.NewInt(2), Bidder: common.HexToAddress("0x2"), ExpressLaneController: common.HexToAddress("0x2")}, + }, + }, + { + name: "no bids", + bids: nil, + expected: &auctionResult{firstPlace: nil, secondPlace: nil}, + }, + { + name: "identical bids", + bids: map[common.Address]*ValidatedBid{ + common.HexToAddress("0x1"): {Amount: big.NewInt(100), ChainId: big.NewInt(1), Bidder: common.HexToAddress("0x1"), ExpressLaneController: common.HexToAddress("0x1")}, + common.HexToAddress("0x2"): {Amount: big.NewInt(100), ChainId: big.NewInt(1), Bidder: common.HexToAddress("0x1"), ExpressLaneController: common.HexToAddress("0x2")}, + }, + expected: &auctionResult{ + firstPlace: &ValidatedBid{Amount: big.NewInt(100), ChainId: big.NewInt(1), Bidder: common.HexToAddress("0x1"), ExpressLaneController: common.HexToAddress("0x1")}, + secondPlace: &ValidatedBid{Amount: big.NewInt(100), ChainId: big.NewInt(1), Bidder: common.HexToAddress("0x1"), ExpressLaneController: common.HexToAddress("0x2")}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bc := &bidCache{ + bidsByExpressLaneControllerAddr: tt.bids, + } + result := bc.topTwoBids() + if (result.firstPlace == nil) != (tt.expected.firstPlace == nil) || (result.secondPlace == nil) != (tt.expected.secondPlace == nil) { + t.Fatalf("expected firstPlace: %v, secondPlace: %v, got firstPlace: %v, secondPlace: %v", tt.expected.firstPlace, tt.expected.secondPlace, result.firstPlace, result.secondPlace) + } + if result.firstPlace != nil && result.firstPlace.Amount.Cmp(tt.expected.firstPlace.Amount) != 0 { + t.Errorf("expected firstPlace amount: %v, got: %v", tt.expected.firstPlace.Amount, result.firstPlace.Amount) + } + if result.secondPlace != nil && result.secondPlace.Amount.Cmp(tt.expected.secondPlace.Amount) != 0 { + t.Errorf("expected secondPlace amount: %v, got: %v", tt.expected.secondPlace.Amount, result.secondPlace.Amount) + } + }) + } +} + +func BenchmarkBidValidation(b *testing.B) { + b.StopTimer() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + redisURL := redisutil.CreateTestRedis(ctx, b) + testSetup := setupAuctionTest(b, ctx) + bv, endpoint := setupBidValidator(b, ctx, redisURL, testSetup) + bc := setupBidderClient(b, ctx, testSetup.accounts[0], testSetup, endpoint) + require.NoError(b, bc.Deposit(ctx, big.NewInt(5))) + + // Form a valid bid. + newBid, err := bc.Bid(ctx, big.NewInt(5), testSetup.accounts[0].txOpts.From) + require.NoError(b, err) + + b.StartTimer() + for i := 0; i < b.N; i++ { + _, err = bv.validateBid(newBid, bv.auctionContract.BalanceOf) + require.NoError(b, err) + } +} + +func setupBidValidator(t testing.TB, ctx context.Context, redisURL string, testSetup *auctionSetup) (*BidValidator, string) { + randHttp := getRandomPort(t) + stackConf := node.Config{ + DataDir: "", // ephemeral. + HTTPPort: randHttp, + HTTPModules: []string{AuctioneerNamespace}, + HTTPHost: "localhost", + HTTPVirtualHosts: []string{"localhost"}, + HTTPTimeouts: rpc.DefaultHTTPTimeouts, + WSPort: getRandomPort(t), + WSModules: []string{AuctioneerNamespace}, + WSHost: "localhost", + GraphQLVirtualHosts: []string{"localhost"}, + P2P: p2p.Config{ + ListenAddr: "", + NoDial: true, + NoDiscovery: true, + }, + } + stack, err := node.New(&stackConf) + require.NoError(t, err) + cfg := &BidValidatorConfig{ + SequencerEndpoint: testSetup.endpoint, + AuctionContractAddress: testSetup.expressLaneAuctionAddr.Hex(), + RedisURL: redisURL, + ProducerConfig: pubsub.TestProducerConfig, + } + fetcher := func() *BidValidatorConfig { + return cfg + } + bidValidator, err := NewBidValidator( + ctx, + stack, + fetcher, + ) + require.NoError(t, err) + require.NoError(t, bidValidator.Initialize(ctx)) + require.NoError(t, stack.Start()) + bidValidator.Start(ctx) + return bidValidator, fmt.Sprintf("http://localhost:%d", randHttp) +} + +func getRandomPort(t testing.TB) int { + listener, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + defer listener.Close() + return listener.Addr().(*net.TCPAddr).Port +} diff --git a/timeboost/bid_validator.go b/timeboost/bid_validator.go new file mode 100644 index 0000000000..218230bd59 --- /dev/null +++ b/timeboost/bid_validator.go @@ -0,0 +1,368 @@ +package timeboost + +import ( + "context" + "fmt" + "math/big" + "sync" + "time" + + "github.com/pkg/errors" + "github.com/redis/go-redis/v9" + "github.com/spf13/pflag" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/node" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/offchainlabs/nitro/pubsub" + "github.com/offchainlabs/nitro/solgen/go/express_lane_auctiongen" + "github.com/offchainlabs/nitro/util/arbmath" + "github.com/offchainlabs/nitro/util/redisutil" + "github.com/offchainlabs/nitro/util/stopwaiter" +) + +type BidValidatorConfigFetcher func() *BidValidatorConfig + +type BidValidatorConfig struct { + Enable bool `koanf:"enable"` + RedisURL string `koanf:"redis-url"` + ProducerConfig pubsub.ProducerConfig `koanf:"producer-config"` + // Timeout on polling for existence of each redis stream. + SequencerEndpoint string `koanf:"sequencer-endpoint"` + AuctionContractAddress string `koanf:"auction-contract-address"` +} + +var DefaultBidValidatorConfig = BidValidatorConfig{ + Enable: true, + RedisURL: "", + ProducerConfig: pubsub.DefaultProducerConfig, +} + +var TestBidValidatorConfig = BidValidatorConfig{ + Enable: true, + RedisURL: "", + ProducerConfig: pubsub.TestProducerConfig, +} + +func BidValidatorConfigAddOptions(prefix string, f *pflag.FlagSet) { + f.Bool(prefix+".enable", DefaultBidValidatorConfig.Enable, "enable bid validator") + f.String(prefix+".redis-url", DefaultBidValidatorConfig.RedisURL, "url of redis server") + pubsub.ProducerAddConfigAddOptions(prefix+".producer-config", f) + f.String(prefix+".sequencer-endpoint", DefaultAuctioneerServerConfig.SequencerEndpoint, "sequencer RPC endpoint") + f.String(prefix+".auction-contract-address", DefaultAuctioneerServerConfig.AuctionContractAddress, "express lane auction contract address") +} + +type BidValidator struct { + stopwaiter.StopWaiter + sync.RWMutex + chainId *big.Int + stack *node.Node + producerCfg *pubsub.ProducerConfig + producer *pubsub.Producer[*JsonValidatedBid, error] + redisClient redis.UniversalClient + domainValue []byte + client *ethclient.Client + auctionContract *express_lane_auctiongen.ExpressLaneAuction + auctionContractAddr common.Address + bidsReceiver chan *Bid + initialRoundTimestamp time.Time + roundDuration time.Duration + auctionClosingDuration time.Duration + reserveSubmissionDuration time.Duration + reservePriceLock sync.RWMutex + reservePrice *big.Int + bidsPerSenderInRound map[common.Address]uint8 + maxBidsPerSenderInRound uint8 +} + +func NewBidValidator( + ctx context.Context, + stack *node.Node, + configFetcher BidValidatorConfigFetcher, +) (*BidValidator, error) { + cfg := configFetcher() + if cfg.RedisURL == "" { + return nil, fmt.Errorf("redis url cannot be empty") + } + if cfg.AuctionContractAddress == "" { + return nil, fmt.Errorf("auction contract address cannot be empty") + } + auctionContractAddr := common.HexToAddress(cfg.AuctionContractAddress) + redisClient, err := redisutil.RedisClientFromURL(cfg.RedisURL) + if err != nil { + return nil, err + } + + client, err := rpc.DialContext(ctx, cfg.SequencerEndpoint) + if err != nil { + return nil, err + } + sequencerClient := ethclient.NewClient(client) + chainId, err := sequencerClient.ChainID(ctx) + if err != nil { + return nil, err + } + auctionContract, err := express_lane_auctiongen.NewExpressLaneAuction(auctionContractAddr, sequencerClient) + if err != nil { + return nil, err + } + var roundTimingInfo RoundTimingInfo + roundTimingInfo, err = auctionContract.RoundTimingInfo(&bind.CallOpts{}) + if err != nil { + return nil, err + } + if err = roundTimingInfo.Validate(nil); err != nil { + return nil, err + } + initialTimestamp := time.Unix(int64(roundTimingInfo.OffsetTimestamp), 0) + roundDuration := arbmath.SaturatingCast[time.Duration](roundTimingInfo.RoundDurationSeconds) * time.Second + auctionClosingDuration := arbmath.SaturatingCast[time.Duration](roundTimingInfo.AuctionClosingSeconds) * time.Second + reserveSubmissionDuration := arbmath.SaturatingCast[time.Duration](roundTimingInfo.ReserveSubmissionSeconds) * time.Second + + reservePrice, err := auctionContract.ReservePrice(&bind.CallOpts{}) + if err != nil { + return nil, err + } + bidValidator := &BidValidator{ + chainId: chainId, + client: sequencerClient, + redisClient: redisClient, + stack: stack, + auctionContract: auctionContract, + auctionContractAddr: auctionContractAddr, + bidsReceiver: make(chan *Bid, 10_000), + initialRoundTimestamp: initialTimestamp, + roundDuration: roundDuration, + auctionClosingDuration: auctionClosingDuration, + reserveSubmissionDuration: reserveSubmissionDuration, + reservePrice: reservePrice, + domainValue: domainValue, + bidsPerSenderInRound: make(map[common.Address]uint8), + maxBidsPerSenderInRound: 5, // 5 max bids per sender address in a round. + producerCfg: &cfg.ProducerConfig, + } + api := &BidValidatorAPI{bidValidator} + valAPIs := []rpc.API{{ + Namespace: AuctioneerNamespace, + Version: "1.0", + Service: api, + Public: true, + }} + stack.RegisterAPIs(valAPIs) + return bidValidator, nil +} + +func EnsureBidValidatorExposedViaRPC(stackConf *node.Config) { + found := false + for _, module := range stackConf.HTTPModules { + if module == AuctioneerNamespace { + found = true + break + } + } + if !found { + stackConf.HTTPModules = append(stackConf.HTTPModules, AuctioneerNamespace) + } +} + +func (bv *BidValidator) Initialize(ctx context.Context) error { + if err := pubsub.CreateStream( + ctx, + validatedBidsRedisStream, + bv.redisClient, + ); err != nil { + return fmt.Errorf("creating redis stream: %w", err) + } + p, err := pubsub.NewProducer[*JsonValidatedBid, error]( + bv.redisClient, validatedBidsRedisStream, bv.producerCfg, + ) + if err != nil { + return fmt.Errorf("failed to init redis in bid validator: %w", err) + } + bv.producer = p + return nil +} + +func (bv *BidValidator) Start(ctx_in context.Context) { + bv.StopWaiter.Start(ctx_in, bv) + if bv.producer == nil { + log.Crit("Bid validator not yet initialized by calling Initialize(ctx)") + } + bv.producer.Start(ctx_in) + + // Set reserve price thread. + bv.StopWaiter.LaunchThread(func(ctx context.Context) { + ticker := newAuctionCloseTicker(bv.roundDuration, bv.auctionClosingDuration+bv.reserveSubmissionDuration) + go ticker.start() + for { + select { + case <-ctx.Done(): + log.Error("Context closed, autonomous auctioneer shutting down") + return + case <-ticker.c: + rp, err := bv.auctionContract.ReservePrice(&bind.CallOpts{}) + if err != nil { + log.Error("Could not get reserve price", "error", err) + continue + } + + currentReservePrice := bv.fetchReservePrice() + if currentReservePrice.Cmp(rp) == 0 { + continue + } + + log.Info("Reserve price updated", "old", currentReservePrice.String(), "new", rp.String()) + bv.setReservePrice(rp) + + bv.Lock() + bv.bidsPerSenderInRound = make(map[common.Address]uint8) + bv.Unlock() + } + } + }) +} + +type BidValidatorAPI struct { + *BidValidator +} + +func (bv *BidValidatorAPI) SubmitBid(ctx context.Context, bid *JsonBid) error { + start := time.Now() + receivedBidsCounter.Inc(1) + validatedBid, err := bv.validateBid( + &Bid{ + ChainId: bid.ChainId.ToInt(), + ExpressLaneController: bid.ExpressLaneController, + AuctionContractAddress: bid.AuctionContractAddress, + Round: uint64(bid.Round), + Amount: bid.Amount.ToInt(), + Signature: bid.Signature, + }, + bv.auctionContract.BalanceOf, + ) + if err != nil { + return err + } + validatedBidsCounter.Inc(1) + log.Info("Validated bid", "bidder", validatedBid.Bidder.Hex(), "amount", validatedBid.Amount.String(), "round", validatedBid.Round, "elapsed", time.Since(start)) + _, err = bv.producer.Produce(ctx, validatedBid) + if err != nil { + return err + } + return nil +} + +func (bv *BidValidator) setReservePrice(p *big.Int) { + bv.reservePriceLock.Lock() + defer bv.reservePriceLock.Unlock() + bv.reservePrice = p +} + +func (bv *BidValidator) fetchReservePrice() *big.Int { + bv.reservePriceLock.RLock() + defer bv.reservePriceLock.RUnlock() + return bv.reservePrice +} + +func (bv *BidValidator) validateBid( + bid *Bid, + balanceCheckerFn func(opts *bind.CallOpts, account common.Address) (*big.Int, error)) (*JsonValidatedBid, error) { + // Check basic integrity. + if bid == nil { + return nil, errors.Wrap(ErrMalformedData, "nil bid") + } + if bid.AuctionContractAddress != bv.auctionContractAddr { + return nil, errors.Wrap(ErrMalformedData, "incorrect auction contract address") + } + if bid.ExpressLaneController == (common.Address{}) { + return nil, errors.Wrap(ErrMalformedData, "empty express lane controller address") + } + if bid.ChainId == nil { + return nil, errors.Wrap(ErrMalformedData, "empty chain id") + } + + // Check if the chain ID is valid. + if bid.ChainId.Cmp(bv.chainId) != 0 { + return nil, errors.Wrapf(ErrWrongChainId, "can not auction for chain id: %d", bid.ChainId) + } + + // Check if the bid is intended for upcoming round. + upcomingRound := CurrentRound(bv.initialRoundTimestamp, bv.roundDuration) + 1 + if bid.Round != upcomingRound { + return nil, errors.Wrapf(ErrBadRoundNumber, "wanted %d, got %d", upcomingRound, bid.Round) + } + + // Check if the auction is closed. + if isAuctionRoundClosed( + time.Now(), + bv.initialRoundTimestamp, + bv.roundDuration, + bv.auctionClosingDuration, + ) { + return nil, errors.Wrap(ErrBadRoundNumber, "auction is closed") + } + + // Check bid is higher than or equal to reserve price. + if bid.Amount.Cmp(bv.reservePrice) == -1 { + return nil, errors.Wrapf(ErrReservePriceNotMet, "reserve price %s, bid %s", bv.reservePrice.String(), bid.Amount.String()) + } + + // Validate the signature. + packedBidBytes := bid.ToMessageBytes() + if len(bid.Signature) != 65 { + return nil, errors.Wrap(ErrMalformedData, "signature length is not 65") + } + // Recover the public key. + sigItem := make([]byte, len(bid.Signature)) + copy(sigItem, bid.Signature) + + // Signature verification expects the last byte of the signature to have 27 subtracted, + // as it represents the recovery ID. If the last byte is greater than or equal to 27, it indicates a recovery ID that hasn't been adjusted yet, + // it's needed for internal signature verification logic. + if sigItem[len(sigItem)-1] >= 27 { + sigItem[len(sigItem)-1] -= 27 + } + pubkey, err := crypto.SigToPub(buildEthereumSignedMessage(packedBidBytes), sigItem) + if err != nil { + return nil, ErrMalformedData + } + // Check how many bids the bidder has sent in this round and cap according to a limit. + bidder := crypto.PubkeyToAddress(*pubkey) + bv.Lock() + numBids, ok := bv.bidsPerSenderInRound[bidder] + if !ok { + bv.bidsPerSenderInRound[bidder] = 0 + } + if numBids >= bv.maxBidsPerSenderInRound { + bv.Unlock() + return nil, errors.Wrapf(ErrTooManyBids, "bidder %s has already sent the maximum allowed bids = %d in this round", bidder.Hex(), numBids) + } + bv.bidsPerSenderInRound[bidder]++ + bv.Unlock() + + depositBal, err := balanceCheckerFn(&bind.CallOpts{}, bidder) + if err != nil { + return nil, err + } + if depositBal.Cmp(new(big.Int)) == 0 { + return nil, ErrNotDepositor + } + if depositBal.Cmp(bid.Amount) < 0 { + return nil, errors.Wrapf(ErrInsufficientBalance, "onchain balance %#x, bid amount %#x", depositBal, bid.Amount) + } + vb := &ValidatedBid{ + ExpressLaneController: bid.ExpressLaneController, + Amount: bid.Amount, + Signature: bid.Signature, + ChainId: bid.ChainId, + AuctionContractAddress: bid.AuctionContractAddress, + Round: bid.Round, + Bidder: bidder, + } + return vb.ToJson(), nil +} diff --git a/timeboost/bid_validator_test.go b/timeboost/bid_validator_test.go new file mode 100644 index 0000000000..336e5a3429 --- /dev/null +++ b/timeboost/bid_validator_test.go @@ -0,0 +1,193 @@ +package timeboost + +import ( + "context" + "crypto/ecdsa" + "fmt" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +func TestBidValidator_validateBid(t *testing.T) { + t.Parallel() + setup := setupAuctionTest(t, context.Background()) + tests := []struct { + name string + bid *Bid + expectedErr error + errMsg string + auctionClosed bool + }{ + { + name: "nil bid", + bid: nil, + expectedErr: ErrMalformedData, + errMsg: "nil bid", + }, + { + name: "empty express lane controller address", + bid: &Bid{}, + expectedErr: ErrMalformedData, + errMsg: "incorrect auction contract address", + }, + { + name: "incorrect chain id", + bid: &Bid{ + ExpressLaneController: common.Address{'b'}, + AuctionContractAddress: setup.expressLaneAuctionAddr, + ChainId: big.NewInt(50), + }, + expectedErr: ErrWrongChainId, + errMsg: "can not auction for chain id: 50", + }, + { + name: "incorrect round", + bid: &Bid{ + ExpressLaneController: common.Address{'b'}, + AuctionContractAddress: setup.expressLaneAuctionAddr, + ChainId: big.NewInt(1), + }, + expectedErr: ErrBadRoundNumber, + errMsg: "wanted 1, got 0", + }, + { + name: "auction is closed", + bid: &Bid{ + ExpressLaneController: common.Address{'b'}, + AuctionContractAddress: setup.expressLaneAuctionAddr, + ChainId: big.NewInt(1), + Round: 1, + }, + expectedErr: ErrBadRoundNumber, + errMsg: "auction is closed", + auctionClosed: true, + }, + { + name: "lower than reserved price", + bid: &Bid{ + ExpressLaneController: common.Address{'b'}, + AuctionContractAddress: setup.expressLaneAuctionAddr, + ChainId: big.NewInt(1), + Round: 1, + Amount: big.NewInt(1), + }, + expectedErr: ErrReservePriceNotMet, + errMsg: "reserve price 2, bid 1", + }, + { + name: "incorrect signature", + bid: &Bid{ + ExpressLaneController: common.Address{'b'}, + AuctionContractAddress: setup.expressLaneAuctionAddr, + ChainId: big.NewInt(1), + Round: 1, + Amount: big.NewInt(3), + Signature: []byte{'a'}, + }, + expectedErr: ErrMalformedData, + errMsg: "signature length is not 65", + }, + { + name: "not a depositor", + bid: buildValidBid(t, setup.expressLaneAuctionAddr), + expectedErr: ErrNotDepositor, + }, + } + + for _, tt := range tests { + bv := BidValidator{ + chainId: big.NewInt(1), + initialRoundTimestamp: time.Now().Add(-time.Second * 3), + reservePrice: big.NewInt(2), + roundDuration: 10 * time.Second, + auctionClosingDuration: 5 * time.Second, + auctionContract: setup.expressLaneAuction, + auctionContractAddr: setup.expressLaneAuctionAddr, + bidsPerSenderInRound: make(map[common.Address]uint8), + maxBidsPerSenderInRound: 5, + } + t.Run(tt.name, func(t *testing.T) { + if tt.auctionClosed { + time.Sleep(time.Second * 3) + } + _, err := bv.validateBid(tt.bid, setup.expressLaneAuction.BalanceOf) + require.ErrorIs(t, err, tt.expectedErr) + require.Contains(t, err.Error(), tt.errMsg) + }) + } +} + +func TestBidValidator_validateBid_perRoundBidLimitReached(t *testing.T) { + t.Parallel() + balanceCheckerFn := func(_ *bind.CallOpts, _ common.Address) (*big.Int, error) { + return big.NewInt(10), nil + } + auctionContractAddr := common.Address{'a'} + bv := BidValidator{ + chainId: big.NewInt(1), + initialRoundTimestamp: time.Now().Add(-time.Second), + reservePrice: big.NewInt(2), + roundDuration: time.Minute, + auctionClosingDuration: 45 * time.Second, + bidsPerSenderInRound: make(map[common.Address]uint8), + maxBidsPerSenderInRound: 5, + auctionContractAddr: auctionContractAddr, + } + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + bid := &Bid{ + ExpressLaneController: common.Address{'b'}, + AuctionContractAddress: auctionContractAddr, + ChainId: big.NewInt(1), + Round: 1, + Amount: big.NewInt(3), + Signature: []byte{'a'}, + } + signature, err := buildSignature(privateKey, bid.ToMessageBytes()) + require.NoError(t, err) + + bid.Signature = signature + for i := 0; i < int(bv.maxBidsPerSenderInRound); i++ { + _, err := bv.validateBid(bid, balanceCheckerFn) + require.NoError(t, err) + } + _, err = bv.validateBid(bid, balanceCheckerFn) + require.ErrorIs(t, err, ErrTooManyBids) + +} + +func buildSignature(privateKey *ecdsa.PrivateKey, data []byte) ([]byte, error) { + prefixedData := crypto.Keccak256(append([]byte(fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(data))), data...)) + signature, err := crypto.Sign(prefixedData, privateKey) + if err != nil { + return nil, err + } + return signature, nil +} + +func buildValidBid(t *testing.T, auctionContractAddr common.Address) *Bid { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + bid := &Bid{ + ExpressLaneController: common.Address{'b'}, + AuctionContractAddress: auctionContractAddr, + ChainId: big.NewInt(1), + Round: 1, + Amount: big.NewInt(3), + Signature: []byte{'a'}, + } + + signature, err := buildSignature(privateKey, bid.ToMessageBytes()) + require.NoError(t, err) + + bid.Signature = signature + + return bid +} diff --git a/timeboost/bidder_client.go b/timeboost/bidder_client.go new file mode 100644 index 0000000000..5581d8544c --- /dev/null +++ b/timeboost/bidder_client.go @@ -0,0 +1,228 @@ +package timeboost + +import ( + "context" + "fmt" + "math/big" + "time" + + "github.com/pkg/errors" + "github.com/spf13/pflag" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/offchainlabs/nitro/cmd/genericconf" + "github.com/offchainlabs/nitro/cmd/util" + "github.com/offchainlabs/nitro/solgen/go/express_lane_auctiongen" + "github.com/offchainlabs/nitro/timeboost/bindings" + "github.com/offchainlabs/nitro/util/arbmath" + "github.com/offchainlabs/nitro/util/containers" + "github.com/offchainlabs/nitro/util/signature" + "github.com/offchainlabs/nitro/util/stopwaiter" +) + +type BidderClientConfigFetcher func() *BidderClientConfig + +type BidderClientConfig struct { + Wallet genericconf.WalletConfig `koanf:"wallet"` + ArbitrumNodeEndpoint string `koanf:"arbitrum-node-endpoint"` + BidValidatorEndpoint string `koanf:"bid-validator-endpoint"` + AuctionContractAddress string `koanf:"auction-contract-address"` + DepositGwei int `koanf:"deposit-gwei"` + BidGwei int `koanf:"bid-gwei"` +} + +var DefaultBidderClientConfig = BidderClientConfig{ + ArbitrumNodeEndpoint: "http://localhost:8547", + BidValidatorEndpoint: "http://localhost:9372", +} + +var TestBidderClientConfig = BidderClientConfig{ + ArbitrumNodeEndpoint: "http://localhost:8547", + BidValidatorEndpoint: "http://localhost:9372", +} + +func BidderClientConfigAddOptions(f *pflag.FlagSet) { + genericconf.WalletConfigAddOptions("wallet", f, "wallet for bidder") + f.String("arbitrum-node-endpoint", DefaultBidderClientConfig.ArbitrumNodeEndpoint, "arbitrum node RPC http endpoint") + f.String("bid-validator-endpoint", DefaultBidderClientConfig.BidValidatorEndpoint, "bid validator http endpoint") + f.String("auction-contract-address", DefaultBidderClientConfig.AuctionContractAddress, "express lane auction contract address") + f.Int("deposit-gwei", DefaultBidderClientConfig.DepositGwei, "deposit amount in gwei to take from bidder's account and send to auction contract") + f.Int("bid-gwei", DefaultBidderClientConfig.BidGwei, "bid amount in gwei, bidder must have already deposited enough into the auction contract") +} + +type BidderClient struct { + stopwaiter.StopWaiter + chainId *big.Int + auctionContractAddress common.Address + biddingTokenAddress common.Address + txOpts *bind.TransactOpts + client *ethclient.Client + signer signature.DataSignerFunc + auctionContract *express_lane_auctiongen.ExpressLaneAuction + biddingTokenContract *bindings.MockERC20 + auctioneerClient *rpc.Client + initialRoundTimestamp time.Time + roundDuration time.Duration + domainValue []byte +} + +func NewBidderClient( + ctx context.Context, + configFetcher BidderClientConfigFetcher, +) (*BidderClient, error) { + cfg := configFetcher() + _ = cfg.BidGwei // These fields are used from cmd/bidder-client + _ = cfg.DepositGwei // this marks them as used for the linter. + if cfg.AuctionContractAddress == "" { + return nil, fmt.Errorf("auction contract address cannot be empty") + } + auctionContractAddr := common.HexToAddress(cfg.AuctionContractAddress) + client, err := rpc.DialContext(ctx, cfg.ArbitrumNodeEndpoint) + if err != nil { + return nil, err + } + arbClient := ethclient.NewClient(client) + chainId, err := arbClient.ChainID(ctx) + if err != nil { + return nil, err + } + auctionContract, err := express_lane_auctiongen.NewExpressLaneAuction(auctionContractAddr, arbClient) + if err != nil { + return nil, err + } + var roundTimingInfo RoundTimingInfo + roundTimingInfo, err = auctionContract.RoundTimingInfo(&bind.CallOpts{ + Context: ctx, + }) + if err != nil { + return nil, err + } + if err = roundTimingInfo.Validate(nil); err != nil { + return nil, err + } + initialTimestamp := time.Unix(int64(roundTimingInfo.OffsetTimestamp), 0) + roundDuration := arbmath.SaturatingCast[time.Duration](roundTimingInfo.RoundDurationSeconds) * time.Second + txOpts, signer, err := util.OpenWallet("bidder-client", &cfg.Wallet, chainId) + if err != nil { + return nil, errors.Wrap(err, "opening wallet") + } + + biddingTokenAddr, err := auctionContract.BiddingToken(&bind.CallOpts{ + Context: ctx, + }) + if err != nil { + return nil, errors.Wrap(err, "fetching bidding token") + } + biddingTokenContract, err := bindings.NewMockERC20(biddingTokenAddr, arbClient) + if err != nil { + return nil, errors.Wrap(err, "creating bindings to bidding token contract") + } + + bidValidatorClient, err := rpc.DialContext(ctx, cfg.BidValidatorEndpoint) + if err != nil { + return nil, err + } + return &BidderClient{ + chainId: chainId, + auctionContractAddress: auctionContractAddr, + biddingTokenAddress: biddingTokenAddr, + client: arbClient, + txOpts: txOpts, + signer: signer, + auctionContract: auctionContract, + biddingTokenContract: biddingTokenContract, + auctioneerClient: bidValidatorClient, + initialRoundTimestamp: initialTimestamp, + roundDuration: roundDuration, + domainValue: domainValue, + }, nil +} + +func (bd *BidderClient) Start(ctx_in context.Context) { + bd.StopWaiter.Start(ctx_in, bd) +} + +// Deposit into the auction contract for the account configured by the BidderClient wallet. +// Handles approving the auction contract to spend the erc20 on behalf of the account. +func (bd *BidderClient) Deposit(ctx context.Context, amount *big.Int) error { + allowance, err := bd.biddingTokenContract.Allowance(&bind.CallOpts{ + Context: ctx, + }, bd.txOpts.From, bd.auctionContractAddress) + if err != nil { + return err + } + + if amount.Cmp(allowance) > 0 { + log.Info("Spend allowance of bidding token from auction contract is insufficient, increasing allowance", "from", bd.txOpts.From, "auctionContract", bd.auctionContractAddress, "biddingToken", bd.biddingTokenAddress, "amount", amount.Int64()) + // defecit := arbmath.BigSub(allowance, amount) + tx, err := bd.biddingTokenContract.Approve(bd.txOpts, bd.auctionContractAddress, amount) + if err != nil { + return err + } + receipt, err := bind.WaitMined(ctx, bd.client, tx) + if err != nil { + return err + } + if receipt.Status != types.ReceiptStatusSuccessful { + return errors.New("approval failed") + } + } + + tx, err := bd.auctionContract.Deposit(bd.txOpts, amount) + if err != nil { + return err + } + receipt, err := bind.WaitMined(ctx, bd.client, tx) + if err != nil { + return err + } + if receipt.Status != types.ReceiptStatusSuccessful { + return errors.New("deposit failed") + } + return nil +} + +func (bd *BidderClient) Bid( + ctx context.Context, amount *big.Int, expressLaneController common.Address, +) (*Bid, error) { + if (expressLaneController == common.Address{}) { + expressLaneController = bd.txOpts.From + } + newBid := &Bid{ + ChainId: bd.chainId, + ExpressLaneController: expressLaneController, + AuctionContractAddress: bd.auctionContractAddress, + Round: CurrentRound(bd.initialRoundTimestamp, bd.roundDuration) + 1, + Amount: amount, + Signature: nil, + } + sig, err := bd.signer(buildEthereumSignedMessage(newBid.ToMessageBytes())) + if err != nil { + return nil, err + } + sig[64] += 27 + newBid.Signature = sig + promise := bd.submitBid(newBid) + if _, err := promise.Await(ctx); err != nil { + return nil, err + } + return newBid, nil +} + +func (bd *BidderClient) submitBid(bid *Bid) containers.PromiseInterface[struct{}] { + return stopwaiter.LaunchPromiseThread[struct{}](bd, func(ctx context.Context) (struct{}, error) { + err := bd.auctioneerClient.CallContext(ctx, nil, "auctioneer_submitBid", bid.ToJson()) + return struct{}{}, err + }) +} + +func buildEthereumSignedMessage(msg []byte) []byte { + return crypto.Keccak256(append([]byte(fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(msg))), msg...)) +} diff --git a/timeboost/bindings/mockerc20.go b/timeboost/bindings/mockerc20.go new file mode 100644 index 0000000000..c65ac35cda --- /dev/null +++ b/timeboost/bindings/mockerc20.go @@ -0,0 +1,906 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package bindings + +import ( + "errors" + "math/big" + "strings" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType +) + +// MockERC20MetaData contains all meta data concerning the MockERC20 contract. +var MockERC20MetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"DOMAIN_SEPARATOR\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"_burn\",\"inputs\":[{\"name\":\"from\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"_mint\",\"inputs\":[{\"name\":\"to\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"allowance\",\"inputs\":[{\"name\":\"owner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"spender\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"approve\",\"inputs\":[{\"name\":\"spender\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"balanceOf\",\"inputs\":[{\"name\":\"owner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"decimals\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"uint8\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"initialize\",\"inputs\":[{\"name\":\"name_\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"symbol_\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"decimals_\",\"type\":\"uint8\",\"internalType\":\"uint8\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"name\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"nonces\",\"inputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"permit\",\"inputs\":[{\"name\":\"owner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"spender\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"deadline\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"v\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"r\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"s\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"symbol\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"totalSupply\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"transfer\",\"inputs\":[{\"name\":\"to\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"transferFrom\",\"inputs\":[{\"name\":\"from\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"to\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"Approval\",\"inputs\":[{\"name\":\"owner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"spender\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Transfer\",\"inputs\":[{\"name\":\"from\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"to\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false}]", + Bin: "0x608060405234801561001057600080fd5b50610fb2806100206000396000f3fe608060405234801561001057600080fd5b50600436106100f55760003560e01c80634e6ec2471161009757806395d89b411161006657806395d89b4114610201578063a9059cbb14610209578063d505accf1461021c578063dd62ed3e1461022f57600080fd5b80634e6ec247146101925780636161eb18146101a557806370a08231146101b85780637ecebe00146101e157600080fd5b806318160ddd116100d357806318160ddd1461015057806323b872dd14610162578063313ce567146101755780633644e5151461018a57600080fd5b806306fdde03146100fa578063095ea7b3146101185780631624f6c61461013b575b600080fd5b610102610268565b60405161010f9190610a98565b60405180910390f35b61012b610126366004610b02565b6102fa565b604051901515815260200161010f565b61014e610149366004610be0565b610367565b005b6003545b60405190815260200161010f565b61012b610170366004610c54565b610406565b60025460405160ff909116815260200161010f565b610154610509565b61014e6101a0366004610b02565b61052f565b61014e6101b3366004610b02565b6105ac565b6101546101c6366004610c90565b6001600160a01b031660009081526004602052604090205490565b6101546101ef366004610c90565b60086020526000908152604090205481565b610102610624565b61012b610217366004610b02565b610633565b61014e61022a366004610cab565b6106b8565b61015461023d366004610d15565b6001600160a01b03918216600090815260056020908152604080832093909416825291909152205490565b60606000805461027790610d48565b80601f01602080910402602001604051908101604052809291908181526020018280546102a390610d48565b80156102f05780601f106102c5576101008083540402835291602001916102f0565b820191906000526020600020905b8154815290600101906020018083116102d357829003601f168201915b5050505050905090565b3360008181526005602090815260408083206001600160a01b038716808552925280832085905551919290917f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925906103559086815260200190565b60405180910390a35060015b92915050565b60095460ff16156103b55760405162461bcd60e51b81526020600482015260136024820152721053149150511657d253925512505312569151606a1b60448201526064015b60405180910390fd5b60006103c18482610dd1565b5060016103ce8382610dd1565b506002805460ff191660ff83161790556103e6610916565b6006556103f161092f565b60075550506009805460ff1916600117905550565b6001600160a01b038316600090815260056020908152604080832033845290915281205460001981146104625761043d81846109d2565b6001600160a01b03861660009081526005602090815260408083203384529091529020555b6001600160a01b03851660009081526004602052604090205461048590846109d2565b6001600160a01b0380871660009081526004602052604080822093909355908616815220546104b49084610a35565b6001600160a01b038086166000818152600460205260409081902093909355915190871690600080516020610f5d833981519152906104f69087815260200190565b60405180910390a3506001949350505050565b6000600654610516610916565b146105285761052361092f565b905090565b5060075490565b61053b60035482610a35565b6003556001600160a01b0382166000908152600460205260409020546105619082610a35565b6001600160a01b038316600081815260046020526040808220939093559151909190600080516020610f5d833981519152906105a09085815260200190565b60405180910390a35050565b6001600160a01b0382166000908152600460205260409020546105cf90826109d2565b6001600160a01b0383166000908152600460205260409020556003546105f590826109d2565b6003556040518181526000906001600160a01b03841690600080516020610f5d833981519152906020016105a0565b60606001805461027790610d48565b3360009081526004602052604081205461064d90836109d2565b33600090815260046020526040808220929092556001600160a01b038516815220546106799083610a35565b6001600160a01b038416600081815260046020526040908190209290925590513390600080516020610f5d833981519152906103559086815260200190565b428410156107085760405162461bcd60e51b815260206004820152601760248201527f5045524d49545f444541444c494e455f4558504952454400000000000000000060448201526064016103ac565b60006001610714610509565b6001600160a01b038a16600090815260086020526040812080547f6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9928d928d928d9290919061076283610ea7565b909155506040805160208101969096526001600160a01b0394851690860152929091166060840152608083015260a082015260c0810188905260e001604051602081830303815290604052805190602001206040516020016107db92919061190160f01b81526002810192909252602282015260420190565b60408051601f198184030181528282528051602091820120600084529083018083525260ff871690820152606081018590526080810184905260a0016020604051602081039080840390855afa158015610839573d6000803e3d6000fd5b5050604051601f1901519150506001600160a01b0381161580159061086f5750876001600160a01b0316816001600160a01b0316145b6108ac5760405162461bcd60e51b815260206004820152600e60248201526d24a72b20a624a22fa9a4a3a722a960911b60448201526064016103ac565b6001600160a01b0381811660009081526005602090815260408083208b8516808552908352928190208a90555189815291928b16917f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925910160405180910390a35050505050505050565b6000610a948061092863ffffffff8216565b9250505090565b60007f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f60006040516109619190610ec0565b60405180910390207fc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6610992610916565b604080516020810195909552840192909252606083015260808201523060a082015260c00160405160208183030381529060405280519060200120905090565b600081831015610a245760405162461bcd60e51b815260206004820152601c60248201527f45524332303a207375627472616374696f6e20756e646572666c6f770000000060448201526064016103ac565b610a2e8284610f36565b9392505050565b600080610a428385610f49565b905083811015610a2e5760405162461bcd60e51b815260206004820152601860248201527f45524332303a206164646974696f6e206f766572666c6f77000000000000000060448201526064016103ac565b4690565b600060208083528351808285015260005b81811015610ac557858101830151858201604001528201610aa9565b506000604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b0381168114610afd57600080fd5b919050565b60008060408385031215610b1557600080fd5b610b1e83610ae6565b946020939093013593505050565b634e487b7160e01b600052604160045260246000fd5b600082601f830112610b5357600080fd5b813567ffffffffffffffff80821115610b6e57610b6e610b2c565b604051601f8301601f19908116603f01168101908282118183101715610b9657610b96610b2c565b81604052838152866020858801011115610baf57600080fd5b836020870160208301376000602085830101528094505050505092915050565b803560ff81168114610afd57600080fd5b600080600060608486031215610bf557600080fd5b833567ffffffffffffffff80821115610c0d57600080fd5b610c1987838801610b42565b94506020860135915080821115610c2f57600080fd5b50610c3c86828701610b42565b925050610c4b60408501610bcf565b90509250925092565b600080600060608486031215610c6957600080fd5b610c7284610ae6565b9250610c8060208501610ae6565b9150604084013590509250925092565b600060208284031215610ca257600080fd5b610a2e82610ae6565b600080600080600080600060e0888a031215610cc657600080fd5b610ccf88610ae6565b9650610cdd60208901610ae6565b95506040880135945060608801359350610cf960808901610bcf565b925060a0880135915060c0880135905092959891949750929550565b60008060408385031215610d2857600080fd5b610d3183610ae6565b9150610d3f60208401610ae6565b90509250929050565b600181811c90821680610d5c57607f821691505b602082108103610d7c57634e487b7160e01b600052602260045260246000fd5b50919050565b601f821115610dcc57600081815260208120601f850160051c81016020861015610da95750805b601f850160051c820191505b81811015610dc857828155600101610db5565b5050505b505050565b815167ffffffffffffffff811115610deb57610deb610b2c565b610dff81610df98454610d48565b84610d82565b602080601f831160018114610e345760008415610e1c5750858301515b600019600386901b1c1916600185901b178555610dc8565b600085815260208120601f198616915b82811015610e6357888601518255948401946001909101908401610e44565b5085821015610e815787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b634e487b7160e01b600052601160045260246000fd5b600060018201610eb957610eb9610e91565b5060010190565b6000808354610ece81610d48565b60018281168015610ee65760018114610efb57610f2a565b60ff1984168752821515830287019450610f2a565b8760005260208060002060005b85811015610f215781548a820152908401908201610f08565b50505082870194505b50929695505050505050565b8181038181111561036157610361610e91565b8082018082111561036157610361610e9156feddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3efa26469706673582212202d566c07dcb56bf37a267c42ca84957e1e7a1464769616ab9c405a2a439c68f264736f6c63430008130033", +} + +// MockERC20ABI is the input ABI used to generate the binding from. +// Deprecated: Use MockERC20MetaData.ABI instead. +var MockERC20ABI = MockERC20MetaData.ABI + +// MockERC20Bin is the compiled bytecode used for deploying new contracts. +// Deprecated: Use MockERC20MetaData.Bin instead. +var MockERC20Bin = MockERC20MetaData.Bin + +// DeployMockERC20 deploys a new Ethereum contract, binding an instance of MockERC20 to it. +func DeployMockERC20(auth *bind.TransactOpts, backend bind.ContractBackend) (common.Address, *types.Transaction, *MockERC20, error) { + parsed, err := MockERC20MetaData.GetAbi() + if err != nil { + return common.Address{}, nil, nil, err + } + if parsed == nil { + return common.Address{}, nil, nil, errors.New("GetABI returned nil") + } + + address, tx, contract, err := bind.DeployContract(auth, *parsed, common.FromHex(MockERC20Bin), backend) + if err != nil { + return common.Address{}, nil, nil, err + } + return address, tx, &MockERC20{MockERC20Caller: MockERC20Caller{contract: contract}, MockERC20Transactor: MockERC20Transactor{contract: contract}, MockERC20Filterer: MockERC20Filterer{contract: contract}}, nil +} + +// MockERC20 is an auto generated Go binding around an Ethereum contract. +type MockERC20 struct { + MockERC20Caller // Read-only binding to the contract + MockERC20Transactor // Write-only binding to the contract + MockERC20Filterer // Log filterer for contract events +} + +// MockERC20Caller is an auto generated read-only Go binding around an Ethereum contract. +type MockERC20Caller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// MockERC20Transactor is an auto generated write-only Go binding around an Ethereum contract. +type MockERC20Transactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// MockERC20Filterer is an auto generated log filtering Go binding around an Ethereum contract events. +type MockERC20Filterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// MockERC20Session is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type MockERC20Session struct { + Contract *MockERC20 // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// MockERC20CallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type MockERC20CallerSession struct { + Contract *MockERC20Caller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// MockERC20TransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type MockERC20TransactorSession struct { + Contract *MockERC20Transactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// MockERC20Raw is an auto generated low-level Go binding around an Ethereum contract. +type MockERC20Raw struct { + Contract *MockERC20 // Generic contract binding to access the raw methods on +} + +// MockERC20CallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type MockERC20CallerRaw struct { + Contract *MockERC20Caller // Generic read-only contract binding to access the raw methods on +} + +// MockERC20TransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type MockERC20TransactorRaw struct { + Contract *MockERC20Transactor // Generic write-only contract binding to access the raw methods on +} + +// NewMockERC20 creates a new instance of MockERC20, bound to a specific deployed contract. +func NewMockERC20(address common.Address, backend bind.ContractBackend) (*MockERC20, error) { + contract, err := bindMockERC20(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &MockERC20{MockERC20Caller: MockERC20Caller{contract: contract}, MockERC20Transactor: MockERC20Transactor{contract: contract}, MockERC20Filterer: MockERC20Filterer{contract: contract}}, nil +} + +// NewMockERC20Caller creates a new read-only instance of MockERC20, bound to a specific deployed contract. +func NewMockERC20Caller(address common.Address, caller bind.ContractCaller) (*MockERC20Caller, error) { + contract, err := bindMockERC20(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &MockERC20Caller{contract: contract}, nil +} + +// NewMockERC20Transactor creates a new write-only instance of MockERC20, bound to a specific deployed contract. +func NewMockERC20Transactor(address common.Address, transactor bind.ContractTransactor) (*MockERC20Transactor, error) { + contract, err := bindMockERC20(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &MockERC20Transactor{contract: contract}, nil +} + +// NewMockERC20Filterer creates a new log filterer instance of MockERC20, bound to a specific deployed contract. +func NewMockERC20Filterer(address common.Address, filterer bind.ContractFilterer) (*MockERC20Filterer, error) { + contract, err := bindMockERC20(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &MockERC20Filterer{contract: contract}, nil +} + +// bindMockERC20 binds a generic wrapper to an already deployed contract. +func bindMockERC20(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := MockERC20MetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_MockERC20 *MockERC20Raw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _MockERC20.Contract.MockERC20Caller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_MockERC20 *MockERC20Raw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _MockERC20.Contract.MockERC20Transactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_MockERC20 *MockERC20Raw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _MockERC20.Contract.MockERC20Transactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_MockERC20 *MockERC20CallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _MockERC20.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_MockERC20 *MockERC20TransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _MockERC20.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_MockERC20 *MockERC20TransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _MockERC20.Contract.contract.Transact(opts, method, params...) +} + +// DOMAINSEPARATOR is a free data retrieval call binding the contract method 0x3644e515. +// +// Solidity: function DOMAIN_SEPARATOR() view returns(bytes32) +func (_MockERC20 *MockERC20Caller) DOMAINSEPARATOR(opts *bind.CallOpts) ([32]byte, error) { + var out []interface{} + err := _MockERC20.contract.Call(opts, &out, "DOMAIN_SEPARATOR") + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// DOMAINSEPARATOR is a free data retrieval call binding the contract method 0x3644e515. +// +// Solidity: function DOMAIN_SEPARATOR() view returns(bytes32) +func (_MockERC20 *MockERC20Session) DOMAINSEPARATOR() ([32]byte, error) { + return _MockERC20.Contract.DOMAINSEPARATOR(&_MockERC20.CallOpts) +} + +// DOMAINSEPARATOR is a free data retrieval call binding the contract method 0x3644e515. +// +// Solidity: function DOMAIN_SEPARATOR() view returns(bytes32) +func (_MockERC20 *MockERC20CallerSession) DOMAINSEPARATOR() ([32]byte, error) { + return _MockERC20.Contract.DOMAINSEPARATOR(&_MockERC20.CallOpts) +} + +// Allowance is a free data retrieval call binding the contract method 0xdd62ed3e. +// +// Solidity: function allowance(address owner, address spender) view returns(uint256) +func (_MockERC20 *MockERC20Caller) Allowance(opts *bind.CallOpts, owner common.Address, spender common.Address) (*big.Int, error) { + var out []interface{} + err := _MockERC20.contract.Call(opts, &out, "allowance", owner, spender) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// Allowance is a free data retrieval call binding the contract method 0xdd62ed3e. +// +// Solidity: function allowance(address owner, address spender) view returns(uint256) +func (_MockERC20 *MockERC20Session) Allowance(owner common.Address, spender common.Address) (*big.Int, error) { + return _MockERC20.Contract.Allowance(&_MockERC20.CallOpts, owner, spender) +} + +// Allowance is a free data retrieval call binding the contract method 0xdd62ed3e. +// +// Solidity: function allowance(address owner, address spender) view returns(uint256) +func (_MockERC20 *MockERC20CallerSession) Allowance(owner common.Address, spender common.Address) (*big.Int, error) { + return _MockERC20.Contract.Allowance(&_MockERC20.CallOpts, owner, spender) +} + +// BalanceOf is a free data retrieval call binding the contract method 0x70a08231. +// +// Solidity: function balanceOf(address owner) view returns(uint256) +func (_MockERC20 *MockERC20Caller) BalanceOf(opts *bind.CallOpts, owner common.Address) (*big.Int, error) { + var out []interface{} + err := _MockERC20.contract.Call(opts, &out, "balanceOf", owner) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// BalanceOf is a free data retrieval call binding the contract method 0x70a08231. +// +// Solidity: function balanceOf(address owner) view returns(uint256) +func (_MockERC20 *MockERC20Session) BalanceOf(owner common.Address) (*big.Int, error) { + return _MockERC20.Contract.BalanceOf(&_MockERC20.CallOpts, owner) +} + +// BalanceOf is a free data retrieval call binding the contract method 0x70a08231. +// +// Solidity: function balanceOf(address owner) view returns(uint256) +func (_MockERC20 *MockERC20CallerSession) BalanceOf(owner common.Address) (*big.Int, error) { + return _MockERC20.Contract.BalanceOf(&_MockERC20.CallOpts, owner) +} + +// Decimals is a free data retrieval call binding the contract method 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (_MockERC20 *MockERC20Caller) Decimals(opts *bind.CallOpts) (uint8, error) { + var out []interface{} + err := _MockERC20.contract.Call(opts, &out, "decimals") + + if err != nil { + return *new(uint8), err + } + + out0 := *abi.ConvertType(out[0], new(uint8)).(*uint8) + + return out0, err + +} + +// Decimals is a free data retrieval call binding the contract method 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (_MockERC20 *MockERC20Session) Decimals() (uint8, error) { + return _MockERC20.Contract.Decimals(&_MockERC20.CallOpts) +} + +// Decimals is a free data retrieval call binding the contract method 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (_MockERC20 *MockERC20CallerSession) Decimals() (uint8, error) { + return _MockERC20.Contract.Decimals(&_MockERC20.CallOpts) +} + +// Name is a free data retrieval call binding the contract method 0x06fdde03. +// +// Solidity: function name() view returns(string) +func (_MockERC20 *MockERC20Caller) Name(opts *bind.CallOpts) (string, error) { + var out []interface{} + err := _MockERC20.contract.Call(opts, &out, "name") + + if err != nil { + return *new(string), err + } + + out0 := *abi.ConvertType(out[0], new(string)).(*string) + + return out0, err + +} + +// Name is a free data retrieval call binding the contract method 0x06fdde03. +// +// Solidity: function name() view returns(string) +func (_MockERC20 *MockERC20Session) Name() (string, error) { + return _MockERC20.Contract.Name(&_MockERC20.CallOpts) +} + +// Name is a free data retrieval call binding the contract method 0x06fdde03. +// +// Solidity: function name() view returns(string) +func (_MockERC20 *MockERC20CallerSession) Name() (string, error) { + return _MockERC20.Contract.Name(&_MockERC20.CallOpts) +} + +// Nonces is a free data retrieval call binding the contract method 0x7ecebe00. +// +// Solidity: function nonces(address ) view returns(uint256) +func (_MockERC20 *MockERC20Caller) Nonces(opts *bind.CallOpts, arg0 common.Address) (*big.Int, error) { + var out []interface{} + err := _MockERC20.contract.Call(opts, &out, "nonces", arg0) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// Nonces is a free data retrieval call binding the contract method 0x7ecebe00. +// +// Solidity: function nonces(address ) view returns(uint256) +func (_MockERC20 *MockERC20Session) Nonces(arg0 common.Address) (*big.Int, error) { + return _MockERC20.Contract.Nonces(&_MockERC20.CallOpts, arg0) +} + +// Nonces is a free data retrieval call binding the contract method 0x7ecebe00. +// +// Solidity: function nonces(address ) view returns(uint256) +func (_MockERC20 *MockERC20CallerSession) Nonces(arg0 common.Address) (*big.Int, error) { + return _MockERC20.Contract.Nonces(&_MockERC20.CallOpts, arg0) +} + +// Symbol is a free data retrieval call binding the contract method 0x95d89b41. +// +// Solidity: function symbol() view returns(string) +func (_MockERC20 *MockERC20Caller) Symbol(opts *bind.CallOpts) (string, error) { + var out []interface{} + err := _MockERC20.contract.Call(opts, &out, "symbol") + + if err != nil { + return *new(string), err + } + + out0 := *abi.ConvertType(out[0], new(string)).(*string) + + return out0, err + +} + +// Symbol is a free data retrieval call binding the contract method 0x95d89b41. +// +// Solidity: function symbol() view returns(string) +func (_MockERC20 *MockERC20Session) Symbol() (string, error) { + return _MockERC20.Contract.Symbol(&_MockERC20.CallOpts) +} + +// Symbol is a free data retrieval call binding the contract method 0x95d89b41. +// +// Solidity: function symbol() view returns(string) +func (_MockERC20 *MockERC20CallerSession) Symbol() (string, error) { + return _MockERC20.Contract.Symbol(&_MockERC20.CallOpts) +} + +// TotalSupply is a free data retrieval call binding the contract method 0x18160ddd. +// +// Solidity: function totalSupply() view returns(uint256) +func (_MockERC20 *MockERC20Caller) TotalSupply(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _MockERC20.contract.Call(opts, &out, "totalSupply") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// TotalSupply is a free data retrieval call binding the contract method 0x18160ddd. +// +// Solidity: function totalSupply() view returns(uint256) +func (_MockERC20 *MockERC20Session) TotalSupply() (*big.Int, error) { + return _MockERC20.Contract.TotalSupply(&_MockERC20.CallOpts) +} + +// TotalSupply is a free data retrieval call binding the contract method 0x18160ddd. +// +// Solidity: function totalSupply() view returns(uint256) +func (_MockERC20 *MockERC20CallerSession) TotalSupply() (*big.Int, error) { + return _MockERC20.Contract.TotalSupply(&_MockERC20.CallOpts) +} + +// Burn is a paid mutator transaction binding the contract method 0x6161eb18. +// +// Solidity: function _burn(address from, uint256 amount) returns() +func (_MockERC20 *MockERC20Transactor) Burn(opts *bind.TransactOpts, from common.Address, amount *big.Int) (*types.Transaction, error) { + return _MockERC20.contract.Transact(opts, "_burn", from, amount) +} + +// Burn is a paid mutator transaction binding the contract method 0x6161eb18. +// +// Solidity: function _burn(address from, uint256 amount) returns() +func (_MockERC20 *MockERC20Session) Burn(from common.Address, amount *big.Int) (*types.Transaction, error) { + return _MockERC20.Contract.Burn(&_MockERC20.TransactOpts, from, amount) +} + +// Burn is a paid mutator transaction binding the contract method 0x6161eb18. +// +// Solidity: function _burn(address from, uint256 amount) returns() +func (_MockERC20 *MockERC20TransactorSession) Burn(from common.Address, amount *big.Int) (*types.Transaction, error) { + return _MockERC20.Contract.Burn(&_MockERC20.TransactOpts, from, amount) +} + +// Mint is a paid mutator transaction binding the contract method 0x4e6ec247. +// +// Solidity: function _mint(address to, uint256 amount) returns() +func (_MockERC20 *MockERC20Transactor) Mint(opts *bind.TransactOpts, to common.Address, amount *big.Int) (*types.Transaction, error) { + return _MockERC20.contract.Transact(opts, "_mint", to, amount) +} + +// Mint is a paid mutator transaction binding the contract method 0x4e6ec247. +// +// Solidity: function _mint(address to, uint256 amount) returns() +func (_MockERC20 *MockERC20Session) Mint(to common.Address, amount *big.Int) (*types.Transaction, error) { + return _MockERC20.Contract.Mint(&_MockERC20.TransactOpts, to, amount) +} + +// Mint is a paid mutator transaction binding the contract method 0x4e6ec247. +// +// Solidity: function _mint(address to, uint256 amount) returns() +func (_MockERC20 *MockERC20TransactorSession) Mint(to common.Address, amount *big.Int) (*types.Transaction, error) { + return _MockERC20.Contract.Mint(&_MockERC20.TransactOpts, to, amount) +} + +// Approve is a paid mutator transaction binding the contract method 0x095ea7b3. +// +// Solidity: function approve(address spender, uint256 amount) returns(bool) +func (_MockERC20 *MockERC20Transactor) Approve(opts *bind.TransactOpts, spender common.Address, amount *big.Int) (*types.Transaction, error) { + return _MockERC20.contract.Transact(opts, "approve", spender, amount) +} + +// Approve is a paid mutator transaction binding the contract method 0x095ea7b3. +// +// Solidity: function approve(address spender, uint256 amount) returns(bool) +func (_MockERC20 *MockERC20Session) Approve(spender common.Address, amount *big.Int) (*types.Transaction, error) { + return _MockERC20.Contract.Approve(&_MockERC20.TransactOpts, spender, amount) +} + +// Approve is a paid mutator transaction binding the contract method 0x095ea7b3. +// +// Solidity: function approve(address spender, uint256 amount) returns(bool) +func (_MockERC20 *MockERC20TransactorSession) Approve(spender common.Address, amount *big.Int) (*types.Transaction, error) { + return _MockERC20.Contract.Approve(&_MockERC20.TransactOpts, spender, amount) +} + +// Initialize is a paid mutator transaction binding the contract method 0x1624f6c6. +// +// Solidity: function initialize(string name_, string symbol_, uint8 decimals_) returns() +func (_MockERC20 *MockERC20Transactor) Initialize(opts *bind.TransactOpts, name_ string, symbol_ string, decimals_ uint8) (*types.Transaction, error) { + return _MockERC20.contract.Transact(opts, "initialize", name_, symbol_, decimals_) +} + +// Initialize is a paid mutator transaction binding the contract method 0x1624f6c6. +// +// Solidity: function initialize(string name_, string symbol_, uint8 decimals_) returns() +func (_MockERC20 *MockERC20Session) Initialize(name_ string, symbol_ string, decimals_ uint8) (*types.Transaction, error) { + return _MockERC20.Contract.Initialize(&_MockERC20.TransactOpts, name_, symbol_, decimals_) +} + +// Initialize is a paid mutator transaction binding the contract method 0x1624f6c6. +// +// Solidity: function initialize(string name_, string symbol_, uint8 decimals_) returns() +func (_MockERC20 *MockERC20TransactorSession) Initialize(name_ string, symbol_ string, decimals_ uint8) (*types.Transaction, error) { + return _MockERC20.Contract.Initialize(&_MockERC20.TransactOpts, name_, symbol_, decimals_) +} + +// Permit is a paid mutator transaction binding the contract method 0xd505accf. +// +// Solidity: function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) returns() +func (_MockERC20 *MockERC20Transactor) Permit(opts *bind.TransactOpts, owner common.Address, spender common.Address, value *big.Int, deadline *big.Int, v uint8, r [32]byte, s [32]byte) (*types.Transaction, error) { + return _MockERC20.contract.Transact(opts, "permit", owner, spender, value, deadline, v, r, s) +} + +// Permit is a paid mutator transaction binding the contract method 0xd505accf. +// +// Solidity: function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) returns() +func (_MockERC20 *MockERC20Session) Permit(owner common.Address, spender common.Address, value *big.Int, deadline *big.Int, v uint8, r [32]byte, s [32]byte) (*types.Transaction, error) { + return _MockERC20.Contract.Permit(&_MockERC20.TransactOpts, owner, spender, value, deadline, v, r, s) +} + +// Permit is a paid mutator transaction binding the contract method 0xd505accf. +// +// Solidity: function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) returns() +func (_MockERC20 *MockERC20TransactorSession) Permit(owner common.Address, spender common.Address, value *big.Int, deadline *big.Int, v uint8, r [32]byte, s [32]byte) (*types.Transaction, error) { + return _MockERC20.Contract.Permit(&_MockERC20.TransactOpts, owner, spender, value, deadline, v, r, s) +} + +// Transfer is a paid mutator transaction binding the contract method 0xa9059cbb. +// +// Solidity: function transfer(address to, uint256 amount) returns(bool) +func (_MockERC20 *MockERC20Transactor) Transfer(opts *bind.TransactOpts, to common.Address, amount *big.Int) (*types.Transaction, error) { + return _MockERC20.contract.Transact(opts, "transfer", to, amount) +} + +// Transfer is a paid mutator transaction binding the contract method 0xa9059cbb. +// +// Solidity: function transfer(address to, uint256 amount) returns(bool) +func (_MockERC20 *MockERC20Session) Transfer(to common.Address, amount *big.Int) (*types.Transaction, error) { + return _MockERC20.Contract.Transfer(&_MockERC20.TransactOpts, to, amount) +} + +// Transfer is a paid mutator transaction binding the contract method 0xa9059cbb. +// +// Solidity: function transfer(address to, uint256 amount) returns(bool) +func (_MockERC20 *MockERC20TransactorSession) Transfer(to common.Address, amount *big.Int) (*types.Transaction, error) { + return _MockERC20.Contract.Transfer(&_MockERC20.TransactOpts, to, amount) +} + +// TransferFrom is a paid mutator transaction binding the contract method 0x23b872dd. +// +// Solidity: function transferFrom(address from, address to, uint256 amount) returns(bool) +func (_MockERC20 *MockERC20Transactor) TransferFrom(opts *bind.TransactOpts, from common.Address, to common.Address, amount *big.Int) (*types.Transaction, error) { + return _MockERC20.contract.Transact(opts, "transferFrom", from, to, amount) +} + +// TransferFrom is a paid mutator transaction binding the contract method 0x23b872dd. +// +// Solidity: function transferFrom(address from, address to, uint256 amount) returns(bool) +func (_MockERC20 *MockERC20Session) TransferFrom(from common.Address, to common.Address, amount *big.Int) (*types.Transaction, error) { + return _MockERC20.Contract.TransferFrom(&_MockERC20.TransactOpts, from, to, amount) +} + +// TransferFrom is a paid mutator transaction binding the contract method 0x23b872dd. +// +// Solidity: function transferFrom(address from, address to, uint256 amount) returns(bool) +func (_MockERC20 *MockERC20TransactorSession) TransferFrom(from common.Address, to common.Address, amount *big.Int) (*types.Transaction, error) { + return _MockERC20.Contract.TransferFrom(&_MockERC20.TransactOpts, from, to, amount) +} + +// MockERC20ApprovalIterator is returned from FilterApproval and is used to iterate over the raw logs and unpacked data for Approval events raised by the MockERC20 contract. +type MockERC20ApprovalIterator struct { + Event *MockERC20Approval // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *MockERC20ApprovalIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(MockERC20Approval) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(MockERC20Approval) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *MockERC20ApprovalIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *MockERC20ApprovalIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// MockERC20Approval represents a Approval event raised by the MockERC20 contract. +type MockERC20Approval struct { + Owner common.Address + Spender common.Address + Value *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterApproval is a free log retrieval operation binding the contract event 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925. +// +// Solidity: event Approval(address indexed owner, address indexed spender, uint256 value) +func (_MockERC20 *MockERC20Filterer) FilterApproval(opts *bind.FilterOpts, owner []common.Address, spender []common.Address) (*MockERC20ApprovalIterator, error) { + + var ownerRule []interface{} + for _, ownerItem := range owner { + ownerRule = append(ownerRule, ownerItem) + } + var spenderRule []interface{} + for _, spenderItem := range spender { + spenderRule = append(spenderRule, spenderItem) + } + + logs, sub, err := _MockERC20.contract.FilterLogs(opts, "Approval", ownerRule, spenderRule) + if err != nil { + return nil, err + } + return &MockERC20ApprovalIterator{contract: _MockERC20.contract, event: "Approval", logs: logs, sub: sub}, nil +} + +// WatchApproval is a free log subscription operation binding the contract event 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925. +// +// Solidity: event Approval(address indexed owner, address indexed spender, uint256 value) +func (_MockERC20 *MockERC20Filterer) WatchApproval(opts *bind.WatchOpts, sink chan<- *MockERC20Approval, owner []common.Address, spender []common.Address) (event.Subscription, error) { + + var ownerRule []interface{} + for _, ownerItem := range owner { + ownerRule = append(ownerRule, ownerItem) + } + var spenderRule []interface{} + for _, spenderItem := range spender { + spenderRule = append(spenderRule, spenderItem) + } + + logs, sub, err := _MockERC20.contract.WatchLogs(opts, "Approval", ownerRule, spenderRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(MockERC20Approval) + if err := _MockERC20.contract.UnpackLog(event, "Approval", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseApproval is a log parse operation binding the contract event 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925. +// +// Solidity: event Approval(address indexed owner, address indexed spender, uint256 value) +func (_MockERC20 *MockERC20Filterer) ParseApproval(log types.Log) (*MockERC20Approval, error) { + event := new(MockERC20Approval) + if err := _MockERC20.contract.UnpackLog(event, "Approval", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// MockERC20TransferIterator is returned from FilterTransfer and is used to iterate over the raw logs and unpacked data for Transfer events raised by the MockERC20 contract. +type MockERC20TransferIterator struct { + Event *MockERC20Transfer // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *MockERC20TransferIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(MockERC20Transfer) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(MockERC20Transfer) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *MockERC20TransferIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *MockERC20TransferIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// MockERC20Transfer represents a Transfer event raised by the MockERC20 contract. +type MockERC20Transfer struct { + From common.Address + To common.Address + Value *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterTransfer is a free log retrieval operation binding the contract event 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef. +// +// Solidity: event Transfer(address indexed from, address indexed to, uint256 value) +func (_MockERC20 *MockERC20Filterer) FilterTransfer(opts *bind.FilterOpts, from []common.Address, to []common.Address) (*MockERC20TransferIterator, error) { + + var fromRule []interface{} + for _, fromItem := range from { + fromRule = append(fromRule, fromItem) + } + var toRule []interface{} + for _, toItem := range to { + toRule = append(toRule, toItem) + } + + logs, sub, err := _MockERC20.contract.FilterLogs(opts, "Transfer", fromRule, toRule) + if err != nil { + return nil, err + } + return &MockERC20TransferIterator{contract: _MockERC20.contract, event: "Transfer", logs: logs, sub: sub}, nil +} + +// WatchTransfer is a free log subscription operation binding the contract event 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef. +// +// Solidity: event Transfer(address indexed from, address indexed to, uint256 value) +func (_MockERC20 *MockERC20Filterer) WatchTransfer(opts *bind.WatchOpts, sink chan<- *MockERC20Transfer, from []common.Address, to []common.Address) (event.Subscription, error) { + + var fromRule []interface{} + for _, fromItem := range from { + fromRule = append(fromRule, fromItem) + } + var toRule []interface{} + for _, toItem := range to { + toRule = append(toRule, toItem) + } + + logs, sub, err := _MockERC20.contract.WatchLogs(opts, "Transfer", fromRule, toRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(MockERC20Transfer) + if err := _MockERC20.contract.UnpackLog(event, "Transfer", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseTransfer is a log parse operation binding the contract event 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef. +// +// Solidity: event Transfer(address indexed from, address indexed to, uint256 value) +func (_MockERC20 *MockERC20Filterer) ParseTransfer(log types.Log) (*MockERC20Transfer, error) { + event := new(MockERC20Transfer) + if err := _MockERC20.contract.UnpackLog(event, "Transfer", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/timeboost/db.go b/timeboost/db.go new file mode 100644 index 0000000000..d5825166d6 --- /dev/null +++ b/timeboost/db.go @@ -0,0 +1,138 @@ +package timeboost + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +const sqliteFileName = "validated_bids.db?_journal_mode=WAL" + +type SqliteDatabase struct { + sqlDB *sqlx.DB + lock sync.Mutex + currentTableVersion int +} + +func NewDatabase(path string) (*SqliteDatabase, error) { + //#nosec G304 + if _, err := os.Stat(path); err != nil { + if err = os.MkdirAll(path, fs.ModeDir); err != nil { + return nil, err + } + } + filePath := filepath.Join(path, sqliteFileName) + db, err := sqlx.Open("sqlite3", filePath) + if err != nil { + return nil, err + } + err = dbInit(db, schemaList) + if err != nil { + return nil, err + } + return &SqliteDatabase{ + sqlDB: db, + currentTableVersion: -1, + }, nil +} + +func dbInit(db *sqlx.DB, schemaList []string) error { + version, err := fetchVersion(db) + if err != nil { + return err + } + for index, schema := range schemaList { + // If the current version is less than the version of the schema, update the database + if index+1 > version { + err = executeSchema(db, schema, index+1) + if err != nil { + return err + } + } + } + return nil +} + +func fetchVersion(db *sqlx.DB) (int, error) { + flagValue := make([]int, 0) + // Fetch the current version of the database + err := db.Select(&flagValue, "SELECT FlagValue FROM Flags WHERE FlagName = 'CurrentVersion'") + if err != nil { + if !strings.Contains(err.Error(), "no such table") { + return 0, err + } + // If the table doesn't exist, create it + _, err = db.Exec(flagSetup) + if err != nil { + return 0, err + } + // Fetch the current version of the database + err = db.Select(&flagValue, "SELECT FlagValue FROM Flags WHERE FlagName = 'CurrentVersion'") + if err != nil { + return 0, err + } + } + if len(flagValue) > 0 { + return flagValue[0], nil + } else { + return 0, fmt.Errorf("no version found") + } +} + +func executeSchema(db *sqlx.DB, schema string, version int) error { + // Begin a transaction, so that we update the version and execute the schema atomically + tx, err := db.Beginx() + if err != nil { + return err + } + + // Execute the schema + _, err = tx.Exec(schema) + if err != nil { + return err + } + // Update the version of the database + _, err = tx.Exec(fmt.Sprintf("UPDATE Flags SET FlagValue = %d WHERE FlagName = 'CurrentVersion'", version)) + if err != nil { + return err + } + return tx.Commit() +} + +func (d *SqliteDatabase) InsertBid(b *ValidatedBid) error { + d.lock.Lock() + defer d.lock.Unlock() + query := `INSERT INTO Bids ( + ChainID, Bidder, ExpressLaneController, AuctionContractAddress, Round, Amount, Signature + ) VALUES ( + :ChainID, :Bidder, :ExpressLaneController, :AuctionContractAddress, :Round, :Amount, :Signature + )` + params := map[string]interface{}{ + "ChainID": b.ChainId.String(), + "Bidder": b.Bidder.Hex(), + "ExpressLaneController": b.ExpressLaneController.Hex(), + "AuctionContractAddress": b.AuctionContractAddress.Hex(), + "Round": b.Round, + "Amount": b.Amount.String(), + "Signature": b.Signature, + } + _, err := d.sqlDB.NamedExec(query, params) + if err != nil { + return err + } + return nil +} + +func (d *SqliteDatabase) DeleteBids(round uint64) error { + d.lock.Lock() + defer d.lock.Unlock() + query := `DELETE FROM Bids WHERE Round < ?` + _, err := d.sqlDB.Exec(query, round) + return err +} diff --git a/timeboost/db_test.go b/timeboost/db_test.go new file mode 100644 index 0000000000..600e5adc8e --- /dev/null +++ b/timeboost/db_test.go @@ -0,0 +1,143 @@ +package timeboost + +import ( + "math/big" + "os" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/common" +) + +func TestInsertAndFetchBids(t *testing.T) { + t.Parallel() + type DatabaseBid struct { + Id uint64 `db:"Id"` + ChainId string `db:"ChainId"` + Bidder string `db:"Bidder"` + ExpressLaneController string `db:"ExpressLaneController"` + AuctionContractAddress string `db:"AuctionContractAddress"` + Round uint64 `db:"Round"` + Amount string `db:"Amount"` + Signature string `db:"Signature"` + } + + tmpDir, err := os.MkdirTemp("", "*") + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, os.RemoveAll(tmpDir)) + }) + db, err := NewDatabase(tmpDir) + require.NoError(t, err) + + bids := []*ValidatedBid{ + { + ChainId: big.NewInt(1), + ExpressLaneController: common.HexToAddress("0x0000000000000000000000000000000000000001"), + AuctionContractAddress: common.HexToAddress("0x0000000000000000000000000000000000000002"), + Bidder: common.HexToAddress("0x0000000000000000000000000000000000000002"), + Round: 1, + Amount: big.NewInt(100), + Signature: []byte("signature1"), + }, + { + ChainId: big.NewInt(2), + ExpressLaneController: common.HexToAddress("0x0000000000000000000000000000000000000003"), + AuctionContractAddress: common.HexToAddress("0x0000000000000000000000000000000000000004"), + Bidder: common.HexToAddress("0x0000000000000000000000000000000000000002"), + Round: 2, + Amount: big.NewInt(200), + Signature: []byte("signature2"), + }, + } + for _, bid := range bids { + require.NoError(t, db.InsertBid(bid)) + } + gotBids := make([]*DatabaseBid, 2) + err = db.sqlDB.Select(&gotBids, "SELECT * FROM Bids ORDER BY Id") + require.NoError(t, err) + require.Equal(t, bids[0].Amount.String(), gotBids[0].Amount) + require.Equal(t, bids[1].Amount.String(), gotBids[1].Amount) +} + +func TestInsertBids(t *testing.T) { + t.Parallel() + db, mock, err := sqlmock.New() + assert.NoError(t, err) + defer db.Close() + + sqlxDB := sqlx.NewDb(db, "sqlmock") + + d := &SqliteDatabase{sqlDB: sqlxDB, currentTableVersion: -1} + + bids := []*ValidatedBid{ + { + ChainId: big.NewInt(1), + ExpressLaneController: common.HexToAddress("0x0000000000000000000000000000000000000001"), + AuctionContractAddress: common.HexToAddress("0x0000000000000000000000000000000000000002"), + Bidder: common.HexToAddress("0x0000000000000000000000000000000000000002"), + Round: 1, + Amount: big.NewInt(100), + Signature: []byte("signature1"), + }, + { + ChainId: big.NewInt(2), + ExpressLaneController: common.HexToAddress("0x0000000000000000000000000000000000000003"), + AuctionContractAddress: common.HexToAddress("0x0000000000000000000000000000000000000004"), + Bidder: common.HexToAddress("0x0000000000000000000000000000000000000002"), + Round: 2, + Amount: big.NewInt(200), + Signature: []byte("signature2"), + }, + } + + for _, bid := range bids { + mock.ExpectExec("INSERT INTO Bids").WithArgs( + bid.ChainId.String(), + bid.Bidder.Hex(), + bid.ExpressLaneController.Hex(), + bid.AuctionContractAddress.Hex(), + bid.Round, + bid.Amount.String(), + bid.Signature, + ).WillReturnResult(sqlmock.NewResult(1, 1)) + } + + for _, bid := range bids { + err = d.InsertBid(bid) + assert.NoError(t, err) + } + + err = mock.ExpectationsWereMet() + assert.NoError(t, err) +} + +func TestDeleteBidsLowerThanRound(t *testing.T) { + t.Parallel() + db, mock, err := sqlmock.New() + assert.NoError(t, err) + defer db.Close() + + sqlxDB := sqlx.NewDb(db, "sqlmock") + + d := &SqliteDatabase{ + sqlDB: sqlxDB, + currentTableVersion: -1, + } + + round := uint64(10) + + mock.ExpectExec("DELETE FROM Bids WHERE Round < ?"). + WithArgs(round). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err = d.DeleteBids(round) + assert.NoError(t, err) + + err = mock.ExpectationsWereMet() + assert.NoError(t, err) +} diff --git a/timeboost/errors.go b/timeboost/errors.go new file mode 100644 index 0000000000..ef8dc2c8dc --- /dev/null +++ b/timeboost/errors.go @@ -0,0 +1,19 @@ +package timeboost + +import "github.com/pkg/errors" + +var ( + ErrMalformedData = errors.New("MALFORMED_DATA") + ErrNotDepositor = errors.New("NOT_DEPOSITOR") + ErrWrongChainId = errors.New("WRONG_CHAIN_ID") + ErrWrongSignature = errors.New("WRONG_SIGNATURE") + ErrBadRoundNumber = errors.New("BAD_ROUND_NUMBER") + ErrInsufficientBalance = errors.New("INSUFFICIENT_BALANCE") + ErrReservePriceNotMet = errors.New("RESERVE_PRICE_NOT_MET") + ErrNoOnchainController = errors.New("NO_ONCHAIN_CONTROLLER") + ErrWrongAuctionContract = errors.New("WRONG_AUCTION_CONTRACT") + ErrNotExpressLaneController = errors.New("NOT_EXPRESS_LANE_CONTROLLER") + ErrDuplicateSequenceNumber = errors.New("SUBMISSION_NONCE_ALREADY_SEEN") + ErrSequenceNumberTooLow = errors.New("SUBMISSION_NONCE_TOO_LOW") + ErrTooManyBids = errors.New("PER_ROUND_BID_LIMIT_REACHED") +) diff --git a/timeboost/roundtiminginfo.go b/timeboost/roundtiminginfo.go new file mode 100644 index 0000000000..74ceab4364 --- /dev/null +++ b/timeboost/roundtiminginfo.go @@ -0,0 +1,62 @@ +// Copyright 2024-2025, Offchain Labs, Inc. +// For license information, see https://github.com/nitro/blob/master/LICENSE + +package timeboost + +import ( + "fmt" + "time" + + "github.com/offchainlabs/nitro/util/arbmath" +) + +// Solgen solidity bindings don't give names to return structs, give it a name for convenience. +type RoundTimingInfo struct { + OffsetTimestamp int64 + RoundDurationSeconds uint64 + AuctionClosingSeconds uint64 + ReserveSubmissionSeconds uint64 +} + +// Validate the RoundTimingInfo fields. +// resolutionWaitTime is an additional parameter passed into the auctioneer that it +// needs to validate against the other fields. +func (c *RoundTimingInfo) Validate(resolutionWaitTime *time.Duration) error { + roundDuration := arbmath.SaturatingCast[time.Duration](c.RoundDurationSeconds) * time.Second + auctionClosing := arbmath.SaturatingCast[time.Duration](c.AuctionClosingSeconds) * time.Second + reserveSubmission := arbmath.SaturatingCast[time.Duration](c.ReserveSubmissionSeconds) * time.Second + + // Validate minimum durations + if roundDuration < time.Second*10 { + return fmt.Errorf("RoundDurationSeconds (%d) must be at least 10 seconds", c.RoundDurationSeconds) + } + + if auctionClosing < time.Second*5 { + return fmt.Errorf("AuctionClosingSeconds (%d) must be at least 5 seconds", c.AuctionClosingSeconds) + } + + if reserveSubmission < time.Second { + return fmt.Errorf("ReserveSubmissionSeconds (%d) must be at least 1 second", c.ReserveSubmissionSeconds) + } + + // Validate combined auction closing and reserve submission against round duration + combinedClosingTime := auctionClosing + reserveSubmission + if roundDuration <= combinedClosingTime { + return fmt.Errorf("RoundDurationSeconds (%d) must be greater than AuctionClosingSeconds (%d) + ReserveSubmissionSeconds (%d) = %d", + c.RoundDurationSeconds, + c.AuctionClosingSeconds, + c.ReserveSubmissionSeconds, + combinedClosingTime/time.Second) + } + + // Validate resolution wait time if provided + if resolutionWaitTime != nil { + // Resolution wait time shouldn't be more than 50% of auction closing time + if *resolutionWaitTime > auctionClosing/2 { + return fmt.Errorf("resolution wait time (%v) must not exceed 50%% of auction closing time (%v)", + *resolutionWaitTime, auctionClosing) + } + } + + return nil +} diff --git a/timeboost/schema.go b/timeboost/schema.go new file mode 100644 index 0000000000..94fc04d1f1 --- /dev/null +++ b/timeboost/schema.go @@ -0,0 +1,24 @@ +package timeboost + +var ( + flagSetup = ` +CREATE TABLE IF NOT EXISTS Flags ( + FlagName TEXT NOT NULL PRIMARY KEY, + FlagValue INTEGER NOT NULL +); +INSERT INTO Flags (FlagName, FlagValue) VALUES ('CurrentVersion', 0); +` + version1 = ` +CREATE TABLE IF NOT EXISTS Bids ( + Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + ChainId TEXT NOT NULL, + Bidder TEXT NOT NULL, + ExpressLaneController TEXT NOT NULL, + AuctionContractAddress TEXT NOT NULL, + Round INTEGER NOT NULL, + Amount TEXT NOT NULL, + Signature TEXT NOT NULL +); +` + schemaList = []string{version1} +) diff --git a/timeboost/setup_test.go b/timeboost/setup_test.go new file mode 100644 index 0000000000..c093ab2d67 --- /dev/null +++ b/timeboost/setup_test.go @@ -0,0 +1,257 @@ +package timeboost + +import ( + "context" + "crypto/ecdsa" + "fmt" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/eth/ethconfig" + "github.com/ethereum/go-ethereum/ethclient/simulated" + "github.com/ethereum/go-ethereum/node" + + "github.com/offchainlabs/nitro/cmd/genericconf" + "github.com/offchainlabs/nitro/solgen/go/express_lane_auctiongen" + "github.com/offchainlabs/nitro/solgen/go/mocksgen" + "github.com/offchainlabs/nitro/timeboost/bindings" +) + +type auctionSetup struct { + chainId *big.Int + expressLaneAuctionAddr common.Address + expressLaneAuction *express_lane_auctiongen.ExpressLaneAuction + erc20Addr common.Address + erc20Contract *bindings.MockERC20 + initialTimestamp time.Time + roundDuration time.Duration + expressLaneAddr common.Address + beneficiaryAddr common.Address + accounts []*testAccount + backend *simulated.Backend + endpoint string +} + +func setupAuctionTest(t testing.TB, ctx context.Context) *auctionSetup { + accs, backend, endpoint := setupAccounts(t, 10) + + go func() { + tick := time.NewTicker(time.Second) + defer tick.Stop() + for { + select { + case <-tick.C: + backend.Commit() + case <-ctx.Done(): + return + } + } + }() + + opts := accs[0].txOpts + chainId, err := backend.Client().ChainID(ctx) + require.NoError(t, err) + + // Deploy the token as a mock erc20. + erc20Addr, tx, erc20, err := bindings.DeployMockERC20(opts, backend.Client()) + require.NoError(t, err) + if _, err = bind.WaitMined(ctx, backend.Client(), tx); err != nil { + t.Fatal(err) + } + tx, err = erc20.Initialize(opts, "LANE", "LNE", 18) + require.NoError(t, err) + if _, err = bind.WaitMined(ctx, backend.Client(), tx); err != nil { + t.Fatal(err) + } + + // Mint 10 wei tokens to all accounts. + mintTokens(ctx, opts, backend, accs, erc20) + + // Check account balances. + bal, err := erc20.BalanceOf(&bind.CallOpts{}, accs[0].accountAddr) + require.NoError(t, err) + t.Log("Account seeded with ERC20 token balance =", bal.String()) + + // Deploy the express lane auction contract. + auctionContractAddr, tx, _, err := express_lane_auctiongen.DeployExpressLaneAuction( + opts, backend.Client(), + ) + require.NoError(t, err) + if _, err = bind.WaitMined(ctx, backend.Client(), tx); err != nil { + t.Fatal(err) + } + proxyAddr, tx, _, err := mocksgen.DeploySimpleProxy(opts, backend.Client(), auctionContractAddr) + require.NoError(t, err) + if _, err = bind.WaitMined(ctx, backend.Client(), tx); err != nil { + t.Fatal(err) + } + auctionContract, err := express_lane_auctiongen.NewExpressLaneAuction(proxyAddr, backend.Client()) + require.NoError(t, err) + + expressLaneAddr := common.HexToAddress("0x2424242424242424242424242424242424242424") + + // Calculate the number of seconds until the next minute + // and the next timestamp that is a multiple of a minute. + now := time.Now() + roundDuration := time.Minute + waitTime := roundDuration - time.Duration(now.Second())*time.Second - time.Duration(now.Nanosecond()) + initialTime := now.Add(waitTime) + initialTimestamp := big.NewInt(initialTime.Unix()) + t.Logf("Initial timestamp for express lane auctions: %v", initialTime) + + // Deploy the auction manager contract. + auctioneer := opts.From + beneficiary := opts.From + biddingToken := erc20Addr + bidRoundSeconds := uint64(60) + auctionClosingSeconds := uint64(15) + reserveSubmissionSeconds := uint64(15) + minReservePrice := big.NewInt(1) // 1 wei. + roleAdmin := opts.From + tx, err = auctionContract.Initialize( + opts, + express_lane_auctiongen.InitArgs{ + Auctioneer: auctioneer, + BiddingToken: biddingToken, + Beneficiary: beneficiary, + RoundTimingInfo: express_lane_auctiongen.RoundTimingInfo{ + OffsetTimestamp: initialTimestamp.Int64(), + RoundDurationSeconds: bidRoundSeconds, + AuctionClosingSeconds: auctionClosingSeconds, + ReserveSubmissionSeconds: reserveSubmissionSeconds, + }, + MinReservePrice: minReservePrice, + AuctioneerAdmin: roleAdmin, + MinReservePriceSetter: roleAdmin, + ReservePriceSetter: roleAdmin, + BeneficiarySetter: roleAdmin, + RoundTimingSetter: roleAdmin, + MasterAdmin: roleAdmin, + }, + ) + require.NoError(t, err) + if _, err = bind.WaitMined(ctx, backend.Client(), tx); err != nil { + t.Fatal(err) + } + return &auctionSetup{ + chainId: chainId, + expressLaneAuctionAddr: proxyAddr, + expressLaneAuction: auctionContract, + erc20Addr: erc20Addr, + erc20Contract: erc20, + initialTimestamp: now, + roundDuration: time.Minute, + expressLaneAddr: expressLaneAddr, + beneficiaryAddr: beneficiary, + accounts: accs, + backend: backend, + endpoint: endpoint, + } +} + +func setupBidderClient( + t testing.TB, ctx context.Context, account *testAccount, testSetup *auctionSetup, bidValidatorEndpoint string, +) *BidderClient { + cfgFetcher := func() *BidderClientConfig { + return &BidderClientConfig{ + AuctionContractAddress: testSetup.expressLaneAuctionAddr.Hex(), + BidValidatorEndpoint: bidValidatorEndpoint, + ArbitrumNodeEndpoint: testSetup.endpoint, + Wallet: genericconf.WalletConfig{ + PrivateKey: fmt.Sprintf("%x", account.privKey.D.Bytes()), + }, + } + } + bc, err := NewBidderClient( + ctx, + cfgFetcher, + ) + require.NoError(t, err) + bc.Start(ctx) + + // Approve spending by the express lane auction contract and beneficiary. + maxUint256 := big.NewInt(1) + maxUint256.Lsh(maxUint256, 256).Sub(maxUint256, big.NewInt(1)) + tx, err := testSetup.erc20Contract.Approve( + account.txOpts, testSetup.expressLaneAuctionAddr, maxUint256, + ) + require.NoError(t, err) + if _, err = bind.WaitMined(ctx, testSetup.backend.Client(), tx); err != nil { + t.Fatal(err) + } + tx, err = testSetup.erc20Contract.Approve( + account.txOpts, testSetup.beneficiaryAddr, maxUint256, + ) + require.NoError(t, err) + if _, err = bind.WaitMined(ctx, testSetup.backend.Client(), tx); err != nil { + t.Fatal(err) + } + return bc +} + +type testAccount struct { + accountAddr common.Address + privKey *ecdsa.PrivateKey + txOpts *bind.TransactOpts +} + +func setupAccounts(t testing.TB, numAccounts uint64) ([]*testAccount, *simulated.Backend, string) { + genesis := make(core.GenesisAlloc) + gasLimit := uint64(100000000) + + accs := make([]*testAccount, numAccounts) + for i := uint64(0); i < numAccounts; i++ { + privKey, err := crypto.GenerateKey() + if err != nil { + panic(err) + } + addr := crypto.PubkeyToAddress(privKey.PublicKey) + chainID := big.NewInt(1337) + txOpts, err := bind.NewKeyedTransactorWithChainID(privKey, chainID) + if err != nil { + panic(err) + } + startingBalance, _ := new(big.Int).SetString( + "100000000000000000000000000000000000000", + 10, + ) + genesis[addr] = core.GenesisAccount{Balance: startingBalance} + accs[i] = &testAccount{ + accountAddr: addr, + txOpts: txOpts, + privKey: privKey, + } + } + randPort := getRandomPort(t) + withRPC := func(n *node.Config, _ *ethconfig.Config) { + n.HTTPHost = "localhost" + n.HTTPPort = randPort + n.HTTPModules = []string{"eth", "net", "web3", "debug", "personal"} + } + backend := simulated.NewBackend(genesis, simulated.WithBlockGasLimit(gasLimit), withRPC) + return accs, backend, fmt.Sprintf("http://localhost:%d", randPort) +} + +func mintTokens(ctx context.Context, + opts *bind.TransactOpts, + backend *simulated.Backend, + accs []*testAccount, + erc20 *bindings.MockERC20, +) { + for i := 0; i < len(accs); i++ { + tx, err := erc20.Mint(opts, accs[i].accountAddr, big.NewInt(100)) + if err != nil { + panic(err) + } + if _, err = bind.WaitMined(ctx, backend.Client(), tx); err != nil { + panic(err) + } + } +} diff --git a/timeboost/ticker.go b/timeboost/ticker.go new file mode 100644 index 0000000000..fa8d14c9dd --- /dev/null +++ b/timeboost/ticker.go @@ -0,0 +1,87 @@ +package timeboost + +import ( + "time" + + "github.com/offchainlabs/nitro/util/arbmath" +) + +type auctionCloseTicker struct { + c chan time.Time + done chan bool + roundDuration time.Duration + auctionClosingDuration time.Duration +} + +func newAuctionCloseTicker(roundDuration, auctionClosingDuration time.Duration) *auctionCloseTicker { + return &auctionCloseTicker{ + c: make(chan time.Time, 1), + done: make(chan bool), + roundDuration: roundDuration, + auctionClosingDuration: auctionClosingDuration, + } +} + +func (t *auctionCloseTicker) start() { + for { + now := time.Now() + // Calculate the start of the next round + startOfNextRound := now.Truncate(t.roundDuration).Add(t.roundDuration) + // Subtract AUCTION_CLOSING_SECONDS seconds to get the tick time + nextTickTime := startOfNextRound.Add(-t.auctionClosingDuration) + // Ensure we are not setting a past tick time + if nextTickTime.Before(now) { + // If the calculated tick time is in the past, move to the next interval + nextTickTime = nextTickTime.Add(t.roundDuration) + } + // Calculate how long to wait until the next tick + waitTime := nextTickTime.Sub(now) + + select { + case <-time.After(waitTime): + t.c <- time.Now() + case <-t.done: + close(t.c) + return + } + } +} + +// CurrentRound returns the current round number. +func CurrentRound(initialRoundTimestamp time.Time, roundDuration time.Duration) uint64 { + if roundDuration == 0 { + return 0 + } + return arbmath.SaturatingUCast[uint64](time.Since(initialRoundTimestamp) / roundDuration) +} + +func isAuctionRoundClosed( + timestamp time.Time, + initialTimestamp time.Time, + roundDuration time.Duration, + auctionClosingDuration time.Duration, +) bool { + if timestamp.Before(initialTimestamp) { + return false + } + timeInRound := timeIntoRound(timestamp, initialTimestamp, roundDuration) + return arbmath.SaturatingCast[time.Duration](timeInRound)*time.Second >= roundDuration-auctionClosingDuration +} + +func timeIntoRound( + timestamp time.Time, + initialTimestamp time.Time, + roundDuration time.Duration, +) uint64 { + secondsSinceOffset := uint64(timestamp.Sub(initialTimestamp).Seconds()) + roundDurationSeconds := uint64(roundDuration.Seconds()) + return secondsSinceOffset % roundDurationSeconds +} + +func TimeTilNextRound( + initialTimestamp time.Time, + roundDuration time.Duration) time.Duration { + currentRoundNum := CurrentRound(initialTimestamp, roundDuration) + nextRoundStart := initialTimestamp.Add(roundDuration * arbmath.SaturatingCast[time.Duration](currentRoundNum+1)) + return time.Until(nextRoundStart) +} diff --git a/timeboost/ticker_test.go b/timeboost/ticker_test.go new file mode 100644 index 0000000000..b1ee996bc0 --- /dev/null +++ b/timeboost/ticker_test.go @@ -0,0 +1,40 @@ +package timeboost + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func Test_auctionClosed(t *testing.T) { + t.Parallel() + roundDuration := time.Minute + auctionClosingDuration := time.Second * 15 + now := time.Now() + waitTime := roundDuration - time.Duration(now.Second())*time.Second - time.Duration(now.Nanosecond()) + initialTimestamp := now.Add(waitTime) + + // We should not have closed the round yet, and the time into the round should be less than a second. + isClosed := isAuctionRoundClosed(initialTimestamp, initialTimestamp, roundDuration, auctionClosingDuration) + require.False(t, isClosed) + + // Wait right before auction closure (before the 45 second mark). + timestamp := initialTimestamp.Add((roundDuration - auctionClosingDuration) - time.Second) + isClosed = isAuctionRoundClosed(timestamp, initialTimestamp, roundDuration, auctionClosingDuration) + require.False(t, isClosed) + + // Wait a second more and the auction should be closed. + timestamp = initialTimestamp.Add(roundDuration - auctionClosingDuration) + isClosed = isAuctionRoundClosed(timestamp, initialTimestamp, roundDuration, auctionClosingDuration) + require.True(t, isClosed) + + // Future timestamp should also be closed, until we reach the new round + for i := float64(0); i < auctionClosingDuration.Seconds(); i++ { + timestamp = initialTimestamp.Add((roundDuration - auctionClosingDuration) + time.Second*time.Duration(i)) + isClosed = isAuctionRoundClosed(timestamp, initialTimestamp, roundDuration, auctionClosingDuration) + require.True(t, isClosed) + } + isClosed = isAuctionRoundClosed(initialTimestamp.Add(roundDuration), initialTimestamp, roundDuration, auctionClosingDuration) + require.False(t, isClosed) +} diff --git a/timeboost/types.go b/timeboost/types.go new file mode 100644 index 0000000000..146bbc3740 --- /dev/null +++ b/timeboost/types.go @@ -0,0 +1,213 @@ +package timeboost + +import ( + "bytes" + "encoding/binary" + "math/big" + + "github.com/ethereum/go-ethereum/arbitrum_types" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" +) + +type Bid struct { + Id uint64 `db:"Id"` + ChainId *big.Int `db:"ChainId"` + ExpressLaneController common.Address `db:"ExpressLaneController"` + AuctionContractAddress common.Address `db:"AuctionContractAddress"` + Round uint64 `db:"Round"` + Amount *big.Int `db:"Amount"` + Signature []byte `db:"Signature"` +} + +func (b *Bid) ToJson() *JsonBid { + return &JsonBid{ + ChainId: (*hexutil.Big)(b.ChainId), + ExpressLaneController: b.ExpressLaneController, + AuctionContractAddress: b.AuctionContractAddress, + Round: hexutil.Uint64(b.Round), + Amount: (*hexutil.Big)(b.Amount), + Signature: b.Signature, + } +} + +func (b *Bid) ToMessageBytes() []byte { + buf := new(bytes.Buffer) + // Encode uint256 values - each occupies 32 bytes + buf.Write(domainValue) + buf.Write(padBigInt(b.ChainId)) + buf.Write(b.AuctionContractAddress[:]) + roundBuf := make([]byte, 8) + binary.BigEndian.PutUint64(roundBuf, b.Round) + buf.Write(roundBuf) + buf.Write(padBigInt(b.Amount)) + buf.Write(b.ExpressLaneController[:]) + + return buf.Bytes() +} + +type JsonBid struct { + ChainId *hexutil.Big `json:"chainId"` + ExpressLaneController common.Address `json:"expressLaneController"` + AuctionContractAddress common.Address `json:"auctionContractAddress"` + Round hexutil.Uint64 `json:"round"` + Amount *hexutil.Big `json:"amount"` + Signature hexutil.Bytes `json:"signature"` +} + +type ValidatedBid struct { + ExpressLaneController common.Address + Amount *big.Int + Signature []byte + // For tie breaking + ChainId *big.Int + AuctionContractAddress common.Address + Round uint64 + Bidder common.Address +} + +// BigIntHash returns the hash of the bidder and bidBytes in the form of a big.Int. +// The hash is equivalent to the following Solidity implementation: +// +// uint256(keccak256(abi.encodePacked(bidder, bidBytes))) +func (v *ValidatedBid) BigIntHash() *big.Int { + bidBytes := v.BidBytes() + bidder := v.Bidder.Bytes() + + return new(big.Int).SetBytes(crypto.Keccak256Hash(bidder, bidBytes).Bytes()) +} + +// BidBytes returns the byte representation equivalent to the Solidity implementation of +// +// abi.encodePacked(BID_DOMAIN, block.chainid, address(this), _round, _amount, _expressLaneController) +func (v *ValidatedBid) BidBytes() []byte { + var buffer bytes.Buffer + + buffer.Write(domainValue) + buffer.Write(v.ChainId.Bytes()) + buffer.Write(v.AuctionContractAddress.Bytes()) + + roundBytes := make([]byte, 8) + binary.BigEndian.PutUint64(roundBytes, v.Round) + buffer.Write(roundBytes) + + buffer.Write(v.Amount.Bytes()) + buffer.Write(v.ExpressLaneController.Bytes()) + + return buffer.Bytes() +} + +func (v *ValidatedBid) ToJson() *JsonValidatedBid { + return &JsonValidatedBid{ + ExpressLaneController: v.ExpressLaneController, + Amount: (*hexutil.Big)(v.Amount), + Signature: v.Signature, + ChainId: (*hexutil.Big)(v.ChainId), + AuctionContractAddress: v.AuctionContractAddress, + Round: hexutil.Uint64(v.Round), + Bidder: v.Bidder, + } +} + +type JsonValidatedBid struct { + ExpressLaneController common.Address `json:"expressLaneController"` + Amount *hexutil.Big `json:"amount"` + Signature hexutil.Bytes `json:"signature"` + ChainId *hexutil.Big `json:"chainId"` + AuctionContractAddress common.Address `json:"auctionContractAddress"` + Round hexutil.Uint64 `json:"round"` + Bidder common.Address `json:"bidder"` +} + +func JsonValidatedBidToGo(bid *JsonValidatedBid) *ValidatedBid { + return &ValidatedBid{ + ExpressLaneController: bid.ExpressLaneController, + Amount: bid.Amount.ToInt(), + Signature: bid.Signature, + ChainId: bid.ChainId.ToInt(), + AuctionContractAddress: bid.AuctionContractAddress, + Round: uint64(bid.Round), + Bidder: bid.Bidder, + } +} + +type JsonExpressLaneSubmission struct { + ChainId *hexutil.Big `json:"chainId"` + Round hexutil.Uint64 `json:"round"` + AuctionContractAddress common.Address `json:"auctionContractAddress"` + Transaction hexutil.Bytes `json:"transaction"` + Options *arbitrum_types.ConditionalOptions `json:"options"` + SequenceNumber hexutil.Uint64 + Signature hexutil.Bytes `json:"signature"` +} + +type ExpressLaneSubmission struct { + ChainId *big.Int + Round uint64 + AuctionContractAddress common.Address + Transaction *types.Transaction + Options *arbitrum_types.ConditionalOptions `json:"options"` + SequenceNumber uint64 + Signature []byte +} + +func JsonSubmissionToGo(submission *JsonExpressLaneSubmission) (*ExpressLaneSubmission, error) { + tx := &types.Transaction{} + if err := tx.UnmarshalBinary(submission.Transaction); err != nil { + return nil, err + } + return &ExpressLaneSubmission{ + ChainId: submission.ChainId.ToInt(), + Round: uint64(submission.Round), + AuctionContractAddress: submission.AuctionContractAddress, + Transaction: tx, + Options: submission.Options, + SequenceNumber: uint64(submission.SequenceNumber), + Signature: submission.Signature, + }, nil +} + +func (els *ExpressLaneSubmission) ToJson() (*JsonExpressLaneSubmission, error) { + encoded, err := els.Transaction.MarshalBinary() + if err != nil { + return nil, err + } + return &JsonExpressLaneSubmission{ + ChainId: (*hexutil.Big)(els.ChainId), + Round: hexutil.Uint64(els.Round), + AuctionContractAddress: els.AuctionContractAddress, + Transaction: encoded, + Options: els.Options, + SequenceNumber: hexutil.Uint64(els.SequenceNumber), + Signature: els.Signature, + }, nil +} + +func (els *ExpressLaneSubmission) ToMessageBytes() ([]byte, error) { + buf := new(bytes.Buffer) + buf.Write(domainValue) + buf.Write(padBigInt(els.ChainId)) + buf.Write(els.AuctionContractAddress[:]) + roundBuf := make([]byte, 8) + binary.BigEndian.PutUint64(roundBuf, els.Round) + buf.Write(roundBuf) + seqBuf := make([]byte, 8) + binary.BigEndian.PutUint64(seqBuf, els.SequenceNumber) + buf.Write(seqBuf) + rlpTx, err := els.Transaction.MarshalBinary() + if err != nil { + return nil, err + } + buf.Write(rlpTx) + return buf.Bytes(), nil +} + +// Helper function to pad a big integer to 32 bytes +func padBigInt(bi *big.Int) []byte { + bb := bi.Bytes() + padded := make([]byte, 32-len(bb), 32) + padded = append(padded, bb...) + return padded +} diff --git a/util/redisutil/test_redis.go b/util/redisutil/test_redis.go index 9cabfc23d6..271b3b48af 100644 --- a/util/redisutil/test_redis.go +++ b/util/redisutil/test_redis.go @@ -16,7 +16,7 @@ import ( // CreateTestRedis Provides external redis url, this is only done in TEST_REDIS env, // else creates a new miniredis and returns its url. -func CreateTestRedis(ctx context.Context, t *testing.T) string { +func CreateTestRedis(ctx context.Context, t testing.TB) string { redisUrl := os.Getenv("TEST_REDIS") if redisUrl != "" { return redisUrl diff --git a/util/testhelpers/stackconfig.go b/util/testhelpers/stackconfig.go index 45ab653a1c..9fe18ec35f 100644 --- a/util/testhelpers/stackconfig.go +++ b/util/testhelpers/stackconfig.go @@ -14,6 +14,7 @@ func CreateStackConfigForTest(dataDir string) *node.Config { stackConf.HTTPPort = 0 stackConf.HTTPHost = "" stackConf.HTTPModules = append(stackConf.HTTPModules, "eth", "debug") + stackConf.AuthPort = 0 stackConf.P2P.NoDiscovery = true stackConf.P2P.NoDial = true stackConf.P2P.ListenAddr = "" diff --git a/util/testhelpers/testhelpers.go b/util/testhelpers/testhelpers.go index 7f3e63a811..8ef3c489e4 100644 --- a/util/testhelpers/testhelpers.go +++ b/util/testhelpers/testhelpers.go @@ -23,7 +23,7 @@ import ( ) // Fail a test should an error occur -func RequireImpl(t *testing.T, err error, printables ...interface{}) { +func RequireImpl(t testing.TB, err error, printables ...interface{}) { t.Helper() if err != nil { t.Log(string(debug.Stack()))