diff --git a/Dockerfile b/Dockerfile index 17372f2d08..0637ae9832 100644 --- a/Dockerfile +++ b/Dockerfile @@ -298,6 +298,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 f11896d504..2251b5f0e9 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 mockexternalsigner seq-coordinator-invalidate nitro-val seq-coordinator-manager dbconv) +build: $(patsubst %,$(output_root)/bin/%, nitro deploy relay daserver autonomous-auctioneer bidder-client datool mockexternalsigner 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/arbnode/blockmetadata.go b/arbnode/blockmetadata.go new file mode 100644 index 0000000000..96e02e07b8 --- /dev/null +++ b/arbnode/blockmetadata.go @@ -0,0 +1,151 @@ +package arbnode + +import ( + "bytes" + "context" + "encoding/binary" + "time" + + "github.com/spf13/pflag" + + "github.com/ethereum/go-ethereum/ethdb" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/offchainlabs/nitro/arbutil" + "github.com/offchainlabs/nitro/execution" + "github.com/offchainlabs/nitro/execution/gethexec" + "github.com/offchainlabs/nitro/util" + "github.com/offchainlabs/nitro/util/rpcclient" + "github.com/offchainlabs/nitro/util/stopwaiter" +) + +type BlockMetadataFetcherConfig struct { + Enable bool `koanf:"enable"` + Source rpcclient.ClientConfig `koanf:"source" reload:"hot"` + SyncInterval time.Duration `koanf:"sync-interval"` + APIBlocksLimit uint64 `koanf:"api-blocks-limit"` +} + +var DefaultBlockMetadataFetcherConfig = BlockMetadataFetcherConfig{ + Enable: false, + Source: rpcclient.DefaultClientConfig, + SyncInterval: time.Minute * 5, + APIBlocksLimit: 100, +} + +func BlockMetadataFetcherConfigAddOptions(prefix string, f *pflag.FlagSet) { + f.Bool(prefix+".enable", DefaultBlockMetadataFetcherConfig.Enable, "enable syncing blockMetadata using a bulk blockMetadata api. If the source doesn't have the missing blockMetadata, we keep retyring in every sync-interval (default=5mins) duration") + rpcclient.RPCClientAddOptions(prefix+".source", f, &DefaultBlockMetadataFetcherConfig.Source) + f.Duration(prefix+".sync-interval", DefaultBlockMetadataFetcherConfig.SyncInterval, "interval at which blockMetadata are synced regularly") + f.Uint64(prefix+".api-blocks-limit", DefaultBlockMetadataFetcherConfig.APIBlocksLimit, "maximum number of blocks allowed to be queried for blockMetadata per arb_getRawBlockMetadata query.\n"+ + "This should be set lesser than or equal to the limit on the api provider side") +} + +type BlockMetadataFetcher struct { + stopwaiter.StopWaiter + config BlockMetadataFetcherConfig + db ethdb.Database + client *rpcclient.RpcClient + exec execution.ExecutionClient +} + +func NewBlockMetadataFetcher(ctx context.Context, c BlockMetadataFetcherConfig, db ethdb.Database, exec execution.ExecutionClient) (*BlockMetadataFetcher, error) { + client := rpcclient.NewRpcClient(func() *rpcclient.ClientConfig { return &c.Source }, nil) + if err := client.Start(ctx); err != nil { + return nil, err + } + return &BlockMetadataFetcher{ + config: c, + db: db, + client: client, + exec: exec, + }, nil +} + +func (b *BlockMetadataFetcher) fetch(ctx context.Context, fromBlock, toBlock uint64) ([]gethexec.NumberAndBlockMetadata, error) { + var result []gethexec.NumberAndBlockMetadata + // #nosec G115 + err := b.client.CallContext(ctx, &result, "arb_getRawBlockMetadata", rpc.BlockNumber(fromBlock), rpc.BlockNumber(toBlock)) + if err != nil { + return nil, err + } + return result, nil +} + +func (b *BlockMetadataFetcher) persistBlockMetadata(query []uint64, result []gethexec.NumberAndBlockMetadata) error { + batch := b.db.NewBatch() + queryMap := util.ArrayToSet(query) + for _, elem := range result { + pos, err := b.exec.BlockNumberToMessageIndex(elem.BlockNumber) + if err != nil { + return err + } + if _, ok := queryMap[uint64(pos)]; ok { + if err := batch.Put(dbKey(blockMetadataInputFeedPrefix, uint64(pos)), elem.RawMetadata); err != nil { + return err + } + if err := batch.Delete(dbKey(missingBlockMetadataInputFeedPrefix, uint64(pos))); err != nil { + return err + } + // If we reached the ideal batch size, commit and reset + if batch.ValueSize() >= ethdb.IdealBatchSize { + if err := batch.Write(); err != nil { + return err + } + batch.Reset() + } + } + } + return batch.Write() +} + +func (b *BlockMetadataFetcher) Update(ctx context.Context) time.Duration { + handleQuery := func(query []uint64) bool { + result, err := b.fetch( + ctx, + b.exec.MessageIndexToBlockNumber(arbutil.MessageIndex(query[0])), + b.exec.MessageIndexToBlockNumber(arbutil.MessageIndex(query[len(query)-1])), + ) + if err != nil { + log.Error("Error getting result from bulk blockMetadata API", "err", err) + return false + } + if err = b.persistBlockMetadata(query, result); err != nil { + log.Error("Error committing result from bulk blockMetadata API to ArbDB", "err", err) + return false + } + return true + } + iter := b.db.NewIterator(missingBlockMetadataInputFeedPrefix, nil) + defer iter.Release() + var query []uint64 + for iter.Next() { + keyBytes := bytes.TrimPrefix(iter.Key(), missingBlockMetadataInputFeedPrefix) + query = append(query, binary.BigEndian.Uint64(keyBytes)) + end := len(query) - 1 + if query[end]-query[0]+1 >= uint64(b.config.APIBlocksLimit) { + if query[end]-query[0]+1 > uint64(b.config.APIBlocksLimit) && len(query) >= 2 { + end -= 1 + } + if success := handleQuery(query[:end+1]); !success { + return b.config.SyncInterval + } + query = query[end+1:] + } + } + if len(query) > 0 { + _ = handleQuery(query) + } + return b.config.SyncInterval +} + +func (b *BlockMetadataFetcher) Start(ctx context.Context) { + b.StopWaiter.Start(ctx, b) + b.CallIteratively(b.Update) +} + +func (b *BlockMetadataFetcher) StopAndWait() { + b.StopWaiter.StopAndWait() + b.client.Close() +} diff --git a/arbnode/inbox_test.go b/arbnode/inbox_test.go index 0c31008ff1..28a5d587a9 100644 --- a/arbnode/inbox_test.go +++ b/arbnode/inbox_test.go @@ -184,7 +184,7 @@ func TestTransactionStreamer(t *testing.T) { state.balances[dest].Add(state.balances[dest], value) } - Require(t, inbox.AddMessages(state.numMessages, false, messages)) + Require(t, inbox.AddMessages(state.numMessages, false, messages, nil)) state.numMessages += arbutil.MessageIndex(len(messages)) prevBlockNumber := state.blockNumber diff --git a/arbnode/inbox_tracker.go b/arbnode/inbox_tracker.go index 87e84b3737..45bdee7ddd 100644 --- a/arbnode/inbox_tracker.go +++ b/arbnode/inbox_tracker.go @@ -307,7 +307,12 @@ func (t *InboxTracker) PopulateFeedBacklog(broadcastServer *broadcaster.Broadcas blockHash = &msgResult.BlockHash } - feedMessage, err := broadcastServer.NewBroadcastFeedMessage(*message, seqNum, blockHash) + blockMetadata, err := t.txStreamer.BlockMetadataAtCount(seqNum + 1) + if err != nil { + log.Warn("Error getting blockMetadata byte array from tx streamer", "err", err) + } + + feedMessage, err := broadcastServer.NewBroadcastFeedMessage(*message, seqNum, blockHash, blockMetadata) if err != nil { return fmt.Errorf("error creating broadcast feed message %v: %w", seqNum, err) } @@ -845,7 +850,7 @@ func (t *InboxTracker) AddSequencerBatches(ctx context.Context, client *ethclien } // This also writes the batch - err = t.txStreamer.AddMessagesAndEndBatch(prevbatchmeta.MessageCount, true, messages, dbBatch) + err = t.txStreamer.AddMessagesAndEndBatch(prevbatchmeta.MessageCount, true, messages, nil, dbBatch) if err != nil { return err } diff --git a/arbnode/node.go b/arbnode/node.go index b9ac975176..71ac52e9f8 100644 --- a/arbnode/node.go +++ b/arbnode/node.go @@ -82,23 +82,24 @@ func GenerateRollupConfig(prod bool, wasmModuleRoot common.Hash, rollupOwner com } type Config struct { - Sequencer bool `koanf:"sequencer"` - ParentChainReader headerreader.Config `koanf:"parent-chain-reader" reload:"hot"` - InboxReader InboxReaderConfig `koanf:"inbox-reader" reload:"hot"` - DelayedSequencer DelayedSequencerConfig `koanf:"delayed-sequencer" reload:"hot"` - BatchPoster BatchPosterConfig `koanf:"batch-poster" reload:"hot"` - MessagePruner MessagePrunerConfig `koanf:"message-pruner" reload:"hot"` - BlockValidator staker.BlockValidatorConfig `koanf:"block-validator" reload:"hot"` - Feed broadcastclient.FeedConfig `koanf:"feed" reload:"hot"` - Staker legacystaker.L1ValidatorConfig `koanf:"staker" reload:"hot"` - Bold boldstaker.BoldConfig `koanf:"bold"` - SeqCoordinator SeqCoordinatorConfig `koanf:"seq-coordinator"` - DataAvailability das.DataAvailabilityConfig `koanf:"data-availability"` - SyncMonitor SyncMonitorConfig `koanf:"sync-monitor"` - Dangerous DangerousConfig `koanf:"dangerous"` - TransactionStreamer TransactionStreamerConfig `koanf:"transaction-streamer" reload:"hot"` - Maintenance MaintenanceConfig `koanf:"maintenance" reload:"hot"` - ResourceMgmt resourcemanager.Config `koanf:"resource-mgmt" reload:"hot"` + Sequencer bool `koanf:"sequencer"` + ParentChainReader headerreader.Config `koanf:"parent-chain-reader" reload:"hot"` + InboxReader InboxReaderConfig `koanf:"inbox-reader" reload:"hot"` + DelayedSequencer DelayedSequencerConfig `koanf:"delayed-sequencer" reload:"hot"` + BatchPoster BatchPosterConfig `koanf:"batch-poster" reload:"hot"` + MessagePruner MessagePrunerConfig `koanf:"message-pruner" reload:"hot"` + BlockValidator staker.BlockValidatorConfig `koanf:"block-validator" reload:"hot"` + Feed broadcastclient.FeedConfig `koanf:"feed" reload:"hot"` + Staker legacystaker.L1ValidatorConfig `koanf:"staker" reload:"hot"` + Bold boldstaker.BoldConfig `koanf:"bold"` + SeqCoordinator SeqCoordinatorConfig `koanf:"seq-coordinator"` + DataAvailability das.DataAvailabilityConfig `koanf:"data-availability"` + SyncMonitor SyncMonitorConfig `koanf:"sync-monitor"` + Dangerous DangerousConfig `koanf:"dangerous"` + TransactionStreamer TransactionStreamerConfig `koanf:"transaction-streamer" reload:"hot"` + Maintenance MaintenanceConfig `koanf:"maintenance" reload:"hot"` + ResourceMgmt resourcemanager.Config `koanf:"resource-mgmt" reload:"hot"` + BlockMetadataFetcher BlockMetadataFetcherConfig `koanf:"block-metadata-fetcher" reload:"hot"` // SnapSyncConfig is only used for testing purposes, these should not be configured in production. SnapSyncTest SnapSyncConfig } @@ -135,6 +136,12 @@ func (c *Config) Validate() error { if err := c.Staker.Validate(); err != nil { return err } + if c.Sequencer && c.TransactionStreamer.TrackBlockMetadataFrom == 0 { + return errors.New("when sequencer is enabled track-block-metadata-from should be set as well") + } + if c.TransactionStreamer.TrackBlockMetadataFrom != 0 && !c.BlockMetadataFetcher.Enable { + log.Warn("track-block-metadata-from is set but blockMetadata fetcher is not enabled") + } return nil } @@ -165,27 +172,29 @@ func ConfigAddOptions(prefix string, f *flag.FlagSet, feedInputEnable bool, feed DangerousConfigAddOptions(prefix+".dangerous", f) TransactionStreamerConfigAddOptions(prefix+".transaction-streamer", f) MaintenanceConfigAddOptions(prefix+".maintenance", f) + BlockMetadataFetcherConfigAddOptions(prefix+".block-metadata-fetcher", f) } var ConfigDefault = Config{ - Sequencer: false, - ParentChainReader: headerreader.DefaultConfig, - InboxReader: DefaultInboxReaderConfig, - DelayedSequencer: DefaultDelayedSequencerConfig, - BatchPoster: DefaultBatchPosterConfig, - MessagePruner: DefaultMessagePrunerConfig, - BlockValidator: staker.DefaultBlockValidatorConfig, - Feed: broadcastclient.FeedConfigDefault, - Staker: legacystaker.DefaultL1ValidatorConfig, - Bold: boldstaker.DefaultBoldConfig, - SeqCoordinator: DefaultSeqCoordinatorConfig, - DataAvailability: das.DefaultDataAvailabilityConfig, - SyncMonitor: DefaultSyncMonitorConfig, - Dangerous: DefaultDangerousConfig, - TransactionStreamer: DefaultTransactionStreamerConfig, - ResourceMgmt: resourcemanager.DefaultConfig, - Maintenance: DefaultMaintenanceConfig, - SnapSyncTest: DefaultSnapSyncConfig, + Sequencer: false, + ParentChainReader: headerreader.DefaultConfig, + InboxReader: DefaultInboxReaderConfig, + DelayedSequencer: DefaultDelayedSequencerConfig, + BatchPoster: DefaultBatchPosterConfig, + MessagePruner: DefaultMessagePrunerConfig, + BlockValidator: staker.DefaultBlockValidatorConfig, + Feed: broadcastclient.FeedConfigDefault, + Staker: legacystaker.DefaultL1ValidatorConfig, + Bold: boldstaker.DefaultBoldConfig, + SeqCoordinator: DefaultSeqCoordinatorConfig, + DataAvailability: das.DefaultDataAvailabilityConfig, + SyncMonitor: DefaultSyncMonitorConfig, + Dangerous: DefaultDangerousConfig, + TransactionStreamer: DefaultTransactionStreamerConfig, + ResourceMgmt: resourcemanager.DefaultConfig, + Maintenance: DefaultMaintenanceConfig, + BlockMetadataFetcher: DefaultBlockMetadataFetcherConfig, + SnapSyncTest: DefaultSnapSyncConfig, } func ConfigDefaultL1Test() *Config { @@ -195,6 +204,7 @@ func ConfigDefaultL1Test() *Config { config.SeqCoordinator = TestSeqCoordinatorConfig config.Sequencer = true config.Dangerous.NoSequencerCoordinator = true + config.TransactionStreamer.TrackBlockMetadataFrom = 1 return config } @@ -282,6 +292,7 @@ type Node struct { MaintenanceRunner *MaintenanceRunner DASLifecycleManager *das.LifecycleManager SyncMonitor *SyncMonitor + blockMetadataFetcher *BlockMetadataFetcher configFetcher ConfigFetcher ctx context.Context } @@ -515,6 +526,14 @@ func createNodeImpl( } } + var blockMetadataFetcher *BlockMetadataFetcher + if config.BlockMetadataFetcher.Enable { + blockMetadataFetcher, err = NewBlockMetadataFetcher(ctx, config.BlockMetadataFetcher, arbDb, exec) + if err != nil { + return nil, err + } + } + if !config.ParentChainReader.Enable { return &Node{ ArbDB: arbDb, @@ -538,6 +557,7 @@ func createNodeImpl( MaintenanceRunner: maintenanceRunner, DASLifecycleManager: nil, SyncMonitor: syncMonitor, + blockMetadataFetcher: blockMetadataFetcher, configFetcher: configFetcher, ctx: ctx, }, nil @@ -793,6 +813,7 @@ func createNodeImpl( MaintenanceRunner: maintenanceRunner, DASLifecycleManager: dasLifecycleManager, SyncMonitor: syncMonitor, + blockMetadataFetcher: blockMetadataFetcher, configFetcher: configFetcher, ctx: ctx, }, nil @@ -1023,6 +1044,9 @@ func (n *Node) Start(ctx context.Context) error { n.BroadcastClients.Start(ctx) }() } + if n.blockMetadataFetcher != nil { + n.blockMetadataFetcher.Start(ctx) + } if n.configFetcher != nil { n.configFetcher.Start(ctx) } @@ -1121,8 +1145,8 @@ func (n *Node) GetFinalizedMsgCount(ctx context.Context) (arbutil.MessageIndex, return n.InboxReader.GetFinalizedMsgCount(ctx) } -func (n *Node) WriteMessageFromSequencer(pos arbutil.MessageIndex, msgWithMeta arbostypes.MessageWithMetadata, msgResult execution.MessageResult) error { - return n.TxStreamer.WriteMessageFromSequencer(pos, msgWithMeta, msgResult) +func (n *Node) WriteMessageFromSequencer(pos arbutil.MessageIndex, msgWithMeta arbostypes.MessageWithMetadata, msgResult execution.MessageResult, blockMetadata common.BlockMetadata) error { + return n.TxStreamer.WriteMessageFromSequencer(pos, msgWithMeta, msgResult, blockMetadata) } func (n *Node) ExpectChosenSequencer() error { @@ -1135,3 +1159,7 @@ func (n *Node) ValidatedMessageCount() (arbutil.MessageIndex, error) { } return n.BlockValidator.GetValidated(), nil } + +func (n *Node) BlockMetadataAtCount(count arbutil.MessageIndex) (common.BlockMetadata, error) { + return n.TxStreamer.BlockMetadataAtCount(count) +} diff --git a/arbnode/schema.go b/arbnode/schema.go index 88a31ce90a..0114152e3c 100644 --- a/arbnode/schema.go +++ b/arbnode/schema.go @@ -4,14 +4,16 @@ package arbnode var ( - messagePrefix []byte = []byte("m") // maps a message sequence number to a message - blockHashInputFeedPrefix []byte = []byte("b") // maps a message sequence number to a block hash received through the input feed - messageResultPrefix []byte = []byte("r") // maps a message sequence number to a message result - legacyDelayedMessagePrefix []byte = []byte("d") // maps a delayed sequence number to an accumulator and a message as serialized on L1 - rlpDelayedMessagePrefix []byte = []byte("e") // maps a delayed sequence number to an accumulator and an RLP encoded message - parentChainBlockNumberPrefix []byte = []byte("p") // maps a delayed sequence number to a parent chain block number - sequencerBatchMetaPrefix []byte = []byte("s") // maps a batch sequence number to BatchMetadata - delayedSequencedPrefix []byte = []byte("a") // maps a delayed message count to the first sequencer batch sequence number with this delayed count + messagePrefix []byte = []byte("m") // maps a message sequence number to a message + blockHashInputFeedPrefix []byte = []byte("b") // maps a message sequence number to a block hash received through the input feed + blockMetadataInputFeedPrefix []byte = []byte("t") // maps a message sequence number to a blockMetaData byte array received through the input feed + missingBlockMetadataInputFeedPrefix []byte = []byte("x") // maps a message sequence number whose blockMetaData byte array is missing to nil + messageResultPrefix []byte = []byte("r") // maps a message sequence number to a message result + legacyDelayedMessagePrefix []byte = []byte("d") // maps a delayed sequence number to an accumulator and a message as serialized on L1 + rlpDelayedMessagePrefix []byte = []byte("e") // maps a delayed sequence number to an accumulator and an RLP encoded message + parentChainBlockNumberPrefix []byte = []byte("p") // maps a delayed sequence number to a parent chain block number + sequencerBatchMetaPrefix []byte = []byte("s") // maps a batch sequence number to BatchMetadata + delayedSequencedPrefix []byte = []byte("a") // maps a delayed message count to the first sequencer batch sequence number with this delayed count messageCountKey []byte = []byte("_messageCount") // contains the current message count lastPrunedMessageKey []byte = []byte("_lastPrunedMessageKey") // contains the last pruned message key diff --git a/arbnode/seq_coordinator.go b/arbnode/seq_coordinator.go index fc2f3c9cf6..c0065939ed 100644 --- a/arbnode/seq_coordinator.go +++ b/arbnode/seq_coordinator.go @@ -17,6 +17,7 @@ import ( "github.com/redis/go-redis/v9" flag "github.com/spf13/pflag" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/metrics" @@ -68,6 +69,7 @@ type SeqCoordinatorConfig struct { LockoutDuration time.Duration `koanf:"lockout-duration"` LockoutSpare time.Duration `koanf:"lockout-spare"` SeqNumDuration time.Duration `koanf:"seq-num-duration"` + BlockMetadataDuration time.Duration `koanf:"block-metadata-duration"` UpdateInterval time.Duration `koanf:"update-interval"` RetryInterval time.Duration `koanf:"retry-interval"` HandoffTimeout time.Duration `koanf:"handoff-timeout"` @@ -95,6 +97,7 @@ func SeqCoordinatorConfigAddOptions(prefix string, f *flag.FlagSet) { f.Duration(prefix+".lockout-duration", DefaultSeqCoordinatorConfig.LockoutDuration, "") f.Duration(prefix+".lockout-spare", DefaultSeqCoordinatorConfig.LockoutSpare, "") f.Duration(prefix+".seq-num-duration", DefaultSeqCoordinatorConfig.SeqNumDuration, "") + f.Duration(prefix+".block-metadata-duration", DefaultSeqCoordinatorConfig.BlockMetadataDuration, "") f.Duration(prefix+".update-interval", DefaultSeqCoordinatorConfig.UpdateInterval, "") f.Duration(prefix+".retry-interval", DefaultSeqCoordinatorConfig.RetryInterval, "") f.Duration(prefix+".handoff-timeout", DefaultSeqCoordinatorConfig.HandoffTimeout, "the maximum amount of time to spend waiting for another sequencer to accept the lockout when handing it off on shutdown or db compaction") @@ -114,6 +117,7 @@ var DefaultSeqCoordinatorConfig = SeqCoordinatorConfig{ LockoutDuration: time.Minute, LockoutSpare: 30 * time.Second, SeqNumDuration: 10 * 24 * time.Hour, + BlockMetadataDuration: 10 * 24 * time.Hour, UpdateInterval: 250 * time.Millisecond, HandoffTimeout: 30 * time.Second, SafeShutdownDelay: 5 * time.Second, @@ -126,21 +130,22 @@ var DefaultSeqCoordinatorConfig = SeqCoordinatorConfig{ } var TestSeqCoordinatorConfig = SeqCoordinatorConfig{ - Enable: false, - RedisUrl: "", - NewRedisUrl: "", - LockoutDuration: time.Second * 2, - LockoutSpare: time.Millisecond * 10, - SeqNumDuration: time.Minute * 10, - UpdateInterval: time.Millisecond * 10, - HandoffTimeout: time.Millisecond * 200, - SafeShutdownDelay: time.Millisecond * 100, - ReleaseRetries: 4, - RetryInterval: time.Millisecond * 3, - MsgPerPoll: 20, - MyUrl: redisutil.INVALID_URL, - DeleteFinalizedMsgs: true, - Signer: signature.DefaultSignVerifyConfig, + Enable: false, + RedisUrl: "", + NewRedisUrl: "", + LockoutDuration: time.Second * 2, + LockoutSpare: time.Millisecond * 10, + SeqNumDuration: time.Minute * 10, + BlockMetadataDuration: time.Minute * 10, + UpdateInterval: time.Millisecond * 10, + HandoffTimeout: time.Millisecond * 200, + SafeShutdownDelay: time.Millisecond * 100, + ReleaseRetries: 4, + RetryInterval: time.Millisecond * 3, + MsgPerPoll: 20, + MyUrl: redisutil.INVALID_URL, + DeleteFinalizedMsgs: true, + Signer: signature.DefaultSignVerifyConfig, } func NewSeqCoordinator( @@ -266,7 +271,7 @@ func (c *SeqCoordinator) signedBytesToMsgCount(ctx context.Context, data []byte) } // Acquires or refreshes the chosen one lockout and optionally writes a message into redis atomically. -func (c *SeqCoordinator) acquireLockoutAndWriteMessage(ctx context.Context, msgCountExpected, msgCountToWrite arbutil.MessageIndex, lastmsg *arbostypes.MessageWithMetadata) error { +func (c *SeqCoordinator) acquireLockoutAndWriteMessage(ctx context.Context, msgCountExpected, msgCountToWrite arbutil.MessageIndex, lastmsg *arbostypes.MessageWithMetadata, blockMetadata common.BlockMetadata) error { var messageData *string var messageSigData *string if lastmsg != nil { @@ -337,6 +342,9 @@ func (c *SeqCoordinator) acquireLockoutAndWriteMessage(ctx context.Context, msgC pipe.Set(ctx, redisutil.MessageSigKeyFor(msgCountToWrite-1), *messageSigData, c.config.SeqNumDuration) } } + if blockMetadata != nil { + pipe.Set(ctx, redisutil.BlockMetadataKeyFor(msgCountToWrite-1), string(blockMetadata), c.config.BlockMetadataDuration) + } pipe.PExpireAt(ctx, redisutil.CHOSENSEQ_KEY, lockoutUntil) if setWantsLockout { myWantsLockoutKey := redisutil.WantsLockoutKeyFor(c.config.Url()) @@ -529,7 +537,7 @@ func (c *SeqCoordinator) updateWithLockout(ctx context.Context, nextChosen strin log.Error("coordinator cannot read message count", "err", err) return c.config.UpdateInterval } - err = c.acquireLockoutAndWriteMessage(ctx, localMsgCount, localMsgCount, nil) + err = c.acquireLockoutAndWriteMessage(ctx, localMsgCount, localMsgCount, nil, nil) if err != nil { log.Warn("coordinator failed chosen-one keepalive", "err", err) return c.retryAfterRedisError() @@ -593,6 +601,17 @@ func (c *SeqCoordinator) deleteFinalizedMsgsFromRedis(ctx context.Context, final return nil } +func (c *SeqCoordinator) blockMetadataAt(ctx context.Context, pos arbutil.MessageIndex) (common.BlockMetadata, error) { + blockMetadataStr, err := c.RedisCoordinator().Client.Get(ctx, redisutil.BlockMetadataKeyFor(pos)).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return nil, nil + } + return nil, err + } + return common.BlockMetadata(blockMetadataStr), nil +} + func (c *SeqCoordinator) update(ctx context.Context) time.Duration { chosenSeq, err := c.RedisCoordinator().RecommendSequencerWantingLockout(ctx) if err != nil { @@ -657,6 +676,7 @@ func (c *SeqCoordinator) update(ctx context.Context) time.Duration { log.Info("coordinator caught up to prev redis coordinator", "msgcount", localMsgCount, "prevMsgCount", c.prevRedisMessageCount) } var messages []arbostypes.MessageWithMetadata + var blockMetadataArr []common.BlockMetadata msgToRead := localMsgCount var msgReadErr error for msgToRead < readUntil && localMsgCount >= remoteFinalizedMsgCount { @@ -716,10 +736,17 @@ func (c *SeqCoordinator) update(ctx context.Context) time.Duration { } } messages = append(messages, message) + blockMetadata, err := c.blockMetadataAt(ctx, msgToRead) + if err != nil { + log.Warn("SeqCoordinator failed reading blockMetadata from redis", "pos", msgToRead, "err", err) + msgReadErr = err + break + } + blockMetadataArr = append(blockMetadataArr, blockMetadata) msgToRead++ } if len(messages) > 0 { - if err := c.streamer.AddMessages(localMsgCount, false, messages); err != nil { + if err := c.streamer.AddMessages(localMsgCount, false, messages, blockMetadataArr); err != nil { log.Warn("coordinator failed to add messages", "err", err, "pos", localMsgCount, "length", len(messages)) } else { localMsgCount = msgToRead @@ -756,7 +783,7 @@ func (c *SeqCoordinator) update(ctx context.Context) time.Duration { // we're here because we don't currently hold the lock // sequencer is already either paused or forwarding c.sequencer.Pause() - err := c.acquireLockoutAndWriteMessage(ctx, localMsgCount, localMsgCount, nil) + err := c.acquireLockoutAndWriteMessage(ctx, localMsgCount, localMsgCount, nil, nil) if err != nil { // this could be just new messages we didn't get yet - even then, we should retry soon log.Info("sequencer failed to become chosen", "err", err, "msgcount", localMsgCount) @@ -965,11 +992,11 @@ func (c *SeqCoordinator) CurrentlyChosen() bool { return time.Now().Before(atomicTimeRead(&c.lockoutUntil)) } -func (c *SeqCoordinator) SequencingMessage(pos arbutil.MessageIndex, msg *arbostypes.MessageWithMetadata) error { +func (c *SeqCoordinator) SequencingMessage(pos arbutil.MessageIndex, msg *arbostypes.MessageWithMetadata, blockMetadata common.BlockMetadata) error { if !c.CurrentlyChosen() { return fmt.Errorf("%w: not main sequencer", execution.ErrRetrySequencer) } - if err := c.acquireLockoutAndWriteMessage(c.GetContext(), pos, pos+1, msg); err != nil { + if err := c.acquireLockoutAndWriteMessage(c.GetContext(), pos, pos+1, msg, blockMetadata); err != nil { return err } return nil diff --git a/arbnode/seq_coordinator_test.go b/arbnode/seq_coordinator_test.go index 3f35011c20..e308247d34 100644 --- a/arbnode/seq_coordinator_test.go +++ b/arbnode/seq_coordinator_test.go @@ -4,6 +4,7 @@ package arbnode import ( + "bytes" "context" "fmt" "math/rand" @@ -12,6 +13,8 @@ import ( "testing" "time" + "github.com/ethereum/go-ethereum/common" + "github.com/offchainlabs/nitro/arbos/arbostypes" "github.com/offchainlabs/nitro/arbutil" "github.com/offchainlabs/nitro/util/redisutil" @@ -50,7 +53,7 @@ func coordinatorTestThread(ctx context.Context, coord *SeqCoordinator, data *Coo } asIndex := arbutil.MessageIndex(messageCount) holdingLockout := atomicTimeRead(&coord.lockoutUntil) - err := coord.acquireLockoutAndWriteMessage(ctx, asIndex, asIndex+1, &arbostypes.EmptyTestMessageWithMetadata) + err := coord.acquireLockoutAndWriteMessage(ctx, asIndex, asIndex+1, &arbostypes.EmptyTestMessageWithMetadata, nil) if err == nil { sequenced[messageCount] = true data.messageCount.Store(messageCount + 1) @@ -247,3 +250,42 @@ func TestSeqCoordinatorDeletesFinalizedMessages(t *testing.T) { t.Fatal("non-finalized messages and signatures in range 7 to 10 are not fully available") } } + +func TestSeqCoordinatorAddsBlockMetadata(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + coordConfig := TestSeqCoordinatorConfig + coordConfig.LockoutDuration = time.Millisecond * 100 + coordConfig.LockoutSpare = time.Millisecond * 10 + coordConfig.Signer.ECDSA.AcceptSequencer = false + coordConfig.Signer.SymmetricFallback = true + coordConfig.Signer.SymmetricSign = true + coordConfig.Signer.Symmetric.Dangerous.DisableSignatureVerification = true + coordConfig.Signer.Symmetric.SigningKey = "" + + nullSigner, err := signature.NewSignVerify(&coordConfig.Signer, nil, nil) + Require(t, err) + + redisUrl := redisutil.CreateTestRedis(ctx, t) + coordConfig.RedisUrl = redisUrl + + config := coordConfig + config.MyUrl = "test" + redisCoordinator, err := redisutil.NewRedisCoordinator(config.RedisUrl) + Require(t, err) + coordinator := &SeqCoordinator{ + redisCoordinator: *redisCoordinator, + config: config, + signer: nullSigner, + } + + pos := arbutil.MessageIndex(1) + blockMetadataWant := common.BlockMetadata{0, 4} + Require(t, coordinator.acquireLockoutAndWriteMessage(ctx, pos, pos+1, &arbostypes.EmptyTestMessageWithMetadata, blockMetadataWant)) + blockMetadataGot, err := coordinator.blockMetadataAt(ctx, pos) + Require(t, err) + if !bytes.Equal(blockMetadataWant, blockMetadataGot) { + t.Fatal("got incorrect blockMetadata") + } +} diff --git a/arbnode/transaction_streamer.go b/arbnode/transaction_streamer.go index 1a961ebd3f..935de1adf5 100644 --- a/arbnode/transaction_streamer.go +++ b/arbnode/transaction_streamer.go @@ -59,7 +59,7 @@ type TransactionStreamer struct { nextAllowedFeedReorgLog time.Time - broadcasterQueuedMessages []arbostypes.MessageWithMetadataAndBlockHash + broadcasterQueuedMessages []arbostypes.MessageWithMetadataAndBlockInfo broadcasterQueuedMessagesPos atomic.Uint64 broadcasterQueuedMessagesActiveReorg bool @@ -67,12 +67,15 @@ type TransactionStreamer struct { broadcastServer *broadcaster.Broadcaster inboxReader *InboxReader delayedBridge *DelayedBridge + + trackBlockMetadataFrom arbutil.MessageIndex } type TransactionStreamerConfig struct { MaxBroadcasterQueueSize int `koanf:"max-broadcaster-queue-size"` MaxReorgResequenceDepth int64 `koanf:"max-reorg-resequence-depth" reload:"hot"` ExecuteMessageLoopDelay time.Duration `koanf:"execute-message-loop-delay" reload:"hot"` + TrackBlockMetadataFrom uint64 `koanf:"track-block-metadata-from"` } type TransactionStreamerConfigFetcher func() *TransactionStreamerConfig @@ -81,18 +84,21 @@ var DefaultTransactionStreamerConfig = TransactionStreamerConfig{ MaxBroadcasterQueueSize: 50_000, MaxReorgResequenceDepth: 1024, ExecuteMessageLoopDelay: time.Millisecond * 100, + TrackBlockMetadataFrom: 0, } var TestTransactionStreamerConfig = TransactionStreamerConfig{ MaxBroadcasterQueueSize: 10_000, MaxReorgResequenceDepth: 128 * 1024, ExecuteMessageLoopDelay: time.Millisecond, + TrackBlockMetadataFrom: 0, } func TransactionStreamerConfigAddOptions(prefix string, f *flag.FlagSet) { f.Int(prefix+".max-broadcaster-queue-size", DefaultTransactionStreamerConfig.MaxBroadcasterQueueSize, "maximum cache of pending broadcaster messages") f.Int64(prefix+".max-reorg-resequence-depth", DefaultTransactionStreamerConfig.MaxReorgResequenceDepth, "maximum number of messages to attempt to resequence on reorg (0 = never resequence, -1 = always resequence)") f.Duration(prefix+".execute-message-loop-delay", DefaultTransactionStreamerConfig.ExecuteMessageLoopDelay, "delay when polling calls to execute messages") + f.Uint64(prefix+".track-block-metadata-from", DefaultTransactionStreamerConfig.TrackBlockMetadataFrom, "this is the block number starting from which missing of blockmetadata is being tracked in the local disk. Setting to zero (default value) disables this") } func NewTransactionStreamer( @@ -118,6 +124,13 @@ func NewTransactionStreamer( if err != nil { return nil, err } + if config().TrackBlockMetadataFrom != 0 { + trackBlockMetadataFrom, err := exec.BlockNumberToMessageIndex(config().TrackBlockMetadataFrom) + if err != nil { + return nil, err + } + streamer.trackBlockMetadataFrom = trackBlockMetadataFrom + } return streamer, nil } @@ -263,7 +276,7 @@ func deleteFromRange(ctx context.Context, db ethdb.Database, prefix []byte, star // The insertion mutex must be held. This acquires the reorg mutex. // Note: oldMessages will be empty if reorgHook is nil -func (s *TransactionStreamer) reorg(batch ethdb.Batch, count arbutil.MessageIndex, newMessages []arbostypes.MessageWithMetadataAndBlockHash) error { +func (s *TransactionStreamer) reorg(batch ethdb.Batch, count arbutil.MessageIndex, newMessages []arbostypes.MessageWithMetadataAndBlockInfo) error { if count == 0 { return errors.New("cannot reorg out init message") } @@ -358,9 +371,9 @@ func (s *TransactionStreamer) reorg(batch ethdb.Batch, count arbutil.MessageInde return err } - messagesWithComputedBlockHash := make([]arbostypes.MessageWithMetadataAndBlockHash, 0, len(messagesResults)) + messagesWithComputedBlockHash := make([]arbostypes.MessageWithMetadataAndBlockInfo, 0, len(messagesResults)) for i := 0; i < len(messagesResults); i++ { - messagesWithComputedBlockHash = append(messagesWithComputedBlockHash, arbostypes.MessageWithMetadataAndBlockHash{ + messagesWithComputedBlockHash = append(messagesWithComputedBlockHash, arbostypes.MessageWithMetadataAndBlockInfo{ MessageWithMeta: newMessages[i].MessageWithMeta, BlockHash: &messagesResults[i].BlockHash, }) @@ -382,6 +395,14 @@ func (s *TransactionStreamer) reorg(batch ethdb.Batch, count arbutil.MessageInde if err != nil { return err } + err = deleteStartingAt(s.db, batch, blockMetadataInputFeedPrefix, uint64ToKey(uint64(count))) + if err != nil { + return err + } + err = deleteStartingAt(s.db, batch, missingBlockMetadataInputFeedPrefix, uint64ToKey(uint64(count))) + if err != nil { + return err + } err = deleteStartingAt(s.db, batch, messagePrefix, uint64ToKey(uint64(count))) if err != nil { return err @@ -448,7 +469,7 @@ func (s *TransactionStreamer) GetMessage(seqNum arbutil.MessageIndex) (*arbostyp return &message, nil } -func (s *TransactionStreamer) getMessageWithMetadataAndBlockHash(seqNum arbutil.MessageIndex) (*arbostypes.MessageWithMetadataAndBlockHash, error) { +func (s *TransactionStreamer) getMessageWithMetadataAndBlockInfo(seqNum arbutil.MessageIndex) (*arbostypes.MessageWithMetadataAndBlockInfo, error) { msg, err := s.GetMessage(seqNum) if err != nil { return nil, err @@ -471,11 +492,21 @@ func (s *TransactionStreamer) getMessageWithMetadataAndBlockHash(seqNum arbutil. return nil, err } - msgWithBlockHash := arbostypes.MessageWithMetadataAndBlockHash{ + key = dbKey(blockMetadataInputFeedPrefix, uint64(seqNum)) + blockMetadata, err := s.db.Get(key) + if err != nil { + if !dbutil.IsErrNotFound(err) { + return nil, err + } + blockMetadata = nil + } + + msgWithBlockInfo := arbostypes.MessageWithMetadataAndBlockInfo{ MessageWithMeta: *msg, BlockHash: blockHash, + BlockMetadata: blockMetadata, } - return &msgWithBlockHash, nil + return &msgWithBlockInfo, nil } // Note: if changed to acquire the mutex, some internal users may need to be updated to a non-locking version. @@ -507,8 +538,8 @@ func (s *TransactionStreamer) GetProcessedMessageCount() (arbutil.MessageIndex, return msgCount, nil } -func (s *TransactionStreamer) AddMessages(pos arbutil.MessageIndex, messagesAreConfirmed bool, messages []arbostypes.MessageWithMetadata) error { - return s.AddMessagesAndEndBatch(pos, messagesAreConfirmed, messages, nil) +func (s *TransactionStreamer) AddMessages(pos arbutil.MessageIndex, messagesAreConfirmed bool, messages []arbostypes.MessageWithMetadata, blockMetadataArr []common.BlockMetadata) error { + return s.AddMessagesAndEndBatch(pos, messagesAreConfirmed, messages, blockMetadataArr, nil) } func (s *TransactionStreamer) FeedPendingMessageCount() arbutil.MessageIndex { @@ -531,7 +562,7 @@ func (s *TransactionStreamer) AddBroadcastMessages(feedMessages []*m.BroadcastFe return nil } broadcastStartPos := feedMessages[0].SequenceNumber - var messages []arbostypes.MessageWithMetadataAndBlockHash + var messages []arbostypes.MessageWithMetadataAndBlockInfo broadcastAfterPos := broadcastStartPos for _, feedMessage := range feedMessages { if broadcastAfterPos != feedMessage.SequenceNumber { @@ -540,11 +571,12 @@ func (s *TransactionStreamer) AddBroadcastMessages(feedMessages []*m.BroadcastFe if feedMessage.Message.Message == nil || feedMessage.Message.Message.Header == nil { return fmt.Errorf("invalid feed message at sequence number %v", feedMessage.SequenceNumber) } - msgWithBlockHash := arbostypes.MessageWithMetadataAndBlockHash{ + msgWithBlockInfo := arbostypes.MessageWithMetadataAndBlockInfo{ MessageWithMeta: feedMessage.Message, BlockHash: feedMessage.BlockHash, + BlockMetadata: feedMessage.BlockMetadata, } - messages = append(messages, msgWithBlockHash) + messages = append(messages, msgWithBlockInfo) broadcastAfterPos++ } @@ -647,7 +679,7 @@ func (s *TransactionStreamer) AddFakeInitMessage() error { L2msg: msg, }, DelayedMessagesRead: 1, - }}) + }}, nil) } // Used in redis tests @@ -664,19 +696,27 @@ func endBatch(batch ethdb.Batch) error { return batch.Write() } -func (s *TransactionStreamer) AddMessagesAndEndBatch(pos arbutil.MessageIndex, messagesAreConfirmed bool, messages []arbostypes.MessageWithMetadata, batch ethdb.Batch) error { - messagesWithBlockHash := make([]arbostypes.MessageWithMetadataAndBlockHash, 0, len(messages)) +func (s *TransactionStreamer) AddMessagesAndEndBatch(pos arbutil.MessageIndex, messagesAreConfirmed bool, messages []arbostypes.MessageWithMetadata, blockMetadataArr []common.BlockMetadata, batch ethdb.Batch) error { + messagesWithBlockInfo := make([]arbostypes.MessageWithMetadataAndBlockInfo, 0, len(messages)) for _, message := range messages { - messagesWithBlockHash = append(messagesWithBlockHash, arbostypes.MessageWithMetadataAndBlockHash{ + messagesWithBlockInfo = append(messagesWithBlockInfo, arbostypes.MessageWithMetadataAndBlockInfo{ MessageWithMeta: message, }) } + if len(blockMetadataArr) == len(messagesWithBlockInfo) { + for i, blockMetadata := range blockMetadataArr { + messagesWithBlockInfo[i].BlockMetadata = blockMetadata + } + } else if len(blockMetadataArr) > 0 { + return fmt.Errorf("size of blockMetadata array doesn't match the size of messages array. lockMetadataArrSize: %d, messagesSize: %d", len(blockMetadataArr), len(messages)) + } + if messagesAreConfirmed { // Trim confirmed messages from l1pricedataCache s.exec.MarkFeedStart(pos + arbutil.MessageIndex(len(messages))) s.reorgMutex.RLock() - dups, _, _, err := s.countDuplicateMessages(pos, messagesWithBlockHash, nil) + dups, _, _, err := s.countDuplicateMessages(pos, messagesWithBlockInfo, nil) s.reorgMutex.RUnlock() if err != nil { return err @@ -693,7 +733,7 @@ func (s *TransactionStreamer) AddMessagesAndEndBatch(pos arbutil.MessageIndex, m s.insertionMutex.Lock() defer s.insertionMutex.Unlock() - return s.addMessagesAndEndBatchImpl(pos, messagesAreConfirmed, messagesWithBlockHash, batch) + return s.addMessagesAndEndBatchImpl(pos, messagesAreConfirmed, messagesWithBlockInfo, batch) } func (s *TransactionStreamer) getPrevPrevDelayedRead(pos arbutil.MessageIndex) (uint64, error) { @@ -714,7 +754,7 @@ func (s *TransactionStreamer) getPrevPrevDelayedRead(pos arbutil.MessageIndex) ( func (s *TransactionStreamer) countDuplicateMessages( pos arbutil.MessageIndex, - messages []arbostypes.MessageWithMetadataAndBlockHash, + messages []arbostypes.MessageWithMetadataAndBlockInfo, batch *ethdb.Batch, ) (uint64, bool, *arbostypes.MessageWithMetadata, error) { var curMsg uint64 @@ -808,7 +848,7 @@ func (s *TransactionStreamer) logReorg(pos arbutil.MessageIndex, dbMsg *arbostyp } -func (s *TransactionStreamer) addMessagesAndEndBatchImpl(messageStartPos arbutil.MessageIndex, messagesAreConfirmed bool, messages []arbostypes.MessageWithMetadataAndBlockHash, batch ethdb.Batch) error { +func (s *TransactionStreamer) addMessagesAndEndBatchImpl(messageStartPos arbutil.MessageIndex, messagesAreConfirmed bool, messages []arbostypes.MessageWithMetadataAndBlockInfo, batch ethdb.Batch) error { var confirmedReorg bool var oldMsg *arbostypes.MessageWithMetadata var lastDelayedRead uint64 @@ -952,6 +992,7 @@ func (s *TransactionStreamer) WriteMessageFromSequencer( pos arbutil.MessageIndex, msgWithMeta arbostypes.MessageWithMetadata, msgResult execution.MessageResult, + blockMetadata common.BlockMetadata, ) error { if err := s.ExpectChosenSequencer(); err != nil { return err @@ -971,20 +1012,21 @@ func (s *TransactionStreamer) WriteMessageFromSequencer( } if s.coordinator != nil { - if err := s.coordinator.SequencingMessage(pos, &msgWithMeta); err != nil { + if err := s.coordinator.SequencingMessage(pos, &msgWithMeta, blockMetadata); err != nil { return err } } - msgWithBlockHash := arbostypes.MessageWithMetadataAndBlockHash{ + msgWithBlockInfo := arbostypes.MessageWithMetadataAndBlockInfo{ MessageWithMeta: msgWithMeta, BlockHash: &msgResult.BlockHash, + BlockMetadata: blockMetadata, } - if err := s.writeMessages(pos, []arbostypes.MessageWithMetadataAndBlockHash{msgWithBlockHash}, nil); err != nil { + if err := s.writeMessages(pos, []arbostypes.MessageWithMetadataAndBlockInfo{msgWithBlockInfo}, nil); err != nil { return err } - s.broadcastMessages([]arbostypes.MessageWithMetadataAndBlockHash{msgWithBlockHash}, pos) + s.broadcastMessages([]arbostypes.MessageWithMetadataAndBlockInfo{msgWithBlockInfo}, pos) return nil } @@ -1005,7 +1047,7 @@ func (s *TransactionStreamer) PopulateFeedBacklog() error { return s.inboxReader.tracker.PopulateFeedBacklog(s.broadcastServer) } -func (s *TransactionStreamer) writeMessage(pos arbutil.MessageIndex, msg arbostypes.MessageWithMetadataAndBlockHash, batch ethdb.Batch) error { +func (s *TransactionStreamer) writeMessage(pos arbutil.MessageIndex, msg arbostypes.MessageWithMetadataAndBlockInfo, batch ethdb.Batch) error { // write message with metadata key := dbKey(messagePrefix, uint64(pos)) msgBytes, err := rlp.EncodeToBytes(msg.MessageWithMeta) @@ -1025,11 +1067,33 @@ func (s *TransactionStreamer) writeMessage(pos arbutil.MessageIndex, msg arbosty if err != nil { return err } - return batch.Put(key, msgBytes) + if err := batch.Put(key, msgBytes); err != nil { + return err + } + + if msg.BlockMetadata != nil { + // Only store non-nil BlockMetadata to db. In case of a reorg, we dont have to explicitly + // clear out BlockMetadata of the reorged message, since those messages will be handled by s.reorg() + // This also allows update of BatchGasCost in message without mistakenly erasing BlockMetadata + key = dbKey(blockMetadataInputFeedPrefix, uint64(pos)) + return batch.Put(key, msg.BlockMetadata) + } else if s.trackBlockMetadataFrom != 0 && pos >= s.trackBlockMetadataFrom { + // Mark that blockMetadata is missing only if it isn't already present. This check prevents unnecessary marking + // when updating BatchGasCost or when adding messages from seq-coordinator redis that doesn't have block metadata + prevBlockMetadata, err := s.BlockMetadataAtCount(pos + 1) + if err != nil { + return err + } + if prevBlockMetadata == nil { + key = dbKey(missingBlockMetadataInputFeedPrefix, uint64(pos)) + return batch.Put(key, nil) + } + } + return nil } func (s *TransactionStreamer) broadcastMessages( - msgs []arbostypes.MessageWithMetadataAndBlockHash, + msgs []arbostypes.MessageWithMetadataAndBlockInfo, pos arbutil.MessageIndex, ) { if s.broadcastServer == nil { @@ -1042,7 +1106,7 @@ func (s *TransactionStreamer) broadcastMessages( // The mutex must be held, and pos must be the latest message count. // `batch` may be nil, which initializes a new batch. The batch is closed out in this function. -func (s *TransactionStreamer) writeMessages(pos arbutil.MessageIndex, messages []arbostypes.MessageWithMetadataAndBlockHash, batch ethdb.Batch) error { +func (s *TransactionStreamer) writeMessages(pos arbutil.MessageIndex, messages []arbostypes.MessageWithMetadataAndBlockInfo, batch ethdb.Batch) error { if batch == nil { batch = s.db.NewBatch() } @@ -1071,6 +1135,23 @@ func (s *TransactionStreamer) writeMessages(pos arbutil.MessageIndex, messages [ return nil } +func (s *TransactionStreamer) BlockMetadataAtCount(count arbutil.MessageIndex) (common.BlockMetadata, error) { + if count == 0 { + return nil, nil + } + pos := count - 1 + + key := dbKey(blockMetadataInputFeedPrefix, uint64(pos)) + blockMetadata, err := s.db.Get(key) + if err != nil { + if dbutil.IsErrNotFound(err) { + return nil, nil + } + return nil, err + } + return blockMetadata, nil +} + func (s *TransactionStreamer) ResultAtCount(count arbutil.MessageIndex) (*execution.MessageResult, error) { if count == 0 { return &execution.MessageResult{}, nil @@ -1110,17 +1191,33 @@ func (s *TransactionStreamer) ResultAtCount(count arbutil.MessageIndex) (*execut return msgResult, nil } -func (s *TransactionStreamer) checkResult(msgResult *execution.MessageResult, expectedBlockHash *common.Hash) { - if expectedBlockHash == nil { +func (s *TransactionStreamer) checkResult(pos arbutil.MessageIndex, msgResult *execution.MessageResult, msgAndBlockInfo *arbostypes.MessageWithMetadataAndBlockInfo) { + if msgAndBlockInfo.BlockHash == nil { return } - if msgResult.BlockHash != *expectedBlockHash { + if msgResult.BlockHash != *msgAndBlockInfo.BlockHash { log.Error( BlockHashMismatchLogMsg, - "expected", expectedBlockHash, + "expected", msgAndBlockInfo.BlockHash, "actual", msgResult.BlockHash, ) - return + // Try deleting the existing blockMetadata for this block in arbDB and set it as missing + if msgAndBlockInfo.BlockMetadata != nil { + batch := s.db.NewBatch() + if err := batch.Delete(dbKey(blockMetadataInputFeedPrefix, uint64(pos))); err != nil { + log.Error("error deleting blockMetadata of block whose BlockHash from feed doesn't match locally computed hash", "msgSeqNum", pos, "err", err) + return + } + if s.trackBlockMetadataFrom != 0 && pos >= s.trackBlockMetadataFrom { + if err := batch.Put(dbKey(missingBlockMetadataInputFeedPrefix, uint64(pos)), nil); err != nil { + log.Error("error marking deleted blockMetadata as missing in arbDB for a block whose BlockHash from feed doesn't match locally computed hash", "msgSeqNum", pos, "err", err) + return + } + } + if err := batch.Write(); err != nil { + log.Error("error writing batch that deletes blockMetadata of the block whose BlockHash from feed doesn't match locally computed hash", "msgSeqNum", pos, "err", err) + } + } } } @@ -1163,7 +1260,7 @@ func (s *TransactionStreamer) ExecuteNextMsg(ctx context.Context) bool { if pos >= msgCount { return false } - msgAndBlockHash, err := s.getMessageWithMetadataAndBlockHash(pos) + msgAndBlockInfo, err := s.getMessageWithMetadataAndBlockInfo(pos) if err != nil { log.Error("feedOneMsg failed to readMessage", "err", err, "pos", pos) return false @@ -1177,7 +1274,7 @@ func (s *TransactionStreamer) ExecuteNextMsg(ctx context.Context) bool { } msgForPrefetch = msg } - msgResult, err := s.exec.DigestMessage(pos, &msgAndBlockHash.MessageWithMeta, msgForPrefetch) + msgResult, err := s.exec.DigestMessage(pos, &msgAndBlockInfo.MessageWithMeta, msgForPrefetch) if err != nil { logger := log.Warn if prevMessageCount < msgCount { @@ -1187,7 +1284,7 @@ func (s *TransactionStreamer) ExecuteNextMsg(ctx context.Context) bool { return false } - s.checkResult(msgResult, msgAndBlockHash.BlockHash) + s.checkResult(pos, msgResult, msgAndBlockInfo) batch := s.db.NewBatch() err = s.storeResult(pos, *msgResult, batch) @@ -1201,11 +1298,12 @@ func (s *TransactionStreamer) ExecuteNextMsg(ctx context.Context) bool { return false } - msgWithBlockHash := arbostypes.MessageWithMetadataAndBlockHash{ - MessageWithMeta: msgAndBlockHash.MessageWithMeta, + msgWithBlockInfo := arbostypes.MessageWithMetadataAndBlockInfo{ + MessageWithMeta: msgAndBlockInfo.MessageWithMeta, BlockHash: &msgResult.BlockHash, + BlockMetadata: msgAndBlockInfo.BlockMetadata, } - s.broadcastMessages([]arbostypes.MessageWithMetadataAndBlockHash{msgWithBlockHash}, pos) + s.broadcastMessages([]arbostypes.MessageWithMetadataAndBlockInfo{msgWithBlockInfo}, pos) return pos+1 < msgCount } diff --git a/arbos/arbostypes/messagewithmeta.go b/arbos/arbostypes/messagewithmeta.go index a3bc167526..c32c3cd795 100644 --- a/arbos/arbostypes/messagewithmeta.go +++ b/arbos/arbostypes/messagewithmeta.go @@ -19,9 +19,10 @@ type MessageWithMetadata struct { DelayedMessagesRead uint64 `json:"delayedMessagesRead"` } -type MessageWithMetadataAndBlockHash struct { +type MessageWithMetadataAndBlockInfo struct { MessageWithMeta MessageWithMetadata BlockHash *common.Hash + BlockMetadata common.BlockMetadata } var EmptyTestMessageWithMetadata = MessageWithMetadata{ diff --git a/broadcastclient/broadcastclient_test.go b/broadcastclient/broadcastclient_test.go index 0d9b8443e6..63ce1dbd84 100644 --- a/broadcastclient/broadcastclient_test.go +++ b/broadcastclient/broadcastclient_test.go @@ -93,7 +93,7 @@ func testReceiveMessages(t *testing.T, clientCompression bool, serverCompression go func() { for i := 0; i < messageCount; i++ { // #nosec G115 - Require(t, b.BroadcastSingle(arbostypes.TestMessageWithMetadataAndRequestId, arbutil.MessageIndex(i), nil)) + Require(t, b.BroadcastSingle(arbostypes.TestMessageWithMetadataAndRequestId, arbutil.MessageIndex(i), nil, nil)) } }() @@ -150,7 +150,7 @@ func TestInvalidSignature(t *testing.T) { go func() { for i := 0; i < messageCount; i++ { // #nosec G115 - Require(t, b.BroadcastSingle(arbostypes.TestMessageWithMetadataAndRequestId, arbutil.MessageIndex(i), nil)) + Require(t, b.BroadcastSingle(arbostypes.TestMessageWithMetadataAndRequestId, arbutil.MessageIndex(i), nil, nil)) } }() @@ -313,7 +313,7 @@ func TestServerClientDisconnect(t *testing.T) { broadcastClient.Start(ctx) t.Log("broadcasting seq 0 message") - Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 0, nil)) + Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 0, nil, nil)) // Wait for client to receive batch to ensure it is connected timer := time.NewTimer(5 * time.Second) @@ -385,7 +385,7 @@ func TestBroadcastClientConfirmedMessage(t *testing.T) { broadcastClient.Start(ctx) t.Log("broadcasting seq 0 message") - Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 0, nil)) + Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 0, nil, nil)) // Wait for client to receive batch to ensure it is connected timer := time.NewTimer(5 * time.Second) @@ -727,8 +727,8 @@ func TestBroadcasterSendsCachedMessagesOnClientConnect(t *testing.T) { Require(t, b.Start(ctx)) defer b.StopAndWait() - Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 0, nil)) - Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 1, nil)) + Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 0, nil, nil)) + Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 1, nil, nil)) var wg sync.WaitGroup for i := 0; i < 2; i++ { diff --git a/broadcaster/broadcaster.go b/broadcaster/broadcaster.go index 4fe8657bfa..2c4ffd96ec 100644 --- a/broadcaster/broadcaster.go +++ b/broadcaster/broadcaster.go @@ -43,6 +43,7 @@ func (b *Broadcaster) NewBroadcastFeedMessage( message arbostypes.MessageWithMetadata, sequenceNumber arbutil.MessageIndex, blockHash *common.Hash, + blockMetadata common.BlockMetadata, ) (*m.BroadcastFeedMessage, error) { var messageSignature []byte if b.dataSigner != nil { @@ -61,6 +62,7 @@ func (b *Broadcaster) NewBroadcastFeedMessage( Message: message, BlockHash: blockHash, Signature: messageSignature, + BlockMetadata: blockMetadata, }, nil } @@ -68,6 +70,7 @@ func (b *Broadcaster) BroadcastSingle( msg arbostypes.MessageWithMetadata, seq arbutil.MessageIndex, blockHash *common.Hash, + blockMetadata common.BlockMetadata, ) (err error) { defer func() { if r := recover(); r != nil { @@ -75,7 +78,7 @@ func (b *Broadcaster) BroadcastSingle( err = errors.New("panic in BroadcastSingle") } }() - bfm, err := b.NewBroadcastFeedMessage(msg, seq, blockHash) + bfm, err := b.NewBroadcastFeedMessage(msg, seq, blockHash, blockMetadata) if err != nil { return err } @@ -93,7 +96,7 @@ func (b *Broadcaster) BroadcastSingleFeedMessage(bfm *m.BroadcastFeedMessage) { } func (b *Broadcaster) BroadcastMessages( - messagesWithBlockHash []arbostypes.MessageWithMetadataAndBlockHash, + messagesWithBlockInfo []arbostypes.MessageWithMetadataAndBlockInfo, seq arbutil.MessageIndex, ) (err error) { defer func() { @@ -103,9 +106,9 @@ func (b *Broadcaster) BroadcastMessages( } }() var feedMessages []*m.BroadcastFeedMessage - for i, msg := range messagesWithBlockHash { + for i, msg := range messagesWithBlockInfo { // #nosec G115 - bfm, err := b.NewBroadcastFeedMessage(msg.MessageWithMeta, seq+arbutil.MessageIndex(i), msg.BlockHash) + bfm, err := b.NewBroadcastFeedMessage(msg.MessageWithMeta, seq+arbutil.MessageIndex(i), msg.BlockHash, msg.BlockMetadata) if err != nil { return err } diff --git a/broadcaster/broadcaster_test.go b/broadcaster/broadcaster_test.go index dc208f4163..7da7508e5c 100644 --- a/broadcaster/broadcaster_test.go +++ b/broadcaster/broadcaster_test.go @@ -70,17 +70,17 @@ func TestBroadcasterMessagesRemovedOnConfirmation(t *testing.T) { } // Normal broadcasting and confirming - Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 1, nil)) + Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 1, nil, nil)) waitUntilUpdated(t, expectMessageCount(1, "after 1 message")) - Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 2, nil)) + Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 2, nil, nil)) waitUntilUpdated(t, expectMessageCount(2, "after 2 messages")) - Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 3, nil)) + Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 3, nil, nil)) waitUntilUpdated(t, expectMessageCount(3, "after 3 messages")) - Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 4, nil)) + Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 4, nil, nil)) waitUntilUpdated(t, expectMessageCount(4, "after 4 messages")) - Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 5, nil)) + Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 5, nil, nil)) waitUntilUpdated(t, expectMessageCount(5, "after 4 messages")) - Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 6, nil)) + Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 6, nil, nil)) waitUntilUpdated(t, expectMessageCount(6, "after 4 messages")) b.Confirm(4) @@ -96,7 +96,7 @@ func TestBroadcasterMessagesRemovedOnConfirmation(t *testing.T) { "nothing changed because confirmed sequence number before cache")) b.Confirm(5) - Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 7, nil)) + Require(t, b.BroadcastSingle(arbostypes.EmptyTestMessageWithMetadata, 7, nil, nil)) waitUntilUpdated(t, expectMessageCount(2, "after 7 messages, 5 cleared by confirm")) diff --git a/broadcaster/message/message.go b/broadcaster/message/message.go index f2439912f8..3790fe8dae 100644 --- a/broadcaster/message/message.go +++ b/broadcaster/message/message.go @@ -8,7 +8,8 @@ import ( ) const ( - V1 = 1 + V1 = 1 + TimeboostedVersion = byte(0) ) // BroadcastMessage is the base message type for messages to send over the network. @@ -37,6 +38,7 @@ type BroadcastFeedMessage struct { Message arbostypes.MessageWithMetadata `json:"message"` BlockHash *common.Hash `json:"blockHash,omitempty"` Signature []byte `json:"signature"` + BlockMetadata common.BlockMetadata `json:"blockMetadata,omitempty"` CumulativeSumMsgSize uint64 `json:"-"` } diff --git a/broadcaster/message/message_blockmetadata_test.go b/broadcaster/message/message_blockmetadata_test.go new file mode 100644 index 0000000000..5e72e6e203 --- /dev/null +++ b/broadcaster/message/message_blockmetadata_test.go @@ -0,0 +1,44 @@ +package message + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +func TestTimeboostedInDifferentScenarios(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + blockMetadata common.BlockMetadata + txs []bool // Array representing whether the tx is timeboosted or not. First tx is always false as its an arbitrum internal tx + }{ + { + name: "block has no timeboosted tx", + blockMetadata: []byte{0, 0, 0}, // 00000000 00000000 + txs: []bool{false, false, false, false, false, false, false}, // num of tx in this block = 7 + }, + { + name: "block has only one timeboosted tx", + blockMetadata: []byte{0, 2}, // 00000000 01000000 + txs: []bool{false, true}, // num of tx in this block = 2 + }, + { + name: "block has multiple timeboosted tx", + blockMetadata: []byte{0, 86, 145}, // 00000000 01101010 10001001 + txs: []bool{false, true, true, false, true, false, true, false, true, false, false, false, true, false, false, true}, // num of tx in this block = 16 + }, + } { + t.Run(tc.name, func(t *testing.T) { + for txIndex, want := range tc.txs { + have, err := tc.blockMetadata.IsTxTimeboosted(txIndex) + if err != nil { + t.Fatalf("error getting timeboosted bit for tx of index %d: %v", txIndex, err) + } + if want != have { + t.Fatalf("incorrect timeboosted bit for tx of index %d, Got: %v, Want: %v", txIndex, have, want) + } + } + }) + } +} diff --git a/broadcaster/message/message_serialization_test.go b/broadcaster/message/message_serialization_test.go index 5fb9d55dda..bb444cec51 100644 --- a/broadcaster/message/message_serialization_test.go +++ b/broadcaster/message/message_serialization_test.go @@ -14,7 +14,7 @@ import ( "github.com/offchainlabs/nitro/arbos/arbostypes" ) -func ExampleBroadcastMessage_broadcastfeedmessageWithBlockHash() { +func ExampleBroadcastMessage_broadcastfeedmessageWithBlockHashAndBlockMetadata() { var requestId common.Hash msg := BroadcastMessage{ Version: 1, @@ -35,8 +35,9 @@ func ExampleBroadcastMessage_broadcastfeedmessageWithBlockHash() { }, DelayedMessagesRead: 3333, }, - BlockHash: &common.Hash{0: 0xff}, - Signature: nil, + BlockHash: &common.Hash{0: 0xff}, + Signature: nil, + BlockMetadata: []byte{0, 2}, }, }, } @@ -44,10 +45,10 @@ func ExampleBroadcastMessage_broadcastfeedmessageWithBlockHash() { encoder := json.NewEncoder(&buf) _ = encoder.Encode(msg) fmt.Println(buf.String()) - // Output: {"version":1,"messages":[{"sequenceNumber":12345,"message":{"message":{"header":{"kind":0,"sender":"0x0000000000000000000000000000000000000000","blockNumber":0,"timestamp":0,"requestId":"0x0000000000000000000000000000000000000000000000000000000000000000","baseFeeL1":0},"l2Msg":"3q2+7w=="},"delayedMessagesRead":3333},"blockHash":"0xff00000000000000000000000000000000000000000000000000000000000000","signature":null}]} + // Output: {"version":1,"messages":[{"sequenceNumber":12345,"message":{"message":{"header":{"kind":0,"sender":"0x0000000000000000000000000000000000000000","blockNumber":0,"timestamp":0,"requestId":"0x0000000000000000000000000000000000000000000000000000000000000000","baseFeeL1":0},"l2Msg":"3q2+7w=="},"delayedMessagesRead":3333},"blockHash":"0xff00000000000000000000000000000000000000000000000000000000000000","signature":null,"blockMetadata":"AAI="}]} } -func ExampleBroadcastMessage_broadcastfeedmessageWithoutBlockHash() { +func ExampleBroadcastMessage_broadcastfeedmessageWithoutBlockHashAndBlockMetadata() { var requestId common.Hash msg := BroadcastMessage{ Version: 1, @@ -68,7 +69,8 @@ func ExampleBroadcastMessage_broadcastfeedmessageWithoutBlockHash() { }, DelayedMessagesRead: 3333, }, - Signature: nil, + Signature: nil, + BlockMetadata: nil, }, }, } diff --git a/cmd/autonomous-auctioneer/config.go b/cmd/autonomous-auctioneer/config.go new file mode 100644 index 0000000000..d3f96a8f85 --- /dev/null +++ b/cmd/autonomous-auctioneer/config.go @@ -0,0 +1,159 @@ +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 { + if err := c.AuctioneerServer.S3Storage.Validate(); err != nil { + return err + } + 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/config_test.go b/cmd/nitro/config_test.go index ef41d704f1..9e7cb87524 100644 --- a/cmd/nitro/config_test.go +++ b/cmd/nitro/config_test.go @@ -42,7 +42,7 @@ func TestEmptyCliConfig(t *testing.T) { } func TestSeqConfig(t *testing.T) { - args := strings.Split("--persistent.chain /tmp/data --init.dev-init --node.parent-chain-reader.enable=false --parent-chain.id 5 --chain.id 421613 --node.batch-poster.parent-chain-wallet.pathname /l1keystore --node.batch-poster.parent-chain-wallet.password passphrase --http.addr 0.0.0.0 --ws.addr 0.0.0.0 --node.sequencer --execution.sequencer.enable --node.feed.output.enable --node.feed.output.port 9642", " ") + args := strings.Split("--persistent.chain /tmp/data --init.dev-init --node.parent-chain-reader.enable=false --parent-chain.id 5 --chain.id 421613 --node.batch-poster.parent-chain-wallet.pathname /l1keystore --node.batch-poster.parent-chain-wallet.password passphrase --http.addr 0.0.0.0 --ws.addr 0.0.0.0 --node.sequencer --execution.sequencer.enable --node.feed.output.enable --node.feed.output.port 9642 --node.transaction-streamer.track-block-metadata-from=10", " ") _, _, err := ParseNode(context.Background(), args) Require(t, err) } @@ -79,7 +79,7 @@ func TestInvalidArchiveConfig(t *testing.T) { } func TestAggregatorConfig(t *testing.T) { - args := strings.Split("--persistent.chain /tmp/data --init.dev-init --node.parent-chain-reader.enable=false --parent-chain.id 5 --chain.id 421613 --node.batch-poster.parent-chain-wallet.pathname /l1keystore --node.batch-poster.parent-chain-wallet.password passphrase --http.addr 0.0.0.0 --ws.addr 0.0.0.0 --node.sequencer --execution.sequencer.enable --node.feed.output.enable --node.feed.output.port 9642 --node.data-availability.enable --node.data-availability.rpc-aggregator.backends [{\"url\":\"http://localhost:8547\",\"pubkey\":\"abc==\"}]", " ") + args := strings.Split("--persistent.chain /tmp/data --init.dev-init --node.parent-chain-reader.enable=false --parent-chain.id 5 --chain.id 421613 --node.batch-poster.parent-chain-wallet.pathname /l1keystore --node.batch-poster.parent-chain-wallet.password passphrase --http.addr 0.0.0.0 --ws.addr 0.0.0.0 --node.sequencer --execution.sequencer.enable --node.feed.output.enable --node.feed.output.port 9642 --node.data-availability.enable --node.data-availability.rpc-aggregator.backends [{\"url\":\"http://localhost:8547\",\"pubkey\":\"abc==\"}] --node.transaction-streamer.track-block-metadata-from=10", " ") _, _, err := ParseNode(context.Background(), args) Require(t, err) } @@ -142,7 +142,7 @@ func TestLiveNodeConfig(t *testing.T) { jsonConfig := "{\"chain\":{\"id\":421613}}" Require(t, WriteToConfigFile(configFile, jsonConfig)) - args := strings.Split("--file-logging.enable=false --persistent.chain /tmp/data --init.dev-init --node.parent-chain-reader.enable=false --parent-chain.id 5 --node.batch-poster.parent-chain-wallet.pathname /l1keystore --node.batch-poster.parent-chain-wallet.password passphrase --http.addr 0.0.0.0 --ws.addr 0.0.0.0 --node.sequencer --execution.sequencer.enable --node.feed.output.enable --node.feed.output.port 9642", " ") + args := strings.Split("--file-logging.enable=false --persistent.chain /tmp/data --init.dev-init --node.parent-chain-reader.enable=false --parent-chain.id 5 --node.batch-poster.parent-chain-wallet.pathname /l1keystore --node.batch-poster.parent-chain-wallet.password passphrase --http.addr 0.0.0.0 --ws.addr 0.0.0.0 --node.sequencer --execution.sequencer.enable --node.feed.output.enable --node.feed.output.port 9642 --node.transaction-streamer.track-block-metadata-from=10", " ") args = append(args, []string{"--conf.file", configFile}...) config, _, err := ParseNode(context.Background(), args) Require(t, err) @@ -223,7 +223,7 @@ func TestPeriodicReloadOfLiveNodeConfig(t *testing.T) { jsonConfig := "{\"conf\":{\"reload-interval\":\"20ms\"}}" Require(t, WriteToConfigFile(configFile, jsonConfig)) - args := strings.Split("--persistent.chain /tmp/data --init.dev-init --node.parent-chain-reader.enable=false --parent-chain.id 5 --chain.id 421613 --node.batch-poster.parent-chain-wallet.pathname /l1keystore --node.batch-poster.parent-chain-wallet.password passphrase --http.addr 0.0.0.0 --ws.addr 0.0.0.0 --node.sequencer --execution.sequencer.enable --node.feed.output.enable --node.feed.output.port 9642", " ") + args := strings.Split("--persistent.chain /tmp/data --init.dev-init --node.parent-chain-reader.enable=false --parent-chain.id 5 --chain.id 421613 --node.batch-poster.parent-chain-wallet.pathname /l1keystore --node.batch-poster.parent-chain-wallet.password passphrase --http.addr 0.0.0.0 --ws.addr 0.0.0.0 --node.sequencer --execution.sequencer.enable --node.feed.output.enable --node.feed.output.port 9642 --node.transaction-streamer.track-block-metadata-from=10", " ") args = append(args, []string{"--conf.file", configFile}...) config, _, err := ParseNode(context.Background(), args) Require(t, err) diff --git a/cmd/nitro/nitro.go b/cmd/nitro/nitro.go index e4e1b79353..8f77c1b588 100644 --- a/cmd/nitro/nitro.go +++ b/cmd/nitro/nitro.go @@ -689,6 +689,21 @@ func mainImpl() int { } } + execNodeConfig := execNode.ConfigFetcher() + if execNodeConfig.Sequencer.Enable && execNodeConfig.Sequencer.Timeboost.Enable { + err := execNode.Sequencer.InitializeExpressLaneService( + execNode.Backend.APIBackend(), + execNode.FilterSystem, + common.HexToAddress(execNodeConfig.Sequencer.Timeboost.AuctionContractAddress), + common.HexToAddress(execNodeConfig.Sequencer.Timeboost.AuctioneerAddress), + execNodeConfig.Sequencer.Timeboost.EarlySubmissionGrace, + ) + if err != nil { + log.Error("failed to create express lane service", "err", err) + } + execNode.Sequencer.StartExpressLaneService(ctx) + } + err = nil select { case err = <-fatalErrChan: diff --git a/cmd/util/confighelpers/configuration.go b/cmd/util/confighelpers/configuration.go index 8c4ef2a70b..6a139e4851 100644 --- a/cmd/util/confighelpers/configuration.go +++ b/cmd/util/confighelpers/configuration.go @@ -210,6 +210,7 @@ func devFlagArgs() []string { "--http.port", "8547", "--http.addr", "127.0.0.1", "--http.api=net,web3,eth,arb,arbdebug,debug", + "--node.transaction-streamer.track-block-metadata-from=1", } return args } diff --git a/das/s3_storage_service.go b/das/s3_storage_service.go index 4c0dcaf5a3..7e4e227a54 100644 --- a/das/s3_storage_service.go +++ b/das/s3_storage_service.go @@ -7,13 +7,10 @@ import ( "bytes" "context" "fmt" - "io" "math" "time" "github.com/aws/aws-sdk-go-v2/aws" - awsConfig "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/feature/s3/manager" "github.com/aws/aws-sdk-go-v2/service/s3" flag "github.com/spf13/pflag" @@ -24,16 +21,9 @@ import ( "github.com/offchainlabs/nitro/arbstate/daprovider" "github.com/offchainlabs/nitro/das/dastree" "github.com/offchainlabs/nitro/util/pretty" + "github.com/offchainlabs/nitro/util/s3client" ) -type S3Uploader interface { - Upload(ctx context.Context, input *s3.PutObjectInput, opts ...func(*manager.Uploader)) (*manager.UploadOutput, error) -} - -type S3Downloader interface { - Download(ctx context.Context, w io.WriterAt, input *s3.GetObjectInput, options ...func(*manager.Downloader)) (n int64, err error) -} - type S3StorageServiceConfig struct { Enable bool `koanf:"enable"` AccessKey string `koanf:"access-key"` @@ -57,16 +47,14 @@ func S3ConfigAddOptions(prefix string, f *flag.FlagSet) { } type S3StorageService struct { - client *s3.Client + client s3client.FullClient bucket string objectPrefix string - uploader S3Uploader - downloader S3Downloader discardAfterTimeout bool } func NewS3StorageService(config S3StorageServiceConfig) (StorageService, error) { - client, err := buildS3Client(config.AccessKey, config.SecretKey, config.Region) + client, err := s3client.NewS3FullClient(config.AccessKey, config.SecretKey, config.Region) if err != nil { return nil, err } @@ -74,31 +62,15 @@ func NewS3StorageService(config S3StorageServiceConfig) (StorageService, error) client: client, bucket: config.Bucket, objectPrefix: config.ObjectPrefix, - uploader: manager.NewUploader(client), - downloader: manager.NewDownloader(client), discardAfterTimeout: config.DiscardAfterTimeout, }, nil } -func buildS3Client(accessKey, secretKey, region string) (*s3.Client, error) { - cfg, err := awsConfig.LoadDefaultConfig(context.TODO(), awsConfig.WithRegion(region), func(options *awsConfig.LoadOptions) error { - // remain backward compatible with accessKey and secretKey credentials provided via cli flags - if accessKey != "" && secretKey != "" { - options.Credentials = credentials.NewStaticCredentialsProvider(accessKey, secretKey, "") - } - return nil - }) - if err != nil { - return nil, err - } - return s3.NewFromConfig(cfg), nil -} - func (s3s *S3StorageService) GetByHash(ctx context.Context, key common.Hash) ([]byte, error) { log.Trace("das.S3StorageService.GetByHash", "key", pretty.PrettyHash(key), "this", s3s) buf := manager.NewWriteAtBuffer([]byte{}) - _, err := s3s.downloader.Download(ctx, buf, &s3.GetObjectInput{ + _, err := s3s.client.Download(ctx, buf, &s3.GetObjectInput{ Bucket: aws.String(s3s.bucket), Key: aws.String(s3s.objectPrefix + EncodeStorageServiceKey(key)), }) @@ -116,7 +88,7 @@ func (s3s *S3StorageService) Put(ctx context.Context, value []byte, timeout uint expires := time.Unix(int64(timeout), 0) putObjectInput.Expires = &expires } - _, err := s3s.uploader.Upload(ctx, &putObjectInput) + _, err := s3s.client.Upload(ctx, &putObjectInput) if err != nil { log.Error("das.S3StorageService.Store", "err", err) } @@ -143,6 +115,6 @@ func (s3s *S3StorageService) String() string { } func (s3s *S3StorageService) HealthCheck(ctx context.Context) error { - _, err := s3s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s3s.bucket)}) + _, err := s3s.client.Client().HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s3s.bucket)}) return err } diff --git a/das/s3_storage_service_test.go b/das/s3_storage_service_test.go index d7a715b435..f4bd6f3107 100644 --- a/das/s3_storage_service_test.go +++ b/das/s3_storage_service_test.go @@ -18,11 +18,15 @@ import ( "github.com/offchainlabs/nitro/das/dastree" ) -type mockS3Uploader struct { +type mockS3FullClient struct { mockStorageService StorageService } -func (m *mockS3Uploader) Upload(ctx context.Context, input *s3.PutObjectInput, opts ...func(*manager.Uploader)) (*manager.UploadOutput, error) { +func (m *mockS3FullClient) Client() *s3.Client { + return nil +} + +func (m *mockS3FullClient) Upload(ctx context.Context, input *s3.PutObjectInput, opts ...func(*manager.Uploader)) (*manager.UploadOutput, error) { buf := new(bytes.Buffer) _, err := buf.ReadFrom(input.Body) if err != nil { @@ -33,11 +37,7 @@ func (m *mockS3Uploader) Upload(ctx context.Context, input *s3.PutObjectInput, o return nil, err } -type mockS3Downloader struct { - mockStorageService StorageService -} - -func (m *mockS3Downloader) Download(ctx context.Context, w io.WriterAt, input *s3.GetObjectInput, options ...func(*manager.Downloader)) (n int64, err error) { +func (m *mockS3FullClient) Download(ctx context.Context, w io.WriterAt, input *s3.GetObjectInput, options ...func(*manager.Downloader)) (n int64, err error) { key, err := DecodeStorageServiceKey(*input.Key) if err != nil { return 0, err @@ -56,10 +56,11 @@ func (m *mockS3Downloader) Download(ctx context.Context, w io.WriterAt, input *s func NewTestS3StorageService(ctx context.Context, s3Config genericconf.S3Config) (StorageService, error) { mockStorageService := NewMemoryBackedStorageService(ctx) + s3FullClient := &mockS3FullClient{mockStorageService} return &S3StorageService{ - bucket: s3Config.Bucket, - uploader: &mockS3Uploader{mockStorageService}, - downloader: &mockS3Downloader{mockStorageService}}, nil + bucket: s3Config.Bucket, + client: s3FullClient, + }, nil } func TestS3StorageService(t *testing.T) { diff --git a/execution/gethexec/api.go b/execution/gethexec/api.go index 699aa081b5..e8ac4d9a0e 100644 --- a/execution/gethexec/api.go +++ b/execution/gethexec/api.go @@ -15,6 +15,7 @@ import ( "github.com/ethereum/go-ethereum/arbitrum" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/params" @@ -22,21 +23,66 @@ import ( "github.com/offchainlabs/nitro/arbos/arbosState" "github.com/offchainlabs/nitro/arbos/retryables" + "github.com/offchainlabs/nitro/timeboost" "github.com/offchainlabs/nitro/util/arbmath" ) type ArbAPI struct { - txPublisher TransactionPublisher + txPublisher TransactionPublisher + bulkBlockMetadataFetcher *BulkBlockMetadataFetcher } -func NewArbAPI(publisher TransactionPublisher) *ArbAPI { - return &ArbAPI{publisher} +func NewArbAPI(publisher TransactionPublisher, bulkBlockMetadataFetcher *BulkBlockMetadataFetcher) *ArbAPI { + return &ArbAPI{ + txPublisher: publisher, + bulkBlockMetadataFetcher: bulkBlockMetadataFetcher, + } +} + +type NumberAndBlockMetadata struct { + BlockNumber uint64 `json:"blockNumber"` + RawMetadata hexutil.Bytes `json:"rawMetadata"` } func (a *ArbAPI) CheckPublisherHealth(ctx context.Context) error { return a.txPublisher.CheckHealth(ctx) } +func (a *ArbAPI) GetRawBlockMetadata(ctx context.Context, fromBlock, toBlock rpc.BlockNumber) ([]NumberAndBlockMetadata, error) { + if a.bulkBlockMetadataFetcher == nil { + return nil, errors.New("arb_getRawBlockMetadata is not available") + } + return a.bulkBlockMetadataFetcher.Fetch(fromBlock, toBlock) +} + +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/blockmetadata.go b/execution/gethexec/blockmetadata.go new file mode 100644 index 0000000000..26b1ae2526 --- /dev/null +++ b/execution/gethexec/blockmetadata.go @@ -0,0 +1,111 @@ +package gethexec + +import ( + "context" + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/common/lru" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/offchainlabs/nitro/arbutil" + "github.com/offchainlabs/nitro/util/stopwaiter" +) + +var ErrBlockMetadataApiBlocksLimitExceeded = errors.New("number of blocks requested for blockMetadata exceeded") + +type BlockMetadataFetcher interface { + BlockMetadataAtCount(count arbutil.MessageIndex) (common.BlockMetadata, error) + BlockNumberToMessageIndex(blockNum uint64) (arbutil.MessageIndex, error) + MessageIndexToBlockNumber(messageNum arbutil.MessageIndex) uint64 + SetReorgEventsNotifier(reorgEventsNotifier chan struct{}) +} + +type BulkBlockMetadataFetcher struct { + stopwaiter.StopWaiter + bc *core.BlockChain + fetcher BlockMetadataFetcher + reorgDetector chan struct{} + blocksLimit uint64 + cache *lru.SizeConstrainedCache[arbutil.MessageIndex, common.BlockMetadata] +} + +func NewBulkBlockMetadataFetcher(bc *core.BlockChain, fetcher BlockMetadataFetcher, cacheSize, blocksLimit uint64) *BulkBlockMetadataFetcher { + var cache *lru.SizeConstrainedCache[arbutil.MessageIndex, common.BlockMetadata] + var reorgDetector chan struct{} + if cacheSize != 0 { + cache = lru.NewSizeConstrainedCache[arbutil.MessageIndex, common.BlockMetadata](cacheSize) + reorgDetector = make(chan struct{}) + fetcher.SetReorgEventsNotifier(reorgDetector) + } + return &BulkBlockMetadataFetcher{ + bc: bc, + fetcher: fetcher, + cache: cache, + reorgDetector: reorgDetector, + blocksLimit: blocksLimit, + } +} + +func (b *BulkBlockMetadataFetcher) Fetch(fromBlock, toBlock rpc.BlockNumber) ([]NumberAndBlockMetadata, error) { + fromBlock, _ = b.bc.ClipToPostNitroGenesis(fromBlock) + toBlock, _ = b.bc.ClipToPostNitroGenesis(toBlock) + // #nosec G115 + start, err := b.fetcher.BlockNumberToMessageIndex(uint64(fromBlock)) + if err != nil { + return nil, fmt.Errorf("error converting fromBlock blocknumber to message index: %w", err) + } + // #nosec G115 + end, err := b.fetcher.BlockNumberToMessageIndex(uint64(toBlock)) + if err != nil { + return nil, fmt.Errorf("error converting toBlock blocknumber to message index: %w", err) + } + if start > end { + return nil, fmt.Errorf("invalid inputs, fromBlock: %d is greater than toBlock: %d", fromBlock, toBlock) + } + if b.blocksLimit > 0 && end-start+1 > arbutil.MessageIndex(b.blocksLimit) { + return nil, fmt.Errorf("%w. Range requested- %d, Limit- %d", ErrBlockMetadataApiBlocksLimitExceeded, end-start+1, b.blocksLimit) + } + var result []NumberAndBlockMetadata + for i := start; i <= end; i++ { + var data common.BlockMetadata + var found bool + if b.cache != nil { + data, found = b.cache.Get(i) + } + if !found { + data, err = b.fetcher.BlockMetadataAtCount(i + 1) + if err != nil { + return nil, err + } + if data != nil && b.cache != nil { + b.cache.Add(i, data) + } + } + if data != nil { + result = append(result, NumberAndBlockMetadata{ + BlockNumber: b.fetcher.MessageIndexToBlockNumber(i), + RawMetadata: (hexutil.Bytes)(data), + }) + } + } + return result, nil +} + +func (b *BulkBlockMetadataFetcher) ClearCache(ctx context.Context, ignored struct{}) { + b.cache.Clear() +} + +func (b *BulkBlockMetadataFetcher) Start(ctx context.Context) { + b.StopWaiter.Start(ctx, b) + if b.reorgDetector != nil { + _ = stopwaiter.CallWhenTriggeredWith[struct{}](&b.StopWaiterSafe, b.ClearCache, b.reorgDetector) + } +} + +func (b *BulkBlockMetadataFetcher) StopAndWait() { + b.StopWaiter.StopAndWait() +} diff --git a/execution/gethexec/contract_adapter.go b/execution/gethexec/contract_adapter.go new file mode 100644 index 0000000000..370fc61e9d --- /dev/null +++ b/execution/gethexec/contract_adapter.go @@ -0,0 +1,99 @@ +// Copyright 2024-2025, Offchain Labs, Inc. +// For license information, see https://github.com/nitro/blob/master/LICENSE + +package gethexec + +import ( + "context" + "errors" + "fmt" + "math" + "math/big" + "os" + "runtime/debug" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/arbitrum" + "github.com/ethereum/go-ethereum/common" + "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/eth/filters" + "github.com/ethereum/go-ethereum/rpc" +) + +// contractAdapter is an impl of bind.ContractBackend with necessary methods defined to work with the ExpressLaneAuction contract +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) { + fmt.Fprintf(os.Stderr, "contractAdapter doesn't implement SubscribeFilterLogs: Stack trace:\n%s\n", debug.Stack()) + return nil, errors.New("contractAdapter doesn't implement SubscribeFilterLogs - shouldn't be needed") +} + +func (a *contractAdapter) CodeAt(ctx context.Context, contract common.Address, blockNumber *big.Int) ([]byte, error) { + number := rpc.LatestBlockNumber + if blockNumber != nil { + number = rpc.BlockNumber(blockNumber.Int64()) + } + + statedb, _, err := a.apiBackend.StateAndHeaderByNumber(ctx, number) + if err != nil { + return nil, fmt.Errorf("contractAdapter error: %w", err) + } + code := statedb.GetCode(contract) + return code, nil +} + +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 +} diff --git a/execution/gethexec/executionengine.go b/execution/gethexec/executionengine.go index e606027419..12f22e7321 100644 --- a/execution/gethexec/executionengine.go +++ b/execution/gethexec/executionengine.go @@ -29,6 +29,7 @@ import ( "github.com/google/uuid" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core/rawdb" "github.com/ethereum/go-ethereum/core/state" @@ -84,9 +85,10 @@ type ExecutionEngine struct { resequenceChan chan []*arbostypes.MessageWithMetadata createBlocksMutex sync.Mutex - newBlockNotifier chan struct{} - latestBlockMutex sync.Mutex - latestBlock *types.Block + newBlockNotifier chan struct{} + reorgEventsNotifier chan struct{} + latestBlockMutex sync.Mutex + latestBlock *types.Block nextScheduledVersionCheck time.Time // protected by the createBlocksMutex @@ -209,6 +211,16 @@ func (s *ExecutionEngine) SetRecorder(recorder *BlockRecorder) { s.recorder = recorder } +func (s *ExecutionEngine) SetReorgEventsNotifier(reorgEventsNotifier chan struct{}) { + if s.Started() { + panic("trying to set reorg events notifier after start") + } + if s.reorgEventsNotifier != nil { + panic("trying to set reorg events notifier when already set") + } + s.reorgEventsNotifier = reorgEventsNotifier +} + func (s *ExecutionEngine) EnableReorgSequencing() { if s.Started() { panic("trying to enable reorg sequencing after start") @@ -249,11 +261,18 @@ func (s *ExecutionEngine) SetConsensus(consensus execution.FullConsensusClient) s.consensus = consensus } +func (s *ExecutionEngine) BlockMetadataAtCount(count arbutil.MessageIndex) (common.BlockMetadata, error) { + if s.consensus != nil { + return s.consensus.BlockMetadataAtCount(count) + } + return nil, errors.New("FullConsensusClient is not accessible to execution") +} + func (s *ExecutionEngine) GetBatchFetcher() execution.BatchFetcher { return s.consensus } -func (s *ExecutionEngine) Reorg(count arbutil.MessageIndex, newMessages []arbostypes.MessageWithMetadataAndBlockHash, oldMessages []*arbostypes.MessageWithMetadata) ([]*execution.MessageResult, error) { +func (s *ExecutionEngine) Reorg(count arbutil.MessageIndex, newMessages []arbostypes.MessageWithMetadataAndBlockInfo, oldMessages []*arbostypes.MessageWithMetadata) ([]*execution.MessageResult, error) { if count == 0 { return nil, errors.New("cannot reorg out genesis") } @@ -283,6 +302,13 @@ func (s *ExecutionEngine) Reorg(count arbutil.MessageIndex, newMessages []arbost return nil, err } + if s.reorgEventsNotifier != nil { + select { + case s.reorgEventsNotifier <- struct{}{}: + default: + } + } + newMessagesResults := make([]*execution.MessageResult, 0, len(oldMessages)) for i := range newMessages { var msgForPrefetch *arbostypes.MessageWithMetadata @@ -416,7 +442,7 @@ func (s *ExecutionEngine) resequenceReorgedMessages(messages []*arbostypes.Messa } hooks := arbos.NoopSequencingHooks() hooks.DiscardInvalidTxsEarly = true - _, err = s.sequenceTransactionsWithBlockMutex(msg.Message.Header, txes, hooks) + _, err = s.sequenceTransactionsWithBlockMutex(msg.Message.Header, txes, hooks, nil) if err != nil { log.Error("failed to re-sequence old user message removed by reorg", "err", err) return @@ -453,17 +479,17 @@ func (s *ExecutionEngine) sequencerWrapper(sequencerFunc func() (*types.Block, e } } -func (s *ExecutionEngine) SequenceTransactions(header *arbostypes.L1IncomingMessageHeader, txes types.Transactions, hooks *arbos.SequencingHooks) (*types.Block, error) { +func (s *ExecutionEngine) SequenceTransactions(header *arbostypes.L1IncomingMessageHeader, txes types.Transactions, hooks *arbos.SequencingHooks, timeboostedTxs map[common.Hash]struct{}) (*types.Block, error) { return s.sequencerWrapper(func() (*types.Block, error) { hooks.TxErrors = nil - return s.sequenceTransactionsWithBlockMutex(header, txes, hooks) + return s.sequenceTransactionsWithBlockMutex(header, txes, hooks, timeboostedTxs) }) } // SequenceTransactionsWithProfiling runs SequenceTransactions with tracing and // CPU profiling enabled. If the block creation takes longer than 2 seconds, it // keeps both and prints out filenames in an error log line. -func (s *ExecutionEngine) SequenceTransactionsWithProfiling(header *arbostypes.L1IncomingMessageHeader, txes types.Transactions, hooks *arbos.SequencingHooks) (*types.Block, error) { +func (s *ExecutionEngine) SequenceTransactionsWithProfiling(header *arbostypes.L1IncomingMessageHeader, txes types.Transactions, hooks *arbos.SequencingHooks, timeboostedTxs map[common.Hash]struct{}) (*types.Block, error) { pprofBuf, traceBuf := bytes.NewBuffer(nil), bytes.NewBuffer(nil) if err := pprof.StartCPUProfile(pprofBuf); err != nil { log.Error("Starting CPU profiling", "error", err) @@ -472,7 +498,7 @@ func (s *ExecutionEngine) SequenceTransactionsWithProfiling(header *arbostypes.L log.Error("Starting tracing", "error", err) } start := time.Now() - res, err := s.SequenceTransactions(header, txes, hooks) + res, err := s.SequenceTransactions(header, txes, hooks, timeboostedTxs) elapsed := time.Since(start) pprof.StopCPUProfile() trace.Stop() @@ -498,7 +524,7 @@ func writeAndLog(pprof, trace *bytes.Buffer) { log.Info("Transactions sequencing took longer than 2 seconds, created pprof and trace files", "pprof", pprofFile, "traceFile", traceFile) } -func (s *ExecutionEngine) sequenceTransactionsWithBlockMutex(header *arbostypes.L1IncomingMessageHeader, txes types.Transactions, hooks *arbos.SequencingHooks) (*types.Block, error) { +func (s *ExecutionEngine) sequenceTransactionsWithBlockMutex(header *arbostypes.L1IncomingMessageHeader, txes types.Transactions, hooks *arbos.SequencingHooks, timeboostedTxs map[common.Hash]struct{}) (*types.Block, error) { lastBlockHeader, err := s.getCurrentHeader() if err != nil { return nil, err @@ -569,7 +595,8 @@ func (s *ExecutionEngine) sequenceTransactionsWithBlockMutex(header *arbostypes. return nil, err } - err = s.consensus.WriteMessageFromSequencer(pos, msgWithMeta, *msgResult) + blockMetadata := s.blockMetadataFromBlock(block, timeboostedTxs) + err = s.consensus.WriteMessageFromSequencer(pos, msgWithMeta, *msgResult, blockMetadata) if err != nil { return nil, err } @@ -585,6 +612,24 @@ func (s *ExecutionEngine) sequenceTransactionsWithBlockMutex(header *arbostypes. return block, nil } +// blockMetadataFromBlock returns timeboosted byte array which says whether a transaction in the block was timeboosted +// or not. The first byte of blockMetadata byte array is reserved to indicate the version, +// starting from the second byte, (N)th bit would represent if (N)th tx is timeboosted or not, 1 means yes and 0 means no +// blockMetadata[index / 8 + 1] & (1 << (index % 8)) != 0; where index = (N - 1), implies whether (N)th tx in a block is timeboosted +// note that number of txs in a block will always lag behind (len(blockMetadata) - 1) * 8 but it wont lag more than a value of 7 +func (s *ExecutionEngine) blockMetadataFromBlock(block *types.Block, timeboostedTxs map[common.Hash]struct{}) common.BlockMetadata { + bits := make(common.BlockMetadata, 1+arbmath.DivCeil(uint64(len(block.Transactions())), 8)) + if len(timeboostedTxs) == 0 { + return bits + } + for i, tx := range block.Transactions() { + if _, ok := timeboostedTxs[tx.Hash()]; ok { + bits[1+i/8] |= 1 << (i % 8) + } + } + return bits +} + func (s *ExecutionEngine) SequenceDelayedMessage(message *arbostypes.L1IncomingMessage, delayedSeqNum uint64) error { _, err := s.sequencerWrapper(func() (*types.Block, error) { return s.sequenceDelayedMessageWithBlockMutex(message, delayedSeqNum) @@ -627,7 +672,7 @@ func (s *ExecutionEngine) sequenceDelayedMessageWithBlockMutex(message *arbostyp return nil, err } - err = s.consensus.WriteMessageFromSequencer(pos, messageWithMeta, *msgResult) + err = s.consensus.WriteMessageFromSequencer(pos, messageWithMeta, *msgResult, s.blockMetadataFromBlock(block, nil)) if err != nil { return nil, err } diff --git a/execution/gethexec/express_lane_service.go b/execution/gethexec/express_lane_service.go new file mode 100644 index 0000000000..844038f04b --- /dev/null +++ b/execution/gethexec/express_lane_service.go @@ -0,0 +1,436 @@ +// Copyright 2024-2025, Offchain Labs, Inc. +// For license information, see https://github.com/nitro/blob/master/LICENSE + +package gethexec + +import ( + "bytes" + "context" + "fmt" + "sync" + "time" + + "github.com/pkg/errors" + + "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/core" + "github.com/ethereum/go-ethereum/core/types" + "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" + "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/containers" + "github.com/offchainlabs/nitro/util/stopwaiter" +) + +var ( + auctionResolutionLatency = metrics.NewRegisteredHistogram("arb/sequencer/timeboost/auctionresolution", nil, metrics.NewBoundedHistogramSample()) +) + +type transactionPublisher interface { + PublishTimeboostedTransaction(context.Context, *types.Transaction, *arbitrum_types.ConditionalOptions, chan error) +} + +type msgAndResult struct { + msg *timeboost.ExpressLaneSubmission + resultChan chan error +} + +type expressLaneRoundInfo struct { + sequence uint64 + msgAndResultBySequenceNumber map[uint64]*msgAndResult +} + +type expressLaneService struct { + stopwaiter.StopWaiter + transactionPublisher transactionPublisher + seqConfig SequencerConfigFetcher + auctionContractAddr common.Address + apiBackend *arbitrum.APIBackend + roundTimingInfo timeboost.RoundTimingInfo + earlySubmissionGrace time.Duration + chainConfig *params.ChainConfig + auctionContract *express_lane_auctiongen.ExpressLaneAuction + roundControl containers.SyncMap[uint64, common.Address] // thread safe + + roundInfoMutex sync.Mutex + roundInfo *containers.LruCache[uint64, *expressLaneRoundInfo] +} + +func newExpressLaneService( + transactionPublisher transactionPublisher, + seqConfig SequencerConfigFetcher, + apiBackend *arbitrum.APIBackend, + filterSystem *filters.FilterSystem, + auctionContractAddr common.Address, + bc *core.BlockChain, + earlySubmissionGrace time.Duration, +) (*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: + rawRoundTimingInfo, 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 + } + roundTimingInfo, err := timeboost.NewRoundTimingInfo(rawRoundTimingInfo) + if err != nil { + return nil, err + } + + return &expressLaneService{ + transactionPublisher: transactionPublisher, + seqConfig: seqConfig, + auctionContract: auctionContract, + apiBackend: apiBackend, + chainConfig: chainConfig, + roundTimingInfo: *roundTimingInfo, + earlySubmissionGrace: earlySubmissionGrace, + auctionContractAddr: auctionContractAddr, + roundInfo: containers.NewLruCache[uint64, *expressLaneRoundInfo](8), + }, nil +} + +func (es *expressLaneService) Start(ctxIn context.Context) { + es.StopWaiter.Start(ctxIn, es) + + es.LaunchThread(func(ctx context.Context) { + // Log every new express lane auction round. + log.Info("Watching for new express lane rounds") + + // Wait until the next round starts + waitTime := es.roundTimingInfo.TimeTilNextRound() + select { + case <-ctx.Done(): + return + case <-time.After(waitTime): + } + + // First tick happened, now set up regular ticks + ticker := time.NewTicker(es.roundTimingInfo.Round) + defer ticker.Stop() + for { + var t time.Time + select { + case <-ctx.Done(): + return + case t = <-ticker.C: + } + + round := es.roundTimingInfo.RoundNumber() + // TODO (BUG?) is there a race here where messages for a new round can come + // in before this tick has been processed? + log.Info( + "New express lane auction round", + "round", round, + "timestamp", t, + ) + + // Cleanup previous round controller data + es.roundControl.Delete(round - 1) + } + }) + + es.LaunchThread(func(ctx context.Context) { + // Monitor for auction resolutions from the auction manager smart contract + // and set the express lane controller for the upcoming round accordingly. + log.Info("Monitoring express lane auction contract") + + var fromBlock uint64 + maxBlockSpeed := es.seqConfig().MaxBlockSpeed + latestBlock, err := es.apiBackend.HeaderByNumber(ctx, rpc.LatestBlockNumber) + if err != nil { + log.Error("ExpressLaneService could not get the latest header", "err", err) + } else { + maxBlocksPerRound := es.roundTimingInfo.Round / maxBlockSpeed + fromBlock = latestBlock.Number.Uint64() + // #nosec G115 + if fromBlock > uint64(maxBlocksPerRound) { + // #nosec G115 + fromBlock -= uint64(maxBlocksPerRound) + } + } + + ticker := time.NewTicker(maxBlockSpeed) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + newMaxBlockSpeed := es.seqConfig().MaxBlockSpeed + if newMaxBlockSpeed != maxBlockSpeed { + maxBlockSpeed = newMaxBlockSpeed + ticker.Reset(maxBlockSpeed) + } + } + + latestBlock, err := es.apiBackend.HeaderByNumber(ctx, rpc.LatestBlockNumber) + if err != nil { + log.Error("ExpressLaneService could not get the latest header", "err", err) + continue + } + 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() { + timeSinceAuctionClose := es.roundTimingInfo.AuctionClosing - es.roundTimingInfo.TimeTilNextRound() + auctionResolutionLatency.Update(timeSinceAuctionClose.Nanoseconds()) + log.Info( + "AuctionResolved: New express lane controller assigned", + "round", it.Event.Round, + "controller", it.Event.FirstPriceExpressLaneController, + "timeSinceAuctionClose", timeSinceAuctionClose, + ) + es.roundControl.Store(it.Event.Round, it.Event.FirstPriceExpressLaneController) + } + + // 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() { + // if (setExpressLaneIterator.Event.PreviousExpressLaneController == common.Address{}) { + // // The ExpressLaneAuction contract emits both AuctionResolved and SetExpressLaneController + // // events when an auction is resolved. They contain redundant information so + // // the SetExpressLaneController event can be skipped if it's related to a new round, as + // // indicated by an empty PreviousExpressLaneController field (a new round has no + // // previous controller). + // // It is more explicit and thus clearer to use the AuctionResovled event only for the + // // new round setup logic and SetExpressLaneController event only for transfers, rather + // // than trying to overload everything onto SetExpressLaneController. + // continue + // } + // currentRound := es.roundTimingInfo.RoundNumber() + // round := setExpressLaneIterator.Event.Round + // if round < currentRound { + // log.Info("SetExpressLaneController event's round is lower than current round, not transferring control", "eventRound", round, "currentRound", currentRound) + // continue + // } + // roundController, ok := es.roundControl.Load(round) + // if !ok { + // log.Warn("Could not find round info for ExpressLaneConroller transfer event", "round", round) + // continue + // } + // if roundController != setExpressLaneIterator.Event.PreviousExpressLaneController { + // log.Warn("Previous ExpressLaneController in SetExpressLaneController event does not match Sequencer previous controller, continuing with transfer to new controller anyway", + // "round", round, + // "sequencerRoundController", roundController, + // "previous", setExpressLaneIterator.Event.PreviousExpressLaneController, + // "new", setExpressLaneIterator.Event.NewExpressLaneController) + // } + // if roundController == setExpressLaneIterator.Event.NewExpressLaneController { + // log.Warn("SetExpressLaneController: Previous and New ExpressLaneControllers are the same, not transferring control.", + // "round", round, + // "previous", roundController, + // "new", setExpressLaneIterator.Event.NewExpressLaneController) + // continue + // } + // es.roundControl.Store(round, setExpressLaneIterator.Event.NewExpressLaneController) + // if round == currentRound { + // es.roundInfoMutex.Lock() + // if es.roundInfo.Contains(round) { + // es.roundInfo.Add(round, &expressLaneRoundInfo{ + // 0, + // make(map[uint64]*msgAndResult), + // }) + // } + // es.roundInfoMutex.Unlock() + // } + // } + fromBlock = toBlock + 1 + } + }) +} + +func (es *expressLaneService) currentRoundHasController() bool { + controller, ok := es.roundControl.Load(es.roundTimingInfo.RoundNumber()) + if !ok { + return false + } + return controller != (common.Address{}) +} + +// sequenceExpressLaneSubmission with the roundInfo lock held, validates sequence number and sender address fields of the message +// adds the message to the transaction queue and waits for the response +func (es *expressLaneService) sequenceExpressLaneSubmission( + ctx context.Context, + msg *timeboost.ExpressLaneSubmission, +) error { + unlockByDefer := true + es.roundInfoMutex.Lock() + defer func() { + if unlockByDefer { + es.roundInfoMutex.Unlock() + } + }() + + // Below code block isn't a repetition, it prevents stale messages to be accepted during control transfer within or after the round ends! + controller, ok := es.roundControl.Load(msg.Round) + if !ok { + return timeboost.ErrNoOnchainController + } + sender, err := msg.Sender() // Doesn't recompute sender address + if err != nil { + return err + } + if sender != controller { + return timeboost.ErrNotExpressLaneController + } + + // If expressLaneRoundInfo for current round doesn't exist yet, we'll add it to the cache + if !es.roundInfo.Contains(msg.Round) { + es.roundInfo.Add(msg.Round, &expressLaneRoundInfo{ + 0, + make(map[uint64]*msgAndResult), + }) + } + roundInfo, _ := es.roundInfo.Get(msg.Round) + + // Check if the submission nonce is too low. + if msg.SequenceNumber < roundInfo.sequence { + return timeboost.ErrSequenceNumberTooLow + } + + // Check if a duplicate submission exists already, and reject if so. + if prev, exists := roundInfo.msgAndResultBySequenceNumber[msg.SequenceNumber]; exists { + if bytes.Equal(prev.msg.Signature, msg.Signature) { + return nil + } + return timeboost.ErrDuplicateSequenceNumber + } + + seqConfig := es.seqConfig() + + // Log an informational warning if the message's sequence number is in the future. + if msg.SequenceNumber > roundInfo.sequence { + if seqConfig.Timeboost.MaxQueuedTxCount != 0 && + len(roundInfo.msgAndResultBySequenceNumber) >= seqConfig.Timeboost.MaxQueuedTxCount { + return fmt.Errorf("reached limit for queuing of future sequence number transactions, please try again with the correct sequence number. Limit: %d, Current sequence number: %d", seqConfig.Timeboost.MaxQueuedTxCount, roundInfo.sequence) + } + log.Info("Received express lane submission with future sequence number", "SequenceNumber", msg.SequenceNumber) + } + + // Put into the sequence number map. + resultChan := make(chan error, 1) + roundInfo.msgAndResultBySequenceNumber[msg.SequenceNumber] = &msgAndResult{msg, resultChan} + + now := time.Now() + queueTimeout := seqConfig.QueueTimeout + for es.roundTimingInfo.RoundNumber() == msg.Round { // This check ensures that the controller for this round is not allowed to send transactions from msgAndResultBySequenceNumber map once the next round starts + // Get the next message in the sequence. + nextMsgAndResult, exists := roundInfo.msgAndResultBySequenceNumber[roundInfo.sequence] + if !exists { + break + } + delete(roundInfo.msgAndResultBySequenceNumber, nextMsgAndResult.msg.SequenceNumber) + // Queued txs cannot use this message's context as it would lead to context canceled error once the result for this message is available and returned + // Hence using context.Background() allows unblocking of queued up txs even if current tx's context has errored out + var queueCtx context.Context + var cancel context.CancelFunc + queueCtx, _ = ctxWithTimeout(context.Background(), queueTimeout) + if nextMsgAndResult.msg.SequenceNumber == msg.SequenceNumber { + queueCtx, cancel = ctxWithTimeout(ctx, queueTimeout) + defer cancel() + } + es.transactionPublisher.PublishTimeboostedTransaction(queueCtx, nextMsgAndResult.msg.Transaction, nextMsgAndResult.msg.Options, nextMsgAndResult.resultChan) + // Increase the global round sequence number. + roundInfo.sequence += 1 + } + + es.roundInfo.Add(msg.Round, roundInfo) + unlockByDefer = false + es.roundInfoMutex.Unlock() // Release lock so that other timeboost txs can be processed + + abortCtx, cancel := ctxWithTimeout(ctx, queueTimeout*2) // We use the same timeout value that sequencer imposes + defer cancel() + select { + case err = <-resultChan: + case <-abortCtx.Done(): + if ctx.Err() == nil { + log.Warn("Transaction sequencing hit abort deadline", "err", abortCtx.Err(), "submittedAt", now, "TxProcessingTimeout", queueTimeout*2, "txHash", msg.Transaction.Hash()) + } + err = fmt.Errorf("Transaction sequencing hit timeout, result for the submitted transaction is not yet available: %w", abortCtx.Err()) + } + if err != nil { + // If the tx fails we return an error with all the necessary info for the controller + return fmt.Errorf("%w: Sequence number: %d (consumed), Transaction hash: %v, Error: %w", timeboost.ErrAcceptedTxFailed, msg.SequenceNumber, msg.Transaction.Hash(), err) + } + return nil +} + +// validateExpressLaneTx checks for the correctness of all fields of msg +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 := es.roundTimingInfo.RoundNumber() + if msg.Round != currentRound { + timeTilNextRound := es.roundTimingInfo.TimeTilNextRound() + // We allow txs to come in for the next round if it is close enough to that round, + // but we sleep until the round starts. + if msg.Round == currentRound+1 && timeTilNextRound <= es.earlySubmissionGrace { + time.Sleep(timeTilNextRound) + } else { + return errors.Wrapf(timeboost.ErrBadRoundNumber, "express lane tx round %d does not match current round %d", msg.Round, currentRound) + } + } + + controller, ok := es.roundControl.Load(msg.Round) + if !ok { + return timeboost.ErrNoOnchainController + } + // Extract sender address and cache it to be later used by sequenceExpressLaneSubmission + sender, err := msg.Sender() + if err != nil { + return err + } + if sender != 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..fb662795ab --- /dev/null +++ b/execution/gethexec/express_lane_service_test.go @@ -0,0 +1,585 @@ +// 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" + "sync" + "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/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" + + "github.com/offchainlabs/nitro/timeboost" + "github.com/offchainlabs/nitro/util/containers" +) + +var testPriv, testPriv2 *ecdsa.PrivateKey + +func init() { + privKey, err := crypto.HexToECDSA("93be75cc4df7acbb636b6abe6de2c0446235ac1dc7da9f290a70d83f088b486d") + if err != nil { + panic(err) + } + testPriv = privKey + privKey2, err := crypto.HexToECDSA("93be75cc4df7acbb636b6abe6de2c0446235ac1dc7da9f290a70d83f088b486e") + if err != nil { + panic(err) + } + testPriv2 = privKey2 +} + +func defaultTestRoundTimingInfo(offset time.Time) timeboost.RoundTimingInfo { + return timeboost.RoundTimingInfo{ + Offset: offset, + Round: time.Minute, + AuctionClosing: time.Second * 15, + ReserveSubmission: time.Second * 15, + } +} + +func Test_expressLaneService_validateExpressLaneTx(t *testing.T) { + tests := []struct { + name string + es *expressLaneService + sub *timeboost.ExpressLaneSubmission + expectedErr error + controller common.Address + valid bool + }{ + { + name: "nil msg", + sub: nil, + es: &expressLaneService{}, + expectedErr: timeboost.ErrMalformedData, + }, + { + name: "nil tx", + sub: &timeboost.ExpressLaneSubmission{}, + es: &expressLaneService{}, + expectedErr: timeboost.ErrMalformedData, + }, + { + name: "nil sig", + sub: &timeboost.ExpressLaneSubmission{ + Transaction: &types.Transaction{}, + }, + es: &expressLaneService{}, + expectedErr: timeboost.ErrMalformedData, + }, + { + name: "wrong chain id", + es: &expressLaneService{ + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + }, + }, + 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), + }, + }, + sub: &timeboost.ExpressLaneSubmission{ + ChainId: big.NewInt(1), + AuctionContractAddress: common.Address{'b'}, + Transaction: &types.Transaction{}, + Signature: []byte{'b'}, + }, + expectedErr: timeboost.ErrWrongAuctionContract, + }, + { + name: "bad round number", + es: &expressLaneService{ + auctionContractAddr: common.Address{'a'}, + roundTimingInfo: defaultTestRoundTimingInfo(time.Now()), + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + }, + }, + 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'}, + roundTimingInfo: defaultTestRoundTimingInfo(time.Now()), + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + }, + }, + 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"), + roundTimingInfo: defaultTestRoundTimingInfo(time.Now()), + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + }, + roundInfo: containers.NewLruCache[uint64, *expressLaneRoundInfo](8), + }, + controller: common.Address{'b'}, + sub: buildInvalidSignatureSubmission(t, common.HexToAddress("0x2Aef36410182881a4b13664a1E079762D7F716e6")), + expectedErr: timeboost.ErrNotExpressLaneController, + }, + { + name: "no onchain controller", + es: &expressLaneService{ + auctionContractAddr: common.Address{'a'}, + roundTimingInfo: defaultTestRoundTimingInfo(time.Now()), + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + }, + }, + sub: &timeboost.ExpressLaneSubmission{ + ChainId: big.NewInt(1), + AuctionContractAddress: common.Address{'a'}, + Transaction: &types.Transaction{}, + Signature: []byte{'b'}, + }, + expectedErr: timeboost.ErrNoOnchainController, + }, + { + name: "not express lane controller", + es: &expressLaneService{ + auctionContractAddr: common.HexToAddress("0x2Aef36410182881a4b13664a1E079762D7F716e6"), + roundTimingInfo: defaultTestRoundTimingInfo(time.Now()), + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + }, + roundInfo: containers.NewLruCache[uint64, *expressLaneRoundInfo](8), + }, + controller: common.Address{'b'}, + sub: buildValidSubmission(t, common.HexToAddress("0x2Aef36410182881a4b13664a1E079762D7F716e6"), testPriv, 0), + expectedErr: timeboost.ErrNotExpressLaneController, + }, + { + name: "OK", + es: &expressLaneService{ + auctionContractAddr: common.HexToAddress("0x2Aef36410182881a4b13664a1E079762D7F716e6"), + roundTimingInfo: defaultTestRoundTimingInfo(time.Now()), + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + }, + }, + controller: crypto.PubkeyToAddress(testPriv.PublicKey), + sub: buildValidSubmission(t, common.HexToAddress("0x2Aef36410182881a4b13664a1E079762D7F716e6"), testPriv, 0), + valid: true, + }, + } + + for _, _tt := range tests { + tt := _tt + t.Run(tt.name, func(t *testing.T) { + if tt.es.roundInfo != nil { + tt.es.roundInfo.Add(0, &expressLaneRoundInfo{}) + } + if tt.sub != nil && !errors.Is(tt.expectedErr, timeboost.ErrNoOnchainController) { + tt.es.roundControl.Store(tt.sub.Round, tt.controller) + } + err := tt.es.validateExpressLaneTx(tt.sub) + if tt.valid { + require.NoError(t, err) + return + } + require.ErrorIs(t, err, tt.expectedErr) + }) + } +} + +func Test_expressLaneService_validateExpressLaneTx_gracePeriod(t *testing.T) { + auctionContractAddr := common.HexToAddress("0x2Aef36410182881a4b13664a1E079762D7F716e6") + es := &expressLaneService{ + auctionContractAddr: auctionContractAddr, + roundTimingInfo: timeboost.RoundTimingInfo{ + Offset: time.Now(), + Round: time.Second * 10, + AuctionClosing: time.Second * 5, + }, + earlySubmissionGrace: time.Second * 2, + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + }, + } + es.roundControl.Store(0, crypto.PubkeyToAddress(testPriv.PublicKey)) + es.roundControl.Store(1, crypto.PubkeyToAddress(testPriv2.PublicKey)) + + sub1 := buildValidSubmission(t, auctionContractAddr, testPriv, 0) + err := es.validateExpressLaneTx(sub1) + require.NoError(t, err) + + // Send req for next round + sub2 := buildValidSubmission(t, auctionContractAddr, testPriv2, 1) + err = es.validateExpressLaneTx(sub2) + require.ErrorIs(t, err, timeboost.ErrBadRoundNumber) + + // Sleep til 2 seconds before grace + time.Sleep(time.Second * 6) + err = es.validateExpressLaneTx(sub2) + require.ErrorIs(t, err, timeboost.ErrBadRoundNumber) + + // Send req for next round within grace period + time.Sleep(time.Second * 2) + err = es.validateExpressLaneTx(sub2) + require.NoError(t, err) +} + +type stubPublisher struct { + els *expressLaneService + publishedTxOrder []uint64 +} + +func makeStubPublisher(els *expressLaneService) *stubPublisher { + return &stubPublisher{ + els: els, + publishedTxOrder: make([]uint64, 0), + } +} + +var emptyTx = types.NewTransaction(0, common.MaxAddress, big.NewInt(0), 0, big.NewInt(0), nil) + +func (s *stubPublisher) PublishTimeboostedTransaction(parentCtx context.Context, tx *types.Transaction, options *arbitrum_types.ConditionalOptions, resultChan chan error) { + if tx.Hash() != emptyTx.Hash() { + resultChan <- errors.New("oops, bad tx") + return + } + s.publishedTxOrder = append(s.publishedTxOrder, 0) + resultChan <- nil +} + +func Test_expressLaneService_sequenceExpressLaneSubmission_nonceTooLow(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + els := &expressLaneService{ + roundInfo: containers.NewLruCache[uint64, *expressLaneRoundInfo](8), + } + els.roundInfo.Add(0, &expressLaneRoundInfo{1, make(map[uint64]*msgAndResult)}) + els.StopWaiter.Start(ctx, els) + els.roundControl.Store(0, crypto.PubkeyToAddress(testPriv.PublicKey)) + stubPublisher := makeStubPublisher(els) + els.transactionPublisher = stubPublisher + + msg := buildValidSubmissionWithSeqAndTx(t, 0, 0, emptyTx) + 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{ + roundInfo: containers.NewLruCache[uint64, *expressLaneRoundInfo](8), + roundTimingInfo: defaultTestRoundTimingInfo(time.Now()), + seqConfig: func() *SequencerConfig { return &SequencerConfig{} }, + } + els.roundInfo.Add(0, &expressLaneRoundInfo{1, make(map[uint64]*msgAndResult)}) + els.StopWaiter.Start(ctx, els) + els.roundControl.Store(0, crypto.PubkeyToAddress(testPriv.PublicKey)) + stubPublisher := makeStubPublisher(els) + els.transactionPublisher = stubPublisher + + msg1 := buildValidSubmissionWithSeqAndTx(t, 0, 2, types.NewTx(&types.DynamicFeeTx{Data: []byte{1}})) + msg2 := buildValidSubmissionWithSeqAndTx(t, 0, 2, types.NewTx(&types.DynamicFeeTx{Data: []byte{2}})) + var wg sync.WaitGroup + wg.Add(3) // We expect only of the below two to return with an error here + var err1, err2 error + go func(w *sync.WaitGroup) { + w.Done() + err1 = els.sequenceExpressLaneSubmission(ctx, msg1) + wg.Done() + }(&wg) + go func(w *sync.WaitGroup) { + w.Done() + err2 = els.sequenceExpressLaneSubmission(ctx, msg2) + wg.Done() + }(&wg) + wg.Wait() + if err1 != nil && err2 != nil || err1 == nil && err2 == nil { + t.Fatalf("cannot have err1 and err2 both nil or non-nil. err1: %v, err2: %v", err1, err2) + } + if err1 != nil { + require.ErrorIs(t, err1, timeboost.ErrDuplicateSequenceNumber) + } else { + require.ErrorIs(t, err2, timeboost.ErrDuplicateSequenceNumber) + } + wg.Add(1) // As the goroutine that's still running will call wg.Done() after the test ends +} + +func Test_expressLaneService_sequenceExpressLaneSubmission_outOfOrder(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + els := &expressLaneService{ + roundInfo: containers.NewLruCache[uint64, *expressLaneRoundInfo](8), + roundTimingInfo: defaultTestRoundTimingInfo(time.Now()), + seqConfig: func() *SequencerConfig { return &SequencerConfig{} }, + } + els.roundInfo.Add(0, &expressLaneRoundInfo{1, make(map[uint64]*msgAndResult)}) + els.StopWaiter.Start(ctx, els) + els.roundControl.Store(0, crypto.PubkeyToAddress(testPriv.PublicKey)) + stubPublisher := makeStubPublisher(els) + els.transactionPublisher = stubPublisher + + messages := []*timeboost.ExpressLaneSubmission{ + buildValidSubmissionWithSeqAndTx(t, 0, 10, types.NewTransaction(0, common.MaxAddress, big.NewInt(0), 0, big.NewInt(0), []byte{1})), + buildValidSubmissionWithSeqAndTx(t, 0, 5, emptyTx), + buildValidSubmissionWithSeqAndTx(t, 0, 1, emptyTx), + buildValidSubmissionWithSeqAndTx(t, 0, 4, emptyTx), + buildValidSubmissionWithSeqAndTx(t, 0, 2, emptyTx), + } + + // We launch 5 goroutines out of which 2 would return with a result hence we initially add a delta of 7 + var wg sync.WaitGroup + wg.Add(7) + for _, msg := range messages { + go func(w *sync.WaitGroup) { + w.Done() + err := els.sequenceExpressLaneSubmission(ctx, msg) + if msg.SequenceNumber != 10 { // Because this go-routine will be interrupted after the test itself ends and 10 will still be waiting for result + require.NoError(t, err) + w.Done() + } + }(&wg) + } + wg.Wait() + + // We should have only published 2, as we are missing sequence number 3. + time.Sleep(2 * time.Second) + require.Equal(t, 2, len(stubPublisher.publishedTxOrder)) + els.roundInfoMutex.Lock() + roundInfo, _ := els.roundInfo.Get(0) + require.Equal(t, 3, len(roundInfo.msgAndResultBySequenceNumber)) // Processed txs are deleted + els.roundInfoMutex.Unlock() + + wg.Add(2) // 4 & 5 should be able to get in after 3 so we add a delta of 2 + err := els.sequenceExpressLaneSubmission(ctx, buildValidSubmissionWithSeqAndTx(t, 0, 3, emptyTx)) + require.NoError(t, err) + wg.Wait() + require.Equal(t, 5, len(stubPublisher.publishedTxOrder)) + + els.roundInfoMutex.Lock() + roundInfo, _ = els.roundInfo.Get(0) + require.Equal(t, 1, len(roundInfo.msgAndResultBySequenceNumber)) // Tx with seq num 10 should still be present + els.roundInfoMutex.Unlock() +} + +func Test_expressLaneService_sequenceExpressLaneSubmission_erroredTx(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + els := &expressLaneService{ + roundInfo: containers.NewLruCache[uint64, *expressLaneRoundInfo](8), + roundTimingInfo: defaultTestRoundTimingInfo(time.Now()), + seqConfig: func() *SequencerConfig { return &SequencerConfig{} }, + } + els.roundInfo.Add(0, &expressLaneRoundInfo{1, make(map[uint64]*msgAndResult)}) + els.StopWaiter.Start(ctx, els) + els.roundControl.Store(0, crypto.PubkeyToAddress(testPriv.PublicKey)) + stubPublisher := makeStubPublisher(els) + els.transactionPublisher = stubPublisher + + messages := []*timeboost.ExpressLaneSubmission{ + buildValidSubmissionWithSeqAndTx(t, 0, 1, emptyTx), + buildValidSubmissionWithSeqAndTx(t, 0, 2, types.NewTransaction(0, common.MaxAddress, big.NewInt(0), 0, big.NewInt(0), []byte{1})), + buildValidSubmissionWithSeqAndTx(t, 0, 3, emptyTx), + buildValidSubmissionWithSeqAndTx(t, 0, 4, emptyTx), + } + for _, msg := range messages { + if msg.Transaction.Hash() != emptyTx.Hash() { + 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. + // Since sequence number 2 failed after submission stage, that nonce is used up + require.Equal(t, 3, len(stubPublisher.publishedTxOrder)) +} + +func TestIsWithinAuctionCloseWindow(t *testing.T) { + initialTimestamp := time.Date(2024, 8, 8, 15, 0, 0, 0, time.UTC) + roundTimingInfo := defaultTestRoundTimingInfo(initialTimestamp) + + 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 := roundTimingInfo.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"), + roundTimingInfo: defaultTestRoundTimingInfo(time.Now()), + roundInfo: containers.NewLruCache[uint64, *expressLaneRoundInfo](8), + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + }, + } + es.roundControl.Store(0, addr) + es.roundInfo.Add(0, &expressLaneRoundInfo{1, make(map[uint64]*msgAndResult)}) + + sub := buildValidSubmission(b, common.HexToAddress("0x2Aef36410182881a4b13664a1E079762D7F716e6"), testPriv, 0) + 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, + round uint64, +) *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: round, + } + data, err := b.ToMessageBytes() + require.NoError(t, err) + signature, err := buildSignature(privKey, data) + require.NoError(t, err) + b.Signature = signature + return b +} + +func buildValidSubmissionWithSeqAndTx( + t testing.TB, + round uint64, + seq uint64, + tx *types.Transaction, +) *timeboost.ExpressLaneSubmission { + b := &timeboost.ExpressLaneSubmission{ + ChainId: big.NewInt(1), + AuctionContractAddress: common.HexToAddress("0x2Aef36410182881a4b13664a1E079762D7F716e6"), + Transaction: tx, + Signature: make([]byte, 65), + Round: round, + SequenceNumber: seq, + } + data, err := b.ToMessageBytes() + require.NoError(t, err) + signature, err := buildSignature(testPriv, 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 5030de0cfa..7877447481 100644 --- a/execution/gethexec/node.go +++ b/execution/gethexec/node.go @@ -84,19 +84,21 @@ func StylusTargetConfigAddOptions(prefix string, f *flag.FlagSet) { } type Config struct { - ParentChainReader headerreader.Config `koanf:"parent-chain-reader" reload:"hot"` - Sequencer SequencerConfig `koanf:"sequencer" reload:"hot"` - RecordingDatabase BlockRecorderConfig `koanf:"recording-database"` - TxPreChecker TxPreCheckerConfig `koanf:"tx-pre-checker" reload:"hot"` - Forwarder ForwarderConfig `koanf:"forwarder"` - ForwardingTarget string `koanf:"forwarding-target"` - SecondaryForwardingTarget []string `koanf:"secondary-forwarding-target"` - Caching CachingConfig `koanf:"caching"` - RPC arbitrum.Config `koanf:"rpc"` - TxLookupLimit uint64 `koanf:"tx-lookup-limit"` - EnablePrefetchBlock bool `koanf:"enable-prefetch-block"` - SyncMonitor SyncMonitorConfig `koanf:"sync-monitor"` - StylusTarget StylusTargetConfig `koanf:"stylus-target"` + ParentChainReader headerreader.Config `koanf:"parent-chain-reader" reload:"hot"` + Sequencer SequencerConfig `koanf:"sequencer" reload:"hot"` + RecordingDatabase BlockRecorderConfig `koanf:"recording-database"` + TxPreChecker TxPreCheckerConfig `koanf:"tx-pre-checker" reload:"hot"` + Forwarder ForwarderConfig `koanf:"forwarder"` + ForwardingTarget string `koanf:"forwarding-target"` + SecondaryForwardingTarget []string `koanf:"secondary-forwarding-target"` + Caching CachingConfig `koanf:"caching"` + RPC arbitrum.Config `koanf:"rpc"` + TxLookupLimit uint64 `koanf:"tx-lookup-limit"` + EnablePrefetchBlock bool `koanf:"enable-prefetch-block"` + SyncMonitor SyncMonitorConfig `koanf:"sync-monitor"` + StylusTarget StylusTargetConfig `koanf:"stylus-target"` + BlockMetadataApiCacheSize uint64 `koanf:"block-metadata-api-cache-size"` + BlockMetadataApiBlocksLimit uint64 `koanf:"block-metadata-api-blocks-limit"` forwardingTarget string } @@ -139,39 +141,44 @@ func ConfigAddOptions(prefix string, f *flag.FlagSet) { f.Uint64(prefix+".tx-lookup-limit", ConfigDefault.TxLookupLimit, "retain the ability to lookup transactions by hash for the past N blocks (0 = all blocks)") f.Bool(prefix+".enable-prefetch-block", ConfigDefault.EnablePrefetchBlock, "enable prefetching of blocks") StylusTargetConfigAddOptions(prefix+".stylus-target", f) + f.Uint64(prefix+".block-metadata-api-cache-size", ConfigDefault.BlockMetadataApiCacheSize, "size (in bytes) of lru cache storing the blockMetadata to service arb_getRawBlockMetadata") + f.Uint64(prefix+".block-metadata-api-blocks-limit", ConfigDefault.BlockMetadataApiBlocksLimit, "maximum number of blocks allowed to be queried for blockMetadata per arb_getRawBlockMetadata query. Enabled by default, set 0 to disable the limit") } var ConfigDefault = Config{ - RPC: arbitrum.DefaultConfig, - Sequencer: DefaultSequencerConfig, - ParentChainReader: headerreader.DefaultConfig, - RecordingDatabase: DefaultBlockRecorderConfig, - ForwardingTarget: "", - SecondaryForwardingTarget: []string{}, - TxPreChecker: DefaultTxPreCheckerConfig, - TxLookupLimit: 126_230_400, // 1 year at 4 blocks per second - Caching: DefaultCachingConfig, - Forwarder: DefaultNodeForwarderConfig, - EnablePrefetchBlock: true, - StylusTarget: DefaultStylusTargetConfig, + RPC: arbitrum.DefaultConfig, + Sequencer: DefaultSequencerConfig, + ParentChainReader: headerreader.DefaultConfig, + RecordingDatabase: DefaultBlockRecorderConfig, + ForwardingTarget: "", + SecondaryForwardingTarget: []string{}, + TxPreChecker: DefaultTxPreCheckerConfig, + TxLookupLimit: 126_230_400, // 1 year at 4 blocks per second + Caching: DefaultCachingConfig, + Forwarder: DefaultNodeForwarderConfig, + EnablePrefetchBlock: true, + StylusTarget: DefaultStylusTargetConfig, + BlockMetadataApiCacheSize: 100 * 1024 * 1024, + BlockMetadataApiBlocksLimit: 100, } type ConfigFetcher func() *Config type ExecutionNode struct { - ChainDB ethdb.Database - Backend *arbitrum.Backend - FilterSystem *filters.FilterSystem - ArbInterface *ArbInterface - ExecEngine *ExecutionEngine - Recorder *BlockRecorder - Sequencer *Sequencer // either nil or same as TxPublisher - TxPublisher TransactionPublisher - ConfigFetcher ConfigFetcher - SyncMonitor *SyncMonitor - ParentChainReader *headerreader.HeaderReader - ClassicOutbox *ClassicOutboxRetriever - started atomic.Bool + ChainDB ethdb.Database + Backend *arbitrum.Backend + FilterSystem *filters.FilterSystem + ArbInterface *ArbInterface + ExecEngine *ExecutionEngine + Recorder *BlockRecorder + Sequencer *Sequencer // either nil or same as TxPublisher + TxPublisher TransactionPublisher + ConfigFetcher ConfigFetcher + SyncMonitor *SyncMonitor + ParentChainReader *headerreader.HeaderReader + ClassicOutbox *ClassicOutboxRetriever + started atomic.Bool + bulkBlockMetadataFetcher *BulkBlockMetadataFetcher } func CreateExecutionNode( @@ -261,12 +268,27 @@ func CreateExecutionNode( } } + bulkBlockMetadataFetcher := NewBulkBlockMetadataFetcher(l2BlockChain, execEngine, config.BlockMetadataApiCacheSize, config.BlockMetadataApiBlocksLimit) + apis := []rpc.API{{ Namespace: "arb", Version: "1.0", - Service: NewArbAPI(txPublisher), + Service: NewArbAPI(txPublisher, bulkBlockMetadataFetcher), 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", @@ -296,18 +318,19 @@ func CreateExecutionNode( stack.RegisterAPIs(apis) return &ExecutionNode{ - ChainDB: chainDB, - Backend: backend, - FilterSystem: filterSystem, - ArbInterface: arbInterface, - ExecEngine: execEngine, - Recorder: recorder, - Sequencer: sequencer, - TxPublisher: txPublisher, - ConfigFetcher: configFetcher, - SyncMonitor: syncMon, - ParentChainReader: parentChainReader, - ClassicOutbox: classicOutbox, + ChainDB: chainDB, + Backend: backend, + FilterSystem: filterSystem, + ArbInterface: arbInterface, + ExecEngine: execEngine, + Recorder: recorder, + Sequencer: sequencer, + TxPublisher: txPublisher, + ConfigFetcher: configFetcher, + SyncMonitor: syncMon, + ParentChainReader: parentChainReader, + ClassicOutbox: classicOutbox, + bulkBlockMetadataFetcher: bulkBlockMetadataFetcher, }, nil } @@ -335,6 +358,7 @@ func (n *ExecutionNode) Initialize(ctx context.Context) error { if err != nil { return fmt.Errorf("error setting sync backend: %w", err) } + return nil } @@ -356,6 +380,7 @@ func (n *ExecutionNode) Start(ctx context.Context) error { if n.ParentChainReader != nil { n.ParentChainReader.Start(ctx) } + n.bulkBlockMetadataFetcher.Start(ctx) return nil } @@ -363,6 +388,7 @@ func (n *ExecutionNode) StopAndWait() { if !n.started.Load() { return } + n.bulkBlockMetadataFetcher.StopAndWait() // TODO after separation // n.Stack.StopRPC() // does nothing if not running if n.TxPublisher.Started() { @@ -388,7 +414,7 @@ func (n *ExecutionNode) StopAndWait() { func (n *ExecutionNode) DigestMessage(num arbutil.MessageIndex, msg *arbostypes.MessageWithMetadata, msgForPrefetch *arbostypes.MessageWithMetadata) (*execution.MessageResult, error) { return n.ExecEngine.DigestMessage(num, msg, msgForPrefetch) } -func (n *ExecutionNode) Reorg(count arbutil.MessageIndex, newMessages []arbostypes.MessageWithMetadataAndBlockHash, oldMessages []*arbostypes.MessageWithMetadata) ([]*execution.MessageResult, error) { +func (n *ExecutionNode) Reorg(count arbutil.MessageIndex, newMessages []arbostypes.MessageWithMetadataAndBlockInfo, oldMessages []*arbostypes.MessageWithMetadata) ([]*execution.MessageResult, error) { return n.ExecEngine.Reorg(count, newMessages, oldMessages) } func (n *ExecutionNode) HeadMessageNumber() (arbutil.MessageIndex, error) { @@ -452,6 +478,9 @@ func (n *ExecutionNode) SetConsensusClient(consensus execution.FullConsensusClie func (n *ExecutionNode) MessageIndexToBlockNumber(messageNum arbutil.MessageIndex) uint64 { return n.ExecEngine.MessageIndexToBlockNumber(messageNum) } +func (n *ExecutionNode) BlockNumberToMessageIndex(blockNum uint64) (arbutil.MessageIndex, error) { + return n.ExecEngine.BlockNumberToMessageIndex(blockNum) +} func (n *ExecutionNode) Maintenance() error { return n.ChainDB.Compact(nil, nil) diff --git a/execution/gethexec/sequencer.go b/execution/gethexec/sequencer.go index faded7375c..765843d602 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,31 @@ 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"` + SequencerHTTPEndpoint string `koanf:"sequencer-http-endpoint"` + EarlySubmissionGrace time.Duration `koanf:"early-submission-grace"` + MaxQueuedTxCount int `koanf:"max-queued-tx-count"` +} + +var DefaultTimeboostConfig = TimeboostConfig{ + Enable: false, + AuctionContractAddress: "", + AuctioneerAddress: "", + ExpressLaneAdvantage: time.Millisecond * 200, + SequencerHTTPEndpoint: "http://localhost:8547", + EarlySubmissionGrace: time.Second * 2, + MaxQueuedTxCount: 10, +} + func (c *SequencerConfig) Validate() error { for _, address := range c.SenderWhitelist { if len(address) == 0 { @@ -107,6 +130,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 +166,7 @@ var DefaultSequencerConfig = SequencerConfig{ ExpectedSurplusSoftThreshold: "default", ExpectedSurplusHardThreshold: "default", EnableProfiling: false, + Timeboost: DefaultTimeboostConfig, } func SequencerConfigAddOptions(prefix string, f *flag.FlagSet) { @@ -139,6 +176,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 +189,16 @@ 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") + f.String(prefix+".sequencer-http-endpoint", DefaultTimeboostConfig.SequencerHTTPEndpoint, "this sequencer's http endpoint") + f.Duration(prefix+".early-submission-grace", DefaultTimeboostConfig.EarlySubmissionGrace, "period of time before the next round where submissions for the next round will be queued") + f.Int(prefix+".max-queued-tx-count", DefaultTimeboostConfig.MaxQueuedTxCount, "maximum allowed number of express lane txs with future sequence number to be queued. Set 0 to disable this check and a negative value to prevent queuing of any future sequence number transactions") +} + type txQueueItem struct { tx *types.Transaction txSize int // size in bytes of the marshalled transaction @@ -158,6 +207,7 @@ type txQueueItem struct { returnedResult *atomic.Bool ctx context.Context firstAppearance time.Time + isTimeboosted bool } func (i *txQueueItem) returnResult(err error) { @@ -289,18 +339,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 +388,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 +408,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 +465,127 @@ 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 { + _, forwarder := s.GetPauseAndForwarder() + if forwarder != nil { + err := forwarder.PublishTransaction(parentCtx, tx, options) + if !errors.Is(err, ErrNoSequencer) { + return err + } + } + + config := s.config() + queueTimeout := config.QueueTimeout + queueCtx, cancelFunc := ctxWithTimeout(parentCtx, queueTimeout+config.Timeboost.ExpressLaneAdvantage) // Include timeboost delay in ctx timeout + defer cancelFunc() + + resultChan := make(chan error, 1) + err := s.publishTransactionToQueue(queueCtx, tx, options, resultChan, false /* delay tx if express lane is active */) + if err != nil { + return err + } + + now := time.Now() + // Just to be safe, make sure we don't run over twice the queue timeout + abortCtx, cancel := ctxWithTimeout(parentCtx, queueTimeout*2) + defer cancel() + + select { + case res := <-resultChan: + return res + case <-abortCtx.Done(): + // We use abortCtx here and not queueCtx, because the QueueTimeout only applies to the background queue. + // We want to give the background queue as much time as possible to make a response. + err := abortCtx.Err() + if parentCtx.Err() == nil { + // If we've hit the abort deadline (as opposed to parentCtx being canceled), something went wrong. + log.Warn("Transaction sequencing hit abort deadline", "err", err, "submittedAt", now, "queueTimeout", queueTimeout*2, "txHash", tx.Hash()) + } + return err + } +} + +func (s *Sequencer) PublishAuctionResolutionTransaction(ctx context.Context, tx *types.Transaction) error { + if !s.config().Timeboost.Enable { + return errors.New("timeboost not enabled") + } + + forwarder, err := s.getForwarder(ctx) + if err != nil { + return err + } + if forwarder != nil { + return fmt.Errorf("sequencer is currently not the chosen one, cannot accept auction resolution tx") + } + + 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.roundTimingInfo.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: s.GetContext(), + firstAppearance: time.Now(), + isTimeboosted: true, + } + return nil +} + +func (s *Sequencer) PublishExpressLaneTransaction(ctx context.Context, msg *timeboost.ExpressLaneSubmission) error { + if !s.config().Timeboost.Enable { + return errors.New("timeboost not enabled") + } + + forwarder, err := s.getForwarder(ctx) + if err != nil { + return err + } + if forwarder != nil { + return forwarder.PublishExpressLaneTransaction(ctx, msg) + } + + 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) PublishTimeboostedTransaction(queueCtx context.Context, tx *types.Transaction, options *arbitrum_types.ConditionalOptions, resultChan chan error) { + if err := s.publishTransactionToQueue(queueCtx, tx, options, resultChan, true); err != nil { + resultChan <- err + } +} + +func (s *Sequencer) publishTransactionToQueue(queueCtx context.Context, tx *types.Transaction, options *arbitrum_types.ConditionalOptions, resultChan chan error, 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 @@ -401,14 +600,6 @@ func (s *Sequencer) PublishTransaction(parentCtx context.Context, tx *types.Tran sequencerBacklogGauge.Inc(1) defer sequencerBacklogGauge.Dec(1) - _, forwarder := s.GetPauseAndForwarder() - if forwarder != nil { - err := forwarder.PublishTransaction(parentCtx, tx, options) - if !errors.Is(err, ErrNoSequencer) { - return err - } - } - if len(s.senderWhitelist) > 0 { signer := types.LatestSigner(s.execEngine.bc.Config()) sender, err := types.Sender(signer, tx) @@ -431,15 +622,12 @@ func (s *Sequencer) PublishTransaction(parentCtx context.Context, tx *types.Tran return err } - queueTimeout := config.QueueTimeout - queueCtx, cancelFunc := ctxWithTimeout(parentCtx, queueTimeout) - defer cancelFunc() - - // Just to be safe, make sure we don't run over twice the queue timeout - abortCtx, cancel := ctxWithTimeout(parentCtx, queueTimeout*2) - defer cancel() + if s.config().Timeboost.Enable && s.expressLaneService != nil { + if !isExpressLaneController && s.expressLaneService.currentRoundHasController() { + time.Sleep(s.config().Timeboost.ExpressLaneAdvantage) + } + } - resultChan := make(chan error, 1) queueItem := txQueueItem{ tx, len(txBytes), @@ -448,6 +636,7 @@ func (s *Sequencer) PublishTransaction(parentCtx context.Context, tx *types.Tran &atomic.Bool{}, queueCtx, time.Now(), + isExpressLaneController, } select { case s.txQueue <- queueItem: @@ -455,19 +644,7 @@ func (s *Sequencer) PublishTransaction(parentCtx context.Context, tx *types.Tran return queueCtx.Err() } - select { - case res := <-resultChan: - return res - case <-abortCtx.Done(): - // We use abortCtx here and not queueCtx, because the QueueTimeout only applies to the background queue. - // We want to give the background queue as much time as possible to make a response. - err := abortCtx.Err() - if parentCtx.Err() == nil { - // If we've hit the abort deadline (as opposed to parentCtx being canceled), something went wrong. - log.Warn("Transaction sequencing hit abort deadline", "err", err, "submittedAt", queueItem.firstAppearance, "queueTimeout", queueTimeout, "txHash", tx.Hash()) - } - return err - } + 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 { @@ -599,26 +776,32 @@ func (s *Sequencer) GetPauseAndForwarder() (chan struct{}, *TxForwarder) { return s.pauseChan, s.forwarder } -// only called from createBlock, may be paused -func (s *Sequencer) handleInactive(ctx context.Context, queueItems []txQueueItem) bool { - var forwarder *TxForwarder +// getForwarder returns accurate forwarder and pauses if needed +// required for processing timeboost txs, as just checking forwarder==nil doesn't imply the sequencer to be chosen +func (s *Sequencer) getForwarder(ctx context.Context) (*TxForwarder, error) { for { - var pause chan struct{} - pause, forwarder = s.GetPauseAndForwarder() + pause, forwarder := s.GetPauseAndForwarder() if pause == nil { - if forwarder == nil { - return false - } - // if forwarding: jump to next loop - break + return forwarder, nil } // if paused: wait till unpaused select { case <-ctx.Done(): - return true + return nil, ctx.Err() case <-pause: } } +} + +// only called from createBlock, may be paused +func (s *Sequencer) handleInactive(ctx context.Context, queueItems []txQueueItem) bool { + forwarder, err := s.getForwarder(ctx) + if err != nil { + return true + } + if forwarder == nil { + return false + } publishResults := make(chan *txQueueItem, len(queueItems)) for _, item := range queueItems { item := item @@ -797,35 +980,59 @@ func (s *Sequencer) createBlock(ctx context.Context) (returnValue bool) { for { var queueItem txQueueItem + if s.txRetryQueue.Len() > 0 { - queueItem = s.txRetryQueue.Pop() + select { + case queueItem = <-s.timeboostAuctionResolutionTxQueue: + log.Debug("Popped the auction resolution tx", "txHash", queueItem.tx.Hash()) + default: + // 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 if nextNonceExpiryTimer != nil { nextNonceExpiryChan = nextNonceExpiryTimer.C } select { - case queueItem = <-s.txQueue: - case <-nextNonceExpiryChan: - // No need to stop the previous timer since it already elapsed - nextNonceExpiryTimer = s.expireNonceFailures() - continue - case <-s.onForwarderSet: - // Make sure this notification isn't outdated - _, forwarder := s.GetPauseAndForwarder() - if forwarder != nil { - s.nonceFailures.Clear() + case queueItem = <-s.timeboostAuctionResolutionTxQueue: + log.Debug("Popped the auction resolution tx", "txHash", queueItem.tx.Hash()) + default: + select { + case queueItem = <-s.txQueue: + case queueItem = <-s.timeboostAuctionResolutionTxQueue: + log.Debug("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() + continue + case <-s.onForwarderSet: + // Make sure this notification isn't outdated + _, forwarder := s.GetPauseAndForwarder() + if forwarder != nil { + s.nonceFailures.Clear() + } + continue + case <-ctx.Done(): + return false } - continue - case <-ctx.Done(): - return false } } else { done := false select { - case queueItem = <-s.txQueue: + case queueItem = <-s.timeboostAuctionResolutionTxQueue: + log.Debug("Popped the auction resolution tx", "txHash", queueItem.tx.Hash()) default: - done = true + select { + case queueItem = <-s.txQueue: + case queueItem = <-s.timeboostAuctionResolutionTxQueue: + log.Debug("Popped the auction resolution tx", "txHash", queueItem.tx.Hash()) + default: + done = true + } } if done { break @@ -855,6 +1062,7 @@ func (s *Sequencer) createBlock(ctx context.Context) (returnValue bool) { s.nonceCache.BeginNewBlock() queueItems = s.precheckNonces(queueItems, totalBlockSize) txes := make([]*types.Transaction, len(queueItems)) + timeboostedTxs := make(map[common.Hash]struct{}) hooks := s.makeSequencingHooks() hooks.ConditionalOptionsForTx = make([]*arbitrum_types.ConditionalOptions, len(queueItems)) totalBlockSize = 0 // recompute the totalBlockSize to double check it @@ -862,6 +1070,9 @@ func (s *Sequencer) createBlock(ctx context.Context) (returnValue bool) { txes[i] = queueItem.tx totalBlockSize = arbmath.SaturatingAdd(totalBlockSize, queueItem.txSize) hooks.ConditionalOptionsForTx[i] = queueItem.options + if queueItem.isTimeboosted { + timeboostedTxs[queueItem.tx.Hash()] = struct{}{} + } } if totalBlockSize > config.MaxTxDataSize { @@ -916,9 +1127,9 @@ func (s *Sequencer) createBlock(ctx context.Context) (returnValue bool) { err error ) if config.EnableProfiling { - block, err = s.execEngine.SequenceTransactionsWithProfiling(header, txes, hooks) + block, err = s.execEngine.SequenceTransactionsWithProfiling(header, txes, hooks, timeboostedTxs) } else { - block, err = s.execEngine.SequenceTransactions(header, txes, hooks) + block, err = s.execEngine.SequenceTransactions(header, txes, hooks, timeboostedTxs) } elapsed := time.Since(start) blockCreationTimer.Update(elapsed) @@ -1017,6 +1228,30 @@ func (s *Sequencer) Initialize(ctx context.Context) error { return nil } +func (s *Sequencer) InitializeExpressLaneService( + apiBackend *arbitrum.APIBackend, + filterSystem *filters.FilterSystem, + auctionContractAddr common.Address, + auctioneerAddr common.Address, + earlySubmissionGrace time.Duration, +) error { + els, err := newExpressLaneService( + s, + s.config, + apiBackend, + filterSystem, + auctionContractAddr, + s.execEngine.bc, + earlySubmissionGrace, + ) + if err != nil { + return fmt.Errorf("failed to create express lane service. auctionContractAddr: %v err: %w", auctionContractAddr, err) + } + s.auctioneerAddr = auctioneerAddr + s.expressLaneService = els + return nil +} + var ( usableBytesInBlob = big.NewInt(int64(len(kzg4844.Blob{}) * 31 / 32)) blobTxBlobGasPerBlob = big.NewInt(params.BlobTxBlobGasPerBlob) @@ -1062,6 +1297,12 @@ func (s *Sequencer) updateExpectedSurplus(ctx context.Context) (int64, error) { return expectedSurplus, nil } +func (s *Sequencer) StartExpressLaneService(ctx context.Context) { + if s.expressLaneService != nil { + s.expressLaneService.Start(ctx) + } +} + func (s *Sequencer) Start(ctxIn context.Context) error { s.StopWaiter.Start(ctxIn, s) config := s.config() @@ -1115,7 +1356,6 @@ func (s *Sequencer) Start(ctxIn context.Context) error { } } }) - } s.CallIteratively(func(ctx context.Context) time.Duration { @@ -1133,11 +1373,21 @@ func (s *Sequencer) Start(ctxIn context.Context) error { 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 @@ -1158,6 +1408,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/sync_monitor.go b/execution/gethexec/sync_monitor.go index 7f04b2ee4a..07c05351d1 100644 --- a/execution/gethexec/sync_monitor.go +++ b/execution/gethexec/sync_monitor.go @@ -6,6 +6,9 @@ import ( "github.com/pkg/errors" flag "github.com/spf13/pflag" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" + "github.com/offchainlabs/nitro/execution" ) @@ -122,3 +125,15 @@ func (s *SyncMonitor) Synced() bool { func (s *SyncMonitor) SetConsensusInfo(consensus execution.ConsensusInfo) { s.consensus = consensus } + +func (s *SyncMonitor) BlockMetadataByNumber(blockNum uint64) (common.BlockMetadata, error) { + count, err := s.exec.BlockNumberToMessageIndex(blockNum) + if err != nil { + return nil, err + } + if s.consensus != nil { + return s.consensus.BlockMetadataAtCount(count + 1) + } + log.Debug("FullConsensusClient is not accessible to execution, BlockMetadataByNumber will return nil") + return nil, nil +} 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/execution/interface.go b/execution/interface.go index c0aa71c146..ca067240d0 100644 --- a/execution/interface.go +++ b/execution/interface.go @@ -30,10 +30,12 @@ var ErrSequencerInsertLockTaken = errors.New("insert lock taken") // always needed type ExecutionClient interface { DigestMessage(num arbutil.MessageIndex, msg *arbostypes.MessageWithMetadata, msgForPrefetch *arbostypes.MessageWithMetadata) (*MessageResult, error) - Reorg(count arbutil.MessageIndex, newMessages []arbostypes.MessageWithMetadataAndBlockHash, oldMessages []*arbostypes.MessageWithMetadata) ([]*MessageResult, error) + Reorg(count arbutil.MessageIndex, newMessages []arbostypes.MessageWithMetadataAndBlockInfo, oldMessages []*arbostypes.MessageWithMetadata) ([]*MessageResult, error) HeadMessageNumber() (arbutil.MessageIndex, error) HeadMessageNumberSync(t *testing.T) (arbutil.MessageIndex, error) ResultAtPos(pos arbutil.MessageIndex) (*MessageResult, error) + MessageIndexToBlockNumber(messageNum arbutil.MessageIndex) uint64 + BlockNumberToMessageIndex(blockNum uint64) (arbutil.MessageIndex, error) } // needed for validators / stakers @@ -84,6 +86,7 @@ type ConsensusInfo interface { Synced() bool FullSyncProgressMap() map[string]interface{} SyncTargetMessageCount() arbutil.MessageIndex + BlockMetadataAtCount(count arbutil.MessageIndex) (common.BlockMetadata, error) // TODO: switch from pulling to pushing safe/finalized GetSafeMsgCount(ctx context.Context) (arbutil.MessageIndex, error) @@ -92,7 +95,7 @@ type ConsensusInfo interface { } type ConsensusSequencer interface { - WriteMessageFromSequencer(pos arbutil.MessageIndex, msgWithMeta arbostypes.MessageWithMetadata, msgResult MessageResult) error + WriteMessageFromSequencer(pos arbutil.MessageIndex, msgWithMeta arbostypes.MessageWithMetadata, msgResult MessageResult, blockMetadata common.BlockMetadata) error ExpectChosenSequencer() error } diff --git a/go-ethereum b/go-ethereum index 5a7010a057..540e5acd03 160000 --- a/go-ethereum +++ b/go-ethereum @@ -1 +1 @@ -Subproject commit 5a7010a057c6539a3bb154d15a47c015a96b1ef8 +Subproject commit 540e5acd032a8e8fcb24b789c5e932d8f22b5f3d diff --git a/go.mod b/go.mod index 562ab073d4..6009a6822e 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ replace github.com/offchainlabs/bold => ./bold 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 @@ -31,13 +32,16 @@ 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.1 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/offchainlabs/bold v0.0.0-00010101000000-000000000000 github.com/pkg/errors v0.9.1 @@ -45,9 +49,11 @@ require ( 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.31.0 + golang.org/x/sync v0.10.0 golang.org/x/sys v0.28.0 golang.org/x/term v0.27.0 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d @@ -64,22 +70,26 @@ 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/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/testify v1.9.0 // 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.21.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/grpc v1.64.1 // indirect + google.golang.org/protobuf v1.34.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) @@ -135,10 +145,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.1 // 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 @@ -152,7 +158,6 @@ require ( github.com/holiman/bloomfilter/v2 v2.0.3 // indirect github.com/huin/goupnp v1.3.0 // indirect github.com/jackpal/go-nat-pmp v1.0.2 // indirect - github.com/jmoiron/sqlx v1.3.5 // indirect github.com/juju/errors v0.0.0-20181118221551-089d3ea4e4d5 // indirect github.com/juju/loggo v0.0.0-20180524022052-584905176618 // indirect github.com/klauspost/compress v1.17.2 // indirect @@ -162,7 +167,6 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/mattn/go-sqlite3 v1.14.6 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/pointerstructure v1.2.0 // indirect @@ -170,6 +174,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.39.0 // indirect @@ -195,9 +200,5 @@ require ( golang.org/x/mod v0.17.0 // indirect golang.org/x/net v0.33.0 // indirect golang.org/x/oauth2 v0.22.0 - golang.org/x/sync v0.10.0 - golang.org/x/text v0.21.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 285e4fb872..8b1f2c3a87 100644 --- a/go.sum +++ b/go.sum @@ -13,9 +13,13 @@ cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuA cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng= cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs= cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0= +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/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.5.2 h1:vUG4lAyuPCXO0TLbXvPv7EB7cNK1QV/luu55UHLrrn8= github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible h1:1G1pk05UrOh0NlF1oeaaix1x8XzrfjIDK47TY0Zehcw= @@ -211,8 +215,8 @@ 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.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= -github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +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-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= @@ -336,8 +340,8 @@ github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7Bd 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.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= -github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +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/juju/clock v0.0.0-20180524022203-d293bb356ca4/go.mod h1:nD0vlnrUjcjJhqN5WuCWZyzfd5AHZAC9/ajvbSx69xA= @@ -354,6 +358,7 @@ github.com/juju/utils v0.0.0-20180808125547-9dfc6dbfb02b/go.mod h1:6/KLg8Wz/y2KV github.com/juju/version v0.0.0-20161031051906-1f41e27e54f2/go.mod h1:kE8gK5X0CImdr7qpSKl3xB2PmpySSmfj7zVbkZFs81U= 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= @@ -371,8 +376,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.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +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= @@ -387,8 +392,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.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= -github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +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.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= diff --git a/nitro-testnode b/nitro-testnode index c177f28234..15a2bfea70 160000 --- a/nitro-testnode +++ b/nitro-testnode @@ -1 +1 @@ -Subproject commit c177f282340285bcdae2d6a784547e2bb8b97498 +Subproject commit 15a2bfea7030377771c5d2749f24afc6b48c5deb diff --git a/system_tests/common_test.go b/system_tests/common_test.go index d3d4b33ab9..391111dc2a 100644 --- a/system_tests/common_test.go +++ b/system_tests/common_test.go @@ -686,6 +686,7 @@ func (b *NodeBuilder) RestartL2Node(t *testing.T) { l2.Client = client l2.ExecNode = execNode l2.cleanup = func() { b.L2.ConsensusNode.StopAndWait() } + l2.Stack = stack b.L2 = l2 b.L2Info = l2info diff --git a/system_tests/contract_tx_test.go b/system_tests/contract_tx_test.go index 306b8fada3..ad4f51204a 100644 --- a/system_tests/contract_tx_test.go +++ b/system_tests/contract_tx_test.go @@ -87,7 +87,7 @@ func TestContractTxDeploy(t *testing.T) { }, DelayedMessagesRead: delayedMessagesRead, }, - }) + }, nil) Require(t, err) txHash := types.NewTx(contractTx).Hash() 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/reorg_resequencing_test.go b/system_tests/reorg_resequencing_test.go index 70ab63bec1..8f452ce95d 100644 --- a/system_tests/reorg_resequencing_test.go +++ b/system_tests/reorg_resequencing_test.go @@ -88,7 +88,7 @@ func TestReorgResequencing(t *testing.T) { err = builder.L2.ConsensusNode.TxStreamer.AddMessages(startMsgCount, true, []arbostypes.MessageWithMetadata{{ Message: newMessage, DelayedMessagesRead: prevMessage.DelayedMessagesRead + 1, - }}) + }}, nil) Require(t, err) _, err = builder.L2.ExecNode.ExecEngine.HeadMessageNumberSync(t) diff --git a/system_tests/retryable_test.go b/system_tests/retryable_test.go index 5443f85e9b..fc752db197 100644 --- a/system_tests/retryable_test.go +++ b/system_tests/retryable_test.go @@ -466,7 +466,7 @@ func warpL1Time(t *testing.T, builder *NodeBuilder, ctx context.Context, current } hooks := arbos.NoopSequencingHooks() tx := builder.L2Info.PrepareTx("Faucet", "User2", 300000, big.NewInt(1), nil) - _, err = builder.L2.ExecNode.ExecEngine.SequenceTransactions(timeWarpHeader, types.Transactions{tx}, hooks) + _, err = builder.L2.ExecNode.ExecEngine.SequenceTransactions(timeWarpHeader, types.Transactions{tx}, hooks, nil) Require(t, err) return newL1Timestamp } diff --git a/system_tests/seq_coordinator_test.go b/system_tests/seq_coordinator_test.go index 76cff95f04..f6de83b3d3 100644 --- a/system_tests/seq_coordinator_test.go +++ b/system_tests/seq_coordinator_test.go @@ -89,12 +89,12 @@ func TestRedisSeqCoordinatorPriorities(t *testing.T) { }, DelayedMessagesRead: 1, } - err = node.SeqCoordinator.SequencingMessage(curMsgs, &emptyMessage) + err = node.SeqCoordinator.SequencingMessage(curMsgs, &emptyMessage, nil) if errors.Is(err, execution.ErrRetrySequencer) { return false } Require(t, err) - Require(t, node.TxStreamer.AddMessages(curMsgs, false, []arbostypes.MessageWithMetadata{emptyMessage})) + Require(t, node.TxStreamer.AddMessages(curMsgs, false, []arbostypes.MessageWithMetadata{emptyMessage}, nil)) return true } diff --git a/system_tests/seq_filter_test.go b/system_tests/seq_filter_test.go index fdd0c96d13..d57bb8fd0a 100644 --- a/system_tests/seq_filter_test.go +++ b/system_tests/seq_filter_test.go @@ -26,7 +26,7 @@ func TestSequencerTxFilter(t *testing.T) { builder, header, txes, hooks, cleanup := setupSequencerFilterTest(t, false) defer cleanup() - block, err := builder.L2.ExecNode.ExecEngine.SequenceTransactions(header, txes, hooks) + block, err := builder.L2.ExecNode.ExecEngine.SequenceTransactions(header, txes, hooks, nil) Require(t, err) // There shouldn't be any error in block generation if block == nil { t.Fatal("block should be generated as second tx should pass") @@ -54,7 +54,7 @@ func TestSequencerBlockFilterReject(t *testing.T) { builder, header, txes, hooks, cleanup := setupSequencerFilterTest(t, true) defer cleanup() - block, err := builder.L2.ExecNode.ExecEngine.SequenceTransactions(header, txes, hooks) + block, err := builder.L2.ExecNode.ExecEngine.SequenceTransactions(header, txes, hooks, nil) if block != nil { t.Fatal("block shouldn't be generated when all txes have failed") } @@ -72,7 +72,7 @@ func TestSequencerBlockFilterAccept(t *testing.T) { builder, header, txes, hooks, cleanup := setupSequencerFilterTest(t, true) defer cleanup() - block, err := builder.L2.ExecNode.ExecEngine.SequenceTransactions(header, txes[1:], hooks) + block, err := builder.L2.ExecNode.ExecEngine.SequenceTransactions(header, txes[1:], hooks, nil) Require(t, err) if block == nil { t.Fatal("block should be generated as the tx should pass") diff --git a/system_tests/timeboost_test.go b/system_tests/timeboost_test.go new file mode 100644 index 0000000000..98f8486832 --- /dev/null +++ b/system_tests/timeboost_test.go @@ -0,0 +1,1588 @@ +package arbtest + +import ( + "bytes" + "context" + "crypto/ecdsa" + "encoding/binary" + "encoding/json" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "strings" + "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" + "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/log" + "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/arbos/util" + "github.com/offchainlabs/nitro/arbutil" + "github.com/offchainlabs/nitro/broadcastclient" + "github.com/offchainlabs/nitro/broadcaster/message" + "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/colors" + "github.com/offchainlabs/nitro/util/containers" + "github.com/offchainlabs/nitro/util/redisutil" + "github.com/offchainlabs/nitro/util/rpcclient" + "github.com/offchainlabs/nitro/util/stopwaiter" + "github.com/offchainlabs/nitro/util/testhelpers" +) + +func TestForwardingExpressLaneTxs(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") + + _, seqClient, seqInfo, auctionContractAddr, aliceBidderClient, bobBidderClient, roundDuration, cleanupSeq, forwarder, cleanupForwarder := setupExpressLaneAuction(t, tmpDir, ctx, jwtSecretPath, withForwardingSeq) + defer cleanupSeq() + defer cleanupForwarder() + + auctionContract, err := express_lane_auctiongen.NewExpressLaneAuction(auctionContractAddr, seqClient) + Require(t, err) + rawRoundTimingInfo, err := auctionContract.RoundTimingInfo(&bind.CallOpts{}) + Require(t, err) + roundTimingInfo, err := timeboost.NewRoundTimingInfo(rawRoundTimingInfo) + Require(t, err) + + placeBidsAndDecideWinner(t, ctx, seqClient, seqInfo, auctionContract, "Bob", "Alice", bobBidderClient, aliceBidderClient, roundDuration) + time.Sleep(roundTimingInfo.TimeTilNextRound()) + + chainId, err := seqClient.ChainID(ctx) + Require(t, err) + + // Prepare a client that can submit txs to the sequencer via the express lane. + bobPriv := seqInfo.Accounts["Bob"].PrivateKey + forwardingSeqDial, err := rpc.Dial(forwarder.ConsensusNode.Stack.HTTPEndpoint()) + Require(t, err) + expressLaneClient := newExpressLaneClient( + bobPriv, + chainId, + *roundTimingInfo, + auctionContractAddr, + forwardingSeqDial, + ) + expressLaneClient.Start(ctx) + + verifyControllerAdvantage(t, ctx, seqClient, expressLaneClient, seqInfo, "Bob", "Alice") +} + +func TestExpressLaneTransactionHandlingComplex(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, aliceBidderClient, bobBidderClient, roundDuration, cleanupSeq, _, _ := setupExpressLaneAuction(t, tmpDir, ctx, jwtSecretPath, 0) + defer cleanupSeq() + + auctionContract, err := express_lane_auctiongen.NewExpressLaneAuction(auctionContractAddr, seqClient) + Require(t, err) + rawRoundTimingInfo, err := auctionContract.RoundTimingInfo(&bind.CallOpts{}) + Require(t, err) + roundTimingInfo, err := timeboost.NewRoundTimingInfo(rawRoundTimingInfo) + Require(t, err) + + // Prepare clients that can submit txs to the sequencer via the express lane. + chainId, err := seqClient.ChainID(ctx) + Require(t, err) + seqDial, err := rpc.Dial(seq.Stack.HTTPEndpoint()) + Require(t, err) + createExpressLaneClientFor := func(name string) (*expressLaneClient, bind.TransactOpts) { + priv := seqInfo.Accounts[name].PrivateKey + expressLaneClient := newExpressLaneClient( + priv, + chainId, + *roundTimingInfo, + auctionContractAddr, + seqDial, + ) + expressLaneClient.Start(ctx) + transacOpts := seqInfo.GetDefaultTransactOpts(name, ctx) + transacOpts.NoSend = true + return expressLaneClient, transacOpts + } + bobExpressLaneClient, _ := createExpressLaneClientFor("Bob") + aliceExpressLaneClient, _ := createExpressLaneClientFor("Alice") + + // Bob will win the auction and become controller for next round = x + placeBidsAndDecideWinner(t, ctx, seqClient, seqInfo, auctionContract, "Bob", "Alice", bobBidderClient, aliceBidderClient, roundDuration) + time.Sleep(roundTimingInfo.TimeTilNextRound()) + + // Check that Bob's tx gets priority since he's the controller + verifyControllerAdvantage(t, ctx, seqClient, bobExpressLaneClient, seqInfo, "Bob", "Alice") + + currNonce, err := seqClient.PendingNonceAt(ctx, seqInfo.GetAddress("Alice")) + Require(t, err) + seqInfo.GetInfoWithPrivKey("Alice").Nonce.Store(currNonce) + unblockingTx := seqInfo.PrepareTx("Alice", "Owner", seqInfo.TransferGas, big.NewInt(1), nil) + + bobExpressLaneClient.Lock() + currSeq := bobExpressLaneClient.sequence + bobExpressLaneClient.Unlock() + + // Send bunch of future txs so that they are queued up waiting for the unblocking seq num tx + var bobExpressLaneTxs types.Transactions + for i := currSeq + 1; i < 1000; i++ { + futureSeqTx := seqInfo.PrepareTx("Alice", "Owner", seqInfo.TransferGas, big.NewInt(1), nil) + bobExpressLaneTxs = append(bobExpressLaneTxs, futureSeqTx) + go func(tx *types.Transaction, seqNum uint64) { + err := bobExpressLaneClient.SendTransactionWithSequence(ctx, tx, seqNum) + t.Logf("got error for tx: hash-%s, seqNum-%d, err-%s", tx.Hash(), seqNum, err.Error()) + }(futureSeqTx, i) + } + + // Alice will win the auction for next round = x+1 + placeBidsAndDecideWinner(t, ctx, seqClient, seqInfo, auctionContract, "Alice", "Bob", aliceBidderClient, bobBidderClient, roundDuration) + + time.Sleep(roundTimingInfo.TimeTilNextRound() - 500*time.Millisecond) // we'll wait till the 1/2 second mark to the next round and then send the unblocking tx + + Require(t, bobExpressLaneClient.SendTransactionWithSequence(ctx, unblockingTx, currSeq)) // the unblockingTx itself should ideally pass, but the released 1000 txs shouldn't affect the round for which alice has won the bid for + + time.Sleep(500 * time.Millisecond) // Wait for controller change after the current round's end + + // Check that Alice's tx gets priority since she's the controller + verifyControllerAdvantage(t, ctx, seqClient, aliceExpressLaneClient, seqInfo, "Alice", "Bob") + + // Binary search and find how many of bob's futureSeqTxs were able to go through + s, f := 0, len(bobExpressLaneTxs)-1 + for s < f { + m := (s + f + 1) / 2 + _, err := seqClient.TransactionReceipt(ctx, bobExpressLaneTxs[m].Hash()) + if err != nil { + f = m - 1 + } else { + s = m + } + } + t.Logf("%d of the total %d bob's pending txs were accepted", s+1, len(bobExpressLaneTxs)) +} + +func TestExpressLaneTransactionHandling(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, aliceBidderClient, bobBidderClient, roundDuration, cleanupSeq, _, _ := setupExpressLaneAuction(t, tmpDir, ctx, jwtSecretPath, 0) + defer cleanupSeq() + + auctionContract, err := express_lane_auctiongen.NewExpressLaneAuction(auctionContractAddr, seqClient) + Require(t, err) + rawRoundTimingInfo, err := auctionContract.RoundTimingInfo(&bind.CallOpts{}) + Require(t, err) + roundTimingInfo, err := timeboost.NewRoundTimingInfo(rawRoundTimingInfo) + Require(t, err) + + placeBidsAndDecideWinner(t, ctx, seqClient, seqInfo, auctionContract, "Bob", "Alice", bobBidderClient, aliceBidderClient, roundDuration) + time.Sleep(roundTimingInfo.TimeTilNextRound()) + + chainId, err := seqClient.ChainID(ctx) + Require(t, err) + + // Prepare a client that can submit txs to the sequencer via the express lane. + bobPriv := seqInfo.Accounts["Bob"].PrivateKey + seqDial, err := rpc.Dial(seq.Stack.HTTPEndpoint()) + Require(t, err) + expressLaneClient := newExpressLaneClient( + bobPriv, + chainId, + *roundTimingInfo, + auctionContractAddr, + seqDial, + ) + expressLaneClient.Start(ctx) + + currNonce, err := seqClient.PendingNonceAt(ctx, seqInfo.GetAddress("Alice")) + Require(t, err) + seqInfo.GetInfoWithPrivKey("Alice").Nonce.Store(currNonce) + + // Send txs out of order + var txs types.Transactions + txs = append(txs, seqInfo.PrepareTx("Alice", "Owner", seqInfo.TransferGas, big.NewInt(1), nil)) // currNonce + txs = append(txs, seqInfo.PrepareTx("Alice", "Owner", seqInfo.TransferGas, big.NewInt(1), nil)) // currNonce + 1 + txs = append(txs, seqInfo.PrepareTx("Alice", "Owner", seqInfo.TransferGas, big.NewInt(1), nil)) // currNonce + 2 + + var wg sync.WaitGroup + wg.Add(2) // We send two txs in out of order + for i := uint64(2); i > 0; i-- { + go func(w *sync.WaitGroup) { + err := expressLaneClient.SendTransactionWithSequence(ctx, txs[i], i) + Require(t, err) + w.Done() + }(&wg) + } + + time.Sleep(time.Second) // Wait for both txs to be submitted + + // Send the first transaction which will unblock the future ones + err = expressLaneClient.SendTransactionWithSequence(ctx, txs[0], 0) // we'll wait for the result + Require(t, err) + + wg.Wait() // Make sure future sequence number txs that were sent earlier did not error + + var txReceipts types.Receipts + for _, tx := range txs { + receipt, err := seqClient.TransactionReceipt(ctx, tx.Hash()) + Require(t, err) + txReceipts = append(txReceipts, receipt) + } + + if !(txReceipts[0].BlockNumber.Cmp(txReceipts[1].BlockNumber) <= 0 && + txReceipts[1].BlockNumber.Cmp(txReceipts[2].BlockNumber) <= 0) { + t.Fatal("incorrect ordering of txs acceptance, lower sequence number txs should appear earlier block") + } + + if txReceipts[0].BlockNumber.Cmp(txReceipts[1].BlockNumber) == 0 && + txReceipts[0].TransactionIndex > txReceipts[1].TransactionIndex { + t.Fatal("incorrect ordering of txs in a block, lower sequence number txs should appear earlier") + } + + if txReceipts[1].BlockNumber.Cmp(txReceipts[2].BlockNumber) == 0 && + txReceipts[1].TransactionIndex > txReceipts[2].TransactionIndex { + t.Fatal("incorrect ordering of txs in a block, lower sequence number txs should appear earlier") + } + + // Test that failed txs are given responses + passTx := seqInfo.PrepareTx("Alice", "Owner", seqInfo.TransferGas, big.NewInt(1), nil) // currNonce + 3 + passTx2 := seqInfo.PrepareTx("Alice", "Owner", seqInfo.TransferGas, big.NewInt(1), nil) // currNonce + 4 + + seqInfo.GetInfoWithPrivKey("Alice").Nonce.Store(20) + failTx := seqInfo.PrepareTx("Alice", "Owner", seqInfo.TransferGas, big.NewInt(1), nil) + failTxDueToTimeout := seqInfo.PrepareTx("Alice", "Owner", seqInfo.TransferGas, big.NewInt(1), nil) + + currSeqNumber := uint64(3) + wg.Add(2) // We send a failing and a passing tx with cummulative future seq numbers, followed by a unblocking seq num tx + var failErr error + go func(w *sync.WaitGroup) { + failErr = expressLaneClient.SendTransactionWithSequence(ctx, failTx, currSeqNumber+1) // Should give out nonce too high error + w.Done() + }(&wg) + + time.Sleep(time.Second) + + go func(w *sync.WaitGroup) { + err := expressLaneClient.SendTransactionWithSequence(ctx, passTx2, currSeqNumber+2) + Require(t, err) + w.Done() + }(&wg) + + err = expressLaneClient.SendTransactionWithSequence(ctx, passTx, currSeqNumber) + Require(t, err) + + wg.Wait() + + checkFailErr := func(reason string) { + if failErr == nil { + t.Fatal("incorrect express lane tx didn't fail upon submission") + } + if !strings.Contains(failErr.Error(), timeboost.ErrAcceptedTxFailed.Error()) || // Should be an ErrAcceptedTxFailed error that would consume sequence number + !strings.Contains(failErr.Error(), reason) { + t.Fatalf("unexpected error string returned: %s", failErr.Error()) + } + } + checkFailErr(core.ErrNonceTooHigh.Error()) + + wg.Add(1) + go func(w *sync.WaitGroup) { + failErr = expressLaneClient.SendTransactionWithSequence(ctx, failTxDueToTimeout, currSeqNumber+4) // Should give out a tx aborted error as this tx is never processed + w.Done() + }(&wg) + wg.Wait() + + checkFailErr("Transaction sequencing hit timeout") +} + +func dbKey(prefix []byte, pos uint64) []byte { + var key []byte + key = append(key, prefix...) + data := make([]byte, 8) + binary.BigEndian.PutUint64(data, pos) + key = append(key, data...) + return key +} + +func TestTimeboostBulkBlockMetadataFetcher(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + builder := NewNodeBuilder(ctx).DefaultConfig(t, true) + httpConfig := genericconf.HTTPConfigDefault + httpConfig.Addr = "127.0.0.1" + httpConfig.Apply(builder.l2StackConfig) + builder.execConfig.BlockMetadataApiCacheSize = 0 // Caching is disabled + builder.nodeConfig.TransactionStreamer.TrackBlockMetadataFrom = 1 + cleanupSeq := builder.Build(t) + defer cleanupSeq() + + // Generate blocks until current block is > 20 + arbDb := builder.L2.ConsensusNode.ArbDB + builder.L2Info.GenerateAccount("User") + user := builder.L2Info.GetDefaultTransactOpts("User", ctx) + var latestL2 uint64 + var err error + var lastTx *types.Transaction + for i := 0; ; i++ { + lastTx, _ = builder.L2.TransferBalanceTo(t, "Owner", util.RemapL1Address(user.From), big.NewInt(1e18), builder.L2Info) + latestL2, err = builder.L2.Client.BlockNumber(ctx) + Require(t, err) + // Clean BlockMetadata from arbDB so that we can modify it at will + Require(t, arbDb.Delete(dbKey([]byte("t"), latestL2))) + if latestL2 > uint64(20) { + break + } + } + var sampleBulkData []common.BlockMetadata + for i := 1; i <= int(latestL2); i++ { + // #nosec G115 + blockMetadata := []byte{0, uint8(i)} + sampleBulkData = append(sampleBulkData, blockMetadata) + // #nosec G115 + Require(t, arbDb.Put(dbKey([]byte("t"), uint64(i)), blockMetadata)) + } + + nodecfg := arbnode.ConfigDefaultL1NonSequencerTest() + trackBlockMetadataFrom := uint64(5) + nodecfg.TransactionStreamer.TrackBlockMetadataFrom = trackBlockMetadataFrom + newNode, cleanupNewNode := builder.Build2ndNode(t, &SecondNodeParams{ + nodeConfig: nodecfg, + stackConfig: testhelpers.CreateStackConfigForTest(t.TempDir()), + }) + defer cleanupNewNode() + + // Wait for second node to catchup via L1, since L1 doesn't have the blockMetadata, we ensure that messages are tracked with missingBlockMetadataInputFeedPrefix prefix + _, err = WaitForTx(ctx, newNode.Client, lastTx.Hash(), time.Second*5) + Require(t, err) + + blockMetadataInputFeedPrefix := []byte("t") + missingBlockMetadataInputFeedPrefix := []byte("x") + arbDb = newNode.ConsensusNode.ArbDB + + // Introduce fragmentation + blocksWithBlockMetadata := []uint64{8, 9, 10, 14, 16} + for _, key := range blocksWithBlockMetadata { + Require(t, arbDb.Put(dbKey([]byte("t"), key), sampleBulkData[key-1])) + Require(t, arbDb.Delete(dbKey([]byte("x"), key))) + } + + // Check if all block numbers with missingBlockMetadataInputFeedPrefix are present as keys in arbDB and that no keys with blockMetadataInputFeedPrefix + iter := arbDb.NewIterator(blockMetadataInputFeedPrefix, nil) + pos := uint64(0) + for iter.Next() { + keyBytes := bytes.TrimPrefix(iter.Key(), blockMetadataInputFeedPrefix) + if binary.BigEndian.Uint64(keyBytes) != blocksWithBlockMetadata[pos] { + t.Fatalf("unexpected presence of blockMetadata, when blocks are synced via L1. msgSeqNum: %d, expectedMsgSeqNum: %d", binary.BigEndian.Uint64(keyBytes), blocksWithBlockMetadata[pos]) + } + pos++ + } + iter.Release() + iter = arbDb.NewIterator(missingBlockMetadataInputFeedPrefix, nil) + pos = trackBlockMetadataFrom + i := 0 + for iter.Next() { + // Blocks with blockMetadata present shouldn't have the missingBlockMetadataInputFeedPrefix keys present in arbDB + for i < len(blocksWithBlockMetadata) && blocksWithBlockMetadata[i] == pos { + i++ + pos++ + } + keyBytes := bytes.TrimPrefix(iter.Key(), missingBlockMetadataInputFeedPrefix) + if binary.BigEndian.Uint64(keyBytes) != pos { + t.Fatalf("unexpected msgSeqNum with missingBlockMetadataInputFeedPrefix for blockMetadata. Want: %d, Got: %d", pos, binary.BigEndian.Uint64(keyBytes)) + } + pos++ + } + if pos-1 != latestL2 { + t.Fatalf("number of keys with missingBlockMetadataInputFeedPrefix doesn't match expected value. Want: %d, Got: %d", latestL2, pos-1) + } + iter.Release() + + // Rebuild blockMetadata and cleanup trackers from ArbDB + blockMetadataFetcher, err := arbnode.NewBlockMetadataFetcher(ctx, arbnode.BlockMetadataFetcherConfig{Source: rpcclient.ClientConfig{URL: builder.L2.Stack.HTTPEndpoint()}}, arbDb, newNode.ExecNode) + Require(t, err) + blockMetadataFetcher.Update(ctx) + + // Check if all blockMetadata was synced from bulk BlockMetadata API via the blockMetadataFetcher and that trackers for missing blockMetadata were cleared + iter = arbDb.NewIterator(blockMetadataInputFeedPrefix, nil) + pos = trackBlockMetadataFrom + for iter.Next() { + keyBytes := bytes.TrimPrefix(iter.Key(), blockMetadataInputFeedPrefix) + if binary.BigEndian.Uint64(keyBytes) != pos { + t.Fatalf("unexpected msgSeqNum with blockMetadataInputFeedPrefix for blockMetadata. Want: %d, Got: %d", pos, binary.BigEndian.Uint64(keyBytes)) + } + if !bytes.Equal(sampleBulkData[pos-1], iter.Value()) { + t.Fatalf("blockMetadata mismatch for blockNumber: %d. Want: %v, Got: %v", pos, sampleBulkData[pos-1], iter.Value()) + } + pos++ + } + if pos-1 != latestL2 { + t.Fatalf("number of keys with blockMetadataInputFeedPrefix doesn't match expected value. Want: %d, Got: %d", latestL2, pos-1) + } + iter.Release() + iter = arbDb.NewIterator(missingBlockMetadataInputFeedPrefix, nil) + for iter.Next() { + keyBytes := bytes.TrimPrefix(iter.Key(), missingBlockMetadataInputFeedPrefix) + t.Fatalf("unexpected presence of msgSeqNum with missingBlockMetadataInputFeedPrefix, indicating missing of some blockMetadata after rebuilding. msgSeqNum: %d", binary.BigEndian.Uint64(keyBytes)) + } + iter.Release() +} + +func TestTimeboostedFieldInReceiptsObject(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + builder := NewNodeBuilder(ctx).DefaultConfig(t, false) + builder.execConfig.BlockMetadataApiCacheSize = 0 // Caching is disabled + cleanup := builder.Build(t) + defer cleanup() + + // Generate blocks until current block is totalBlocks + arbDb := builder.L2.ConsensusNode.ArbDB + blockNum := big.NewInt(2) + builder.L2Info.GenerateAccount("User") + user := builder.L2Info.GetDefaultTransactOpts("User", ctx) + var latestL2 uint64 + var err error + for i := 0; ; i++ { + builder.L2.TransferBalanceTo(t, "Owner", util.RemapL1Address(user.From), big.NewInt(1e18), builder.L2Info) + latestL2, err = builder.L2.Client.BlockNumber(ctx) + Require(t, err) + if latestL2 >= blockNum.Uint64() { + break + } + } + + for i := uint64(1); i < latestL2; i++ { + // Clean BlockMetadata from arbDB so that we can modify it at will + Require(t, arbDb.Delete(dbKey([]byte("t"), i))) + } + + block, err := builder.L2.Client.BlockByNumber(ctx, blockNum) + Require(t, err) + if len(block.Transactions()) != 2 { + t.Fatalf("expecting two txs in the second block, but found: %d txs", len(block.Transactions())) + } + + // Set first tx (internal tx anyway) to not timeboosted and Second one to timeboosted- BlockMetadata (in bits)-> 00000000 00000010 + Require(t, arbDb.Put(dbKey([]byte("t"), blockNum.Uint64()), []byte{0, 2})) + l2rpc := builder.L2.Stack.Attach() + // Extra timeboosted field in pointer form to check for its existence + type timeboostedFromReceipt struct { + Timeboosted *bool `json:"timeboosted"` + } + var receiptResult []timeboostedFromReceipt + err = l2rpc.CallContext(ctx, &receiptResult, "eth_getBlockReceipts", rpc.BlockNumber(blockNum.Int64())) + Require(t, err) + if receiptResult[0].Timeboosted == nil || receiptResult[1].Timeboosted == nil { + t.Fatal("timeboosted field should exist in the receipt object of both- first and second txs") + } + if *receiptResult[0].Timeboosted != false { + t.Fatal("first tx was not timeboosted, but the field indicates otherwise") + } + if *receiptResult[1].Timeboosted != true { + t.Fatal("second tx was timeboosted, but the field indicates otherwise") + } + + // Check that timeboosted is accurate for eth_getTransactionReceipt as well + var txReceipt timeboostedFromReceipt + err = l2rpc.CallContext(ctx, &txReceipt, "eth_getTransactionReceipt", block.Transactions()[0].Hash()) + Require(t, err) + if txReceipt.Timeboosted == nil { + t.Fatal("timeboosted field should exist in the receipt object of first tx") + } + if *txReceipt.Timeboosted != false { + t.Fatal("first tx was not timeboosted, but the field indicates otherwise") + } + err = l2rpc.CallContext(ctx, &txReceipt, "eth_getTransactionReceipt", block.Transactions()[1].Hash()) + Require(t, err) + if txReceipt.Timeboosted == nil { + t.Fatal("timeboosted field should exist in the receipt object of second tx") + } + if *txReceipt.Timeboosted != true { + t.Fatal("second tx was timeboosted, but the field indicates otherwise") + } + + // Check that timeboosted field shouldn't exist for any txs of block=1, as this block doesn't have blockMetadata + block, err = builder.L2.Client.BlockByNumber(ctx, common.Big1) + Require(t, err) + if len(block.Transactions()) != 2 { + t.Fatalf("expecting two txs in the first block, but found: %d txs", len(block.Transactions())) + } + var receiptResult2 []timeboostedFromReceipt + err = l2rpc.CallContext(ctx, &receiptResult2, "eth_getBlockReceipts", rpc.BlockNumber(1)) + Require(t, err) + if receiptResult2[0].Timeboosted != nil || receiptResult2[1].Timeboosted != nil { + t.Fatal("timeboosted field shouldn't exist in the receipt object of all the txs") + } + var txReceipt2 timeboostedFromReceipt + err = l2rpc.CallContext(ctx, &txReceipt2, "eth_getTransactionReceipt", block.Transactions()[0].Hash()) + Require(t, err) + if txReceipt2.Timeboosted != nil { + t.Fatal("timeboosted field shouldn't exist in the receipt object of all the txs") + } + var txReceipt3 timeboostedFromReceipt + err = l2rpc.CallContext(ctx, &txReceipt3, "eth_getTransactionReceipt", block.Transactions()[1].Hash()) + Require(t, err) + if txReceipt3.Timeboosted != nil { + t.Fatal("timeboosted field shouldn't exist in the receipt object of all the txs") + } + + // Print the receipt object for reference + var receiptResultRaw json.RawMessage + err = l2rpc.CallContext(ctx, &receiptResultRaw, "eth_getBlockReceipts", rpc.BlockNumber(blockNum.Int64())) + Require(t, err) + colors.PrintGrey("receipt object- ", string(receiptResultRaw)) + +} + +func TestTimeboostBulkBlockMetadataAPI(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + builder := NewNodeBuilder(ctx).DefaultConfig(t, false) + builder.execConfig.BlockMetadataApiCacheSize = 0 // Caching is disabled + cleanup := builder.Build(t) + defer cleanup() + + arbDb := builder.L2.ConsensusNode.ArbDB + + // Generate blocks until current block is end + start := 1 + end := 20 + builder.L2Info.GenerateAccount("User") + user := builder.L2Info.GetDefaultTransactOpts("User", ctx) + for i := 0; ; i++ { + builder.L2.TransferBalanceTo(t, "Owner", util.RemapL1Address(user.From), big.NewInt(1e18), builder.L2Info) + latestL2, err := builder.L2.Client.BlockNumber(ctx) + Require(t, err) + // Clean BlockMetadata from arbDB so that we can modify it at will + Require(t, arbDb.Delete(dbKey([]byte("t"), latestL2))) + // #nosec G115 + if latestL2 > uint64(end)+10 { + break + } + } + var sampleBulkData []gethexec.NumberAndBlockMetadata + for i := start; i <= end; i += 2 { + sampleData := gethexec.NumberAndBlockMetadata{ + // #nosec G115 + BlockNumber: uint64(i), + // #nosec G115 + RawMetadata: []byte{0, uint8(i)}, + } + sampleBulkData = append(sampleBulkData, sampleData) + Require(t, arbDb.Put(dbKey([]byte("t"), sampleData.BlockNumber), sampleData.RawMetadata)) + } + + l2rpc := builder.L2.Stack.Attach() + var result []gethexec.NumberAndBlockMetadata + err := l2rpc.CallContext(ctx, &result, "arb_getRawBlockMetadata", rpc.BlockNumber(start), "latest") // Test rpc.BlockNumber feature, send "latest" as an arg instead of blockNumber + Require(t, err) + + if len(result) != len(sampleBulkData) { + t.Fatalf("number of entries in arb_getRawBlockMetadata is incorrect. Got: %d, Want: %d", len(result), len(sampleBulkData)) + } + for i, data := range result { + if data.BlockNumber != sampleBulkData[i].BlockNumber { + t.Fatalf("BlockNumber mismatch. Got: %d, Want: %d", data.BlockNumber, sampleBulkData[i].BlockNumber) + } + if !bytes.Equal(data.RawMetadata, sampleBulkData[i].RawMetadata) { + t.Fatalf("RawMetadata. Got: %s, Want: %s", data.RawMetadata, sampleBulkData[i].RawMetadata) + } + } + + // Test that without cache the result returned is always in sync with ArbDB + sampleBulkData[0].RawMetadata = []byte{1, 11} + Require(t, arbDb.Put(dbKey([]byte("t"), 1), sampleBulkData[0].RawMetadata)) + + err = l2rpc.CallContext(ctx, &result, "arb_getRawBlockMetadata", rpc.BlockNumber(1), rpc.BlockNumber(1)) + Require(t, err) + if len(result) != 1 { + t.Fatal("result returned with more than one entry") + } + if !bytes.Equal(sampleBulkData[0].RawMetadata, result[0].RawMetadata) { + t.Fatal("BlockMetadata gotten from API doesn't match the latest entry in ArbDB") + } + + // Test that LRU caching works + builder.execConfig.BlockMetadataApiCacheSize = 1000 + builder.execConfig.BlockMetadataApiBlocksLimit = 25 + builder.RestartL2Node(t) + l2rpc = builder.L2.Stack.Attach() + err = l2rpc.CallContext(ctx, &result, "arb_getRawBlockMetadata", rpc.BlockNumber(start), rpc.BlockNumber(end)) + Require(t, err) + + arbDb = builder.L2.ConsensusNode.ArbDB + updatedBlockMetadata := []byte{2, 12} + Require(t, arbDb.Put(dbKey([]byte("t"), 1), updatedBlockMetadata)) + + err = l2rpc.CallContext(ctx, &result, "arb_getRawBlockMetadata", rpc.BlockNumber(1), rpc.BlockNumber(1)) + Require(t, err) + if len(result) != 1 { + t.Fatal("result returned with more than one entry") + } + if bytes.Equal(updatedBlockMetadata, result[0].RawMetadata) { + t.Fatal("BlockMetadata should've been fetched from cache and not the db") + } + if !bytes.Equal(sampleBulkData[0].RawMetadata, result[0].RawMetadata) { + t.Fatal("incorrect caching of BlockMetadata") + } + + // Test that ErrBlockMetadataApiBlocksLimitExceeded is thrown when query range exceeds the limit + err = l2rpc.CallContext(ctx, &result, "arb_getRawBlockMetadata", rpc.BlockNumber(start), rpc.BlockNumber(26)) + if !strings.Contains(err.Error(), gethexec.ErrBlockMetadataApiBlocksLimitExceeded.Error()) { + t.Fatalf("expecting ErrBlockMetadataApiBlocksLimitExceeded error, got: %v", err) + } + + // A Reorg event should clear the cache, hence the data fetched now should be accurate + Require(t, builder.L2.ConsensusNode.TxStreamer.ReorgTo(10)) + err = l2rpc.CallContext(ctx, &result, "arb_getRawBlockMetadata", rpc.BlockNumber(start), rpc.BlockNumber(end)) + Require(t, err) + if !bytes.Equal(updatedBlockMetadata, result[0].RawMetadata) { + t.Fatal("BlockMetadata should've been fetched from db and not the cache") + } +} + +// func TestExpressLaneControlTransfer(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, aliceBidderClient, bobBidderClient, roundDuration, cleanupSeq, _, _ := setupExpressLaneAuction(t, tmpDir, ctx, jwtSecretPath, 0) +// defer cleanupSeq() + +// auctionContract, err := express_lane_auctiongen.NewExpressLaneAuction(auctionContractAddr, seqClient) +// Require(t, err) +// rawRoundTimingInfo, err := auctionContract.RoundTimingInfo(&bind.CallOpts{}) +// Require(t, err) +// roundTimingInfo, err := timeboost.NewRoundTimingInfo(rawRoundTimingInfo) +// Require(t, err) + +// // Prepare clients that can submit txs to the sequencer via the express lane. +// chainId, err := seqClient.ChainID(ctx) +// Require(t, err) +// seqDial, err := rpc.Dial(seq.Stack.HTTPEndpoint()) +// Require(t, err) +// createExpressLaneClientFor := func(name string) (*expressLaneClient, bind.TransactOpts) { +// priv := seqInfo.Accounts[name].PrivateKey +// expressLaneClient := newExpressLaneClient( +// priv, +// chainId, +// *roundTimingInfo, +// auctionContractAddr, +// seqDial, +// ) +// expressLaneClient.Start(ctx) +// transacOpts := seqInfo.GetDefaultTransactOpts(name, ctx) +// transacOpts.NoSend = true +// return expressLaneClient, transacOpts +// } +// bobExpressLaneClient, bobOpts := createExpressLaneClientFor("Bob") +// aliceExpressLaneClient, aliceOpts := createExpressLaneClientFor("Alice") + +// // Bob will win the auction and become controller for next round +// placeBidsAndDecideWinner(t, ctx, seqClient, seqInfo, auctionContract, "Bob", "Alice", bobBidderClient, aliceBidderClient, roundDuration) +// time.Sleep(roundTimingInfo.TimeTilNextRound()) + +// // Check that Bob's tx gets priority since he's the controller +// verifyControllerAdvantage(t, ctx, seqClient, bobExpressLaneClient, seqInfo, "Bob", "Alice") + +// // Transfer express lane control from Bob to Alice +// currRound := roundTimingInfo.RoundNumber() +// duringRoundTransferTx, err := auctionContract.ExpressLaneAuctionTransactor.TransferExpressLaneController(&bobOpts, currRound, seqInfo.Accounts["Alice"].Address) +// Require(t, err) +// err = bobExpressLaneClient.SendTransaction(ctx, duringRoundTransferTx) +// Require(t, err) + +// time.Sleep(time.Second) // Wait for controller to change on the sequencer side +// // Check that now Alice's tx gets priority since she's the controller after bob transfered it +// verifyControllerAdvantage(t, ctx, seqClient, aliceExpressLaneClient, seqInfo, "Alice", "Bob") + +// // Alice and Bob submit bids and Alice wins for the next round +// placeBidsAndDecideWinner(t, ctx, seqClient, seqInfo, auctionContract, "Alice", "Bob", aliceBidderClient, bobBidderClient, roundDuration) +// t.Log("Alice won the express lane auction for upcoming round, now try to transfer control before the next round begins...") + +// // Alice now transfers control to bob before her round begins +// winnerRound := currRound + 1 +// currRound = roundTimingInfo.RoundNumber() +// if currRound >= winnerRound { +// t.Fatalf("next round already began, try running the test again. Current round: %d, Winner Round: %d", currRound, winnerRound) +// } + +// beforeRoundTransferTx, err := auctionContract.ExpressLaneAuctionTransactor.TransferExpressLaneController(&aliceOpts, winnerRound, seqInfo.Accounts["Bob"].Address) +// Require(t, err) +// err = aliceExpressLaneClient.SendTransaction(ctx, beforeRoundTransferTx) +// Require(t, err) + +// setExpressLaneIterator, err := auctionContract.FilterSetExpressLaneController(&bind.FilterOpts{Context: ctx}, nil, nil, nil) +// Require(t, err) +// verifyControllerChange := func(round uint64, prev, new common.Address) { +// setExpressLaneIterator.Next() +// if setExpressLaneIterator.Event.Round != round { +// t.Fatalf("unexpected round number. Want: %d, Got: %d", round, setExpressLaneIterator.Event.Round) +// } +// if setExpressLaneIterator.Event.PreviousExpressLaneController != prev { +// t.Fatalf("unexpected previous express lane controller. Want: %v, Got: %v", prev, setExpressLaneIterator.Event.PreviousExpressLaneController) +// } +// if setExpressLaneIterator.Event.NewExpressLaneController != new { +// t.Fatalf("unexpected new express lane controller. Want: %v, Got: %v", new, setExpressLaneIterator.Event.NewExpressLaneController) +// } +// } +// // Verify during round control change +// verifyControllerChange(currRound, common.Address{}, bobOpts.From) // Bob wins auction +// verifyControllerChange(currRound, bobOpts.From, aliceOpts.From) // Bob transfers control to Alice +// // Verify before round control change +// verifyControllerChange(winnerRound, common.Address{}, aliceOpts.From) // Alice wins auction +// verifyControllerChange(winnerRound, aliceOpts.From, bobOpts.From) // Alice transfers control to Bob before the round begins +// } + +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, aliceBidderClient, bobBidderClient, roundDuration, cleanupSeq, _, _ := setupExpressLaneAuction(t, tmpDir, ctx, jwtSecretPath, 0) + defer cleanupSeq() + + auctionContract, err := express_lane_auctiongen.NewExpressLaneAuction(auctionContractAddr, seqClient) + Require(t, err) + rawRoundTimingInfo, err := auctionContract.RoundTimingInfo(&bind.CallOpts{}) + Require(t, err) + roundTimingInfo, err := timeboost.NewRoundTimingInfo(rawRoundTimingInfo) + Require(t, err) + + placeBidsAndDecideWinner(t, ctx, seqClient, seqInfo, auctionContract, "Bob", "Alice", bobBidderClient, aliceBidderClient, roundDuration) + time.Sleep(roundTimingInfo.TimeTilNextRound()) + + chainId, err := seqClient.ChainID(ctx) + Require(t, err) + + // Prepare a client that can submit txs to the sequencer via the express lane. + bobPriv := seqInfo.Accounts["Bob"].PrivateKey + seqDial, err := rpc.Dial(seq.Stack.HTTPEndpoint()) + Require(t, err) + expressLaneClient := newExpressLaneClient( + bobPriv, + chainId, + *roundTimingInfo, + auctionContractAddr, + seqDial, + ) + expressLaneClient.Start(ctx) + + verifyControllerAdvantage(t, ctx, seqClient, expressLaneClient, seqInfo, "Bob", "Alice") +} + +func TestSequencerFeed_ExpressLaneAuction_InnerPayloadNoncesAreRespected_TimeboostedFieldIsCorrect(t *testing.T) { + t.Parallel() + + logHandler := testhelpers.InitTestLog(t, log.LevelInfo) + 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, aliceBidderClient, bobBidderClient, roundDuration, cleanupSeq, feedListener, cleanupFeedListener := setupExpressLaneAuction(t, tmpDir, ctx, jwtSecretPath, withFeedListener) + defer cleanupSeq() + defer cleanupFeedListener() + + auctionContract, err := express_lane_auctiongen.NewExpressLaneAuction(auctionContractAddr, seqClient) + Require(t, err) + rawRoundTimingInfo, err := auctionContract.RoundTimingInfo(&bind.CallOpts{}) + Require(t, err) + roundTimingInfo, err := timeboost.NewRoundTimingInfo(rawRoundTimingInfo) + Require(t, err) + + Require(t, err) + + placeBidsAndDecideWinner(t, ctx, seqClient, seqInfo, auctionContract, "Bob", "Alice", bobBidderClient, aliceBidderClient, roundDuration) + time.Sleep(roundTimingInfo.TimeTilNextRound()) + + // Prepare a client that can submit txs to the sequencer via the express lane. + bobPriv := seqInfo.Accounts["Bob"].PrivateKey + chainId, err := seqClient.ChainID(ctx) + Require(t, err) + seqDial, err := rpc.Dial(seq.Stack.HTTPEndpoint()) + Require(t, err) + expressLaneClient := newExpressLaneClient( + bobPriv, + chainId, + *roundTimingInfo, + 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") + aliceNonce, err := seqClient.PendingNonceAt(ctx, seqInfo.GetAddress("Alice")) + Require(t, err) + aliceData := &types.DynamicFeeTx{ + To: &ownerAddr, + Gas: seqInfo.TransferGas, + GasFeeCap: new(big.Int).Set(seqInfo.GasPrice), + Value: big.NewInt(1e12), + Nonce: aliceNonce, + 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") + } + if !strings.Contains(err2.Error(), timeboost.ErrAcceptedTxFailed.Error()) { + t.Fatal("Charlie's first tx should've consumed a sequence number as it was initially accepted") + } + // 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") + } + } + + // First test that timeboosted byte array is correct on sequencer side + verifyTimeboostedCorrectness(t, ctx, "Alice", seq, seqClient, false, aliceTx, aliceBlock) + verifyTimeboostedCorrectness(t, ctx, "Charlie", seq, seqClient, true, charlie0, charlieBlock) + + // Verify that timeboosted byte array receieved via sequencer feed is correct + _, err = WaitForTx(ctx, feedListener.Client, charlie0.Hash(), time.Second*5) + Require(t, err) + _, err = WaitForTx(ctx, feedListener.Client, aliceTx.Hash(), time.Second*5) + Require(t, err) + verifyTimeboostedCorrectness(t, ctx, "Alice", feedListener.ConsensusNode, feedListener.Client, false, aliceTx, aliceBlock) + verifyTimeboostedCorrectness(t, ctx, "Charlie", feedListener.ConsensusNode, feedListener.Client, true, charlie0, charlieBlock) + + if logHandler.WasLogged(arbnode.BlockHashMismatchLogMsg) { + t.Fatal("BlockHashMismatchLogMsg was logged unexpectedly") + } +} + +// verifyTimeboostedCorrectness is used to check if the timeboosted byte array in both the sequencer's tx streamer and the client node's tx streamer (which is connected +// to the sequencer feed) is accurate, i.e it represents correctly whether a tx is timeboosted or not +func verifyTimeboostedCorrectness(t *testing.T, ctx context.Context, user string, tNode *arbnode.Node, tClient *ethclient.Client, isTimeboosted bool, userTx *types.Transaction, userTxBlockNum uint64) { + blockMetadataOfBlock, err := tNode.TxStreamer.BlockMetadataAtCount(arbutil.MessageIndex(userTxBlockNum) + 1) + Require(t, err) + if len(blockMetadataOfBlock) == 0 { + t.Fatal("got empty blockMetadata byte array") + } + if blockMetadataOfBlock[0] != message.TimeboostedVersion { + t.Fatalf("blockMetadata byte array has invalid version. Want: %d, Got: %d", message.TimeboostedVersion, blockMetadataOfBlock[0]) + } + userTxBlock, err := tClient.BlockByNumber(ctx, new(big.Int).SetUint64(userTxBlockNum)) + Require(t, err) + var foundUserTx bool + for txIndex, tx := range userTxBlock.Transactions() { + got, err := blockMetadataOfBlock.IsTxTimeboosted(txIndex) + Require(t, err) + if tx.Hash() == userTx.Hash() { + foundUserTx = true + if !isTimeboosted && got { + t.Fatalf("incorrect timeboosted bit for %s's tx, it shouldn't be timeboosted", user) + } else if isTimeboosted && !got { + t.Fatalf("incorrect timeboosted bit for %s's tx, it should be timeboosted", user) + } + } else if got { + // Other tx's right now shouln't be timeboosted + t.Fatalf("incorrect timeboosted bit for nonspecified tx with index: %d, it shouldn't be timeboosted", txIndex) + } + } + if !foundUserTx { + t.Fatalf("%s's tx wasn't found in the block with blockNum retrieved from its receipt", user) + } +} + +func placeBidsAndDecideWinner(t *testing.T, ctx context.Context, seqClient *ethclient.Client, seqInfo *BlockchainTestInfo, auctionContract *express_lane_auctiongen.ExpressLaneAuction, winner, loser string, winnerBidderClient, loserBidderClient *timeboost.BidderClient, roundDuration time.Duration) { + t.Helper() + + rawRoundTimingInfo, err := auctionContract.RoundTimingInfo(&bind.CallOpts{}) + Require(t, err) + roundTimingInfo, err := timeboost.NewRoundTimingInfo(rawRoundTimingInfo) + Require(t, err) + currRound := roundTimingInfo.RoundNumber() + + // We are now in the bidding round, both issue their bids. winner will win + t.Logf("%s and %s now submitting their bids at %v", winner, loser, time.Now()) + winnerBid, err := winnerBidderClient.Bid(ctx, big.NewInt(2), seqInfo.GetAddress(winner)) + Require(t, err) + loserBid, err := loserBidderClient.Bid(ctx, big.NewInt(1), seqInfo.GetAddress(loser)) + Require(t, err) + t.Logf("%s bid %+v", winner, winnerBid) + t.Logf("%s bid %+v", loser, loserBid) + + // Subscribe to auction resolutions and wait for a winner + winnerAddr, winnerRound := awaitAuctionResolved(t, ctx, seqClient, auctionContract) + + // Verify winner wins the auction + if winnerAddr != seqInfo.GetAddress(winner) { + t.Fatalf("%s should have won the express lane auction", winner) + } + t.Logf("%s won the auction for the round: %d", winner, winnerRound) + if winnerRound != currRound+1 { + t.Fatalf("unexpected winner round: Want:%d Got:%d", currRound+1, winnerRound) + } + + it, err := auctionContract.FilterAuctionResolved(&bind.FilterOpts{Context: ctx}, nil, nil, nil) + Require(t, err) + winnerWon := false + for it.Next() { + if it.Event.FirstPriceBidder == seqInfo.GetAddress(winner) && it.Event.Round == winnerRound { + winnerWon = true + } + } + if !winnerWon { + t.Fatalf("%s should have won the auction", winner) + } +} + +func verifyControllerAdvantage(t *testing.T, ctx context.Context, seqClient *ethclient.Client, controllerClient *expressLaneClient, seqInfo *BlockchainTestInfo, controller, otherUser string) { + t.Helper() + + // During the express lane around, controller sends txs always 150ms later than otherUser, but otherUser's + // txs end up getting delayed by 200ms as they are not the express lane controller. + // In the end, controller's txs should be ordered before otherUser's during the round. + var wg sync.WaitGroup + wg.Add(2) + ownerAddr := seqInfo.GetAddress("Owner") + + otherUserNonce, err := seqClient.PendingNonceAt(ctx, seqInfo.GetAddress(otherUser)) + Require(t, err) + otherUserData := &types.DynamicFeeTx{ + To: &ownerAddr, + Gas: seqInfo.TransferGas, + GasFeeCap: new(big.Int).Set(seqInfo.GasPrice), + Value: big.NewInt(1e12), + Nonce: otherUserNonce, + Data: nil, + } + otherUserTx := seqInfo.SignTxAs(otherUser, otherUserData) + go func(w *sync.WaitGroup) { + defer w.Done() + Require(t, seqClient.SendTransaction(ctx, otherUserTx)) + }(&wg) + + controllerNonce, err := seqClient.PendingNonceAt(ctx, seqInfo.GetAddress(controller)) + Require(t, err) + controllerData := &types.DynamicFeeTx{ + To: &ownerAddr, + Gas: seqInfo.TransferGas, + GasFeeCap: new(big.Int).Set(seqInfo.GasPrice), + Value: big.NewInt(1e12), + Nonce: controllerNonce, + Data: nil, + } + controllerBoostableTx := seqInfo.SignTxAs(controller, controllerData) + go func(w *sync.WaitGroup) { + defer w.Done() + time.Sleep(time.Millisecond * 10) + Require(t, controllerClient.SendTransaction(ctx, controllerBoostableTx)) + }(&wg) + wg.Wait() + + // After round is done, verify that controller beats otherUser in the final sequence. + otherUserTxReceipt, err := seqClient.TransactionReceipt(ctx, otherUserTx.Hash()) + Require(t, err) + otherUserBlock := otherUserTxReceipt.BlockNumber.Uint64() + controllerBoostableTxReceipt, err := seqClient.TransactionReceipt(ctx, controllerBoostableTx.Hash()) + Require(t, err) + controllerBlock := controllerBoostableTxReceipt.BlockNumber.Uint64() + + if otherUserBlock < controllerBlock { + t.Fatalf("%s's tx should not have been sequenced before %s's in different blocks", otherUser, controller) + } else if otherUserBlock == controllerBlock { + if otherUserTxReceipt.TransactionIndex < controllerBoostableTxReceipt.TransactionIndex { + t.Fatalf("%s should have been sequenced before %s with express lane", controller, otherUser) + } + } +} + +type extraNodeType int + +const ( + withForwardingSeq extraNodeType = iota + 1 + withFeedListener +) + +func setupExpressLaneAuction( + t *testing.T, + dbDirPath string, + ctx context.Context, + jwtSecretPath string, + extraNodeTy extraNodeType, +) (*arbnode.Node, *ethclient.Client, *BlockchainTestInfo, common.Address, *timeboost.BidderClient, *timeboost.BidderClient, time.Duration, func(), *TestClient, 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, + } + builderSeq.nodeConfig.TransactionStreamer.TrackBlockMetadataFrom = 1 + cleanupSeq := builderSeq.Build(t) + seqInfo, seqNode, seqClient := builderSeq.L2Info, builderSeq.L2.ConsensusNode, builderSeq.L2.Client + + var extraNode *TestClient + var cleanupExtraNode func() + switch extraNodeTy { + case withForwardingSeq: + forwarderNodeCfg := arbnode.ConfigDefaultL1Test() + forwarderNodeCfg.BatchPoster.Enable = false + builderSeq.l2StackConfig.HTTPPort = getRandomPort(t) + builderSeq.l2StackConfig.AuthPort = getRandomPort(t) + builderSeq.l2StackConfig.JWTSecret = jwtSecretPath + extraNode, cleanupExtraNode = builderSeq.Build2ndNode(t, &SecondNodeParams{nodeConfig: forwarderNodeCfg}) + Require(t, extraNode.ExecNode.ForwardTo(seqNode.Stack.HTTPEndpoint())) + case withFeedListener: + tcpAddr, ok := seqNode.BroadcastServer.ListenerAddr().(*net.TCPAddr) + if !ok { + t.Fatalf("failed to cast listener address to *net.TCPAddr") + } + port := tcpAddr.Port + nodeConfig := arbnode.ConfigDefaultL1NonSequencerTest() + nodeConfig.Feed.Input = *newBroadcastClientConfigTest(port) + nodeConfig.Feed.Input.Timeout = broadcastclient.DefaultConfig.Timeout + extraNode, cleanupExtraNode = builderSeq.Build2ndNode(t, &SecondNodeParams{nodeConfig: nodeConfig, stackConfig: testhelpers.CreateStackConfigForTest(t.TempDir())}) + } + + // 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 + err = builderSeq.L2.ExecNode.Sequencer.InitializeExpressLaneService(builderSeq.L2.ExecNode.Backend.APIBackend(), builderSeq.L2.ExecNode.FilterSystem, proxyAddr, seqInfo.GetAddress("AuctionContract"), gethexec.DefaultTimeboostConfig.EarlySubmissionGrace) + Require(t, err) + builderSeq.L2.ExecNode.Sequencer.StartExpressLaneService(ctx) + 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. + 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(30))) + Require(t, bob.Deposit(ctx, big.NewInt(30))) + + // Wait until the next timeboost round + a few milliseconds. + t.Logf("Alice and Bob are now deposited into the autonomous auction contract, waiting %v for bidding round..., timestamp %v", waitTime, time.Now()) + rawRoundTimingInfo, err := auctionContract.RoundTimingInfo(&bind.CallOpts{}) + Require(t, err) + roundTimingInfo, err := timeboost.NewRoundTimingInfo(rawRoundTimingInfo) + Require(t, err) + time.Sleep(roundTimingInfo.TimeTilNextRound()) + t.Logf("Reached the bidding round at %v", time.Now()) + time.Sleep(time.Second * 5) + return seqNode, seqClient, seqInfo, proxyAddr, alice, bob, roundDuration, cleanupSeq, extraNode, cleanupExtraNode +} + +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 + roundTimingInfo timeboost.RoundTimingInfo + auctionContractAddr common.Address + client *rpc.Client + sequence uint64 +} + +func newExpressLaneClient( + privKey *ecdsa.PrivateKey, + chainId *big.Int, + roundTimingInfo timeboost.RoundTimingInfo, + auctionContractAddr common.Address, + client *rpc.Client, +) *expressLaneClient { + return &expressLaneClient{ + privKey: privKey, + chainId: chainId, + roundTimingInfo: roundTimingInfo, + auctionContractAddr: auctionContractAddr, + client: client, + sequence: 0, + } +} + +func (elc *expressLaneClient) Start(ctxIn context.Context) { + elc.StopWaiter.Start(ctxIn, elc) +} + +func (elc *expressLaneClient) SendTransactionWithSequence(ctx context.Context, transaction *types.Transaction, seq uint64) error { + encodedTx, err := transaction.MarshalBinary() + if err != nil { + return err + } + msg := &timeboost.JsonExpressLaneSubmission{ + ChainId: (*hexutil.Big)(elc.chainId), + Round: hexutil.Uint64(elc.roundTimingInfo.RoundNumber()), + AuctionContractAddress: elc.auctionContractAddr, + Transaction: encodedTx, + SequenceNumber: hexutil.Uint64(seq), + 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 + } + return nil +} + +func (elc *expressLaneClient) SendTransaction(ctx context.Context, transaction *types.Transaction) error { + elc.Lock() + defer elc.Unlock() + err := elc.SendTransactionWithSequence(ctx, transaction, elc.sequence) + if err == nil || strings.Contains(err.Error(), timeboost.ErrAcceptedTxFailed.Error()) { + elc.sequence += 1 + } + return err +} + +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() + tcpAddr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("failed to cast listener address to *net.TCPAddr") + } + return tcpAddr.Port +} diff --git a/timeboost/auctioneer.go b/timeboost/auctioneer.go new file mode 100644 index 0000000000..5c40505a6b --- /dev/null +++ b/timeboost/auctioneer.go @@ -0,0 +1,480 @@ +// 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/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"` + S3Storage S3StorageServiceConfig `koanf:"s3-storage"` +} + +var DefaultAuctioneerServerConfig = AuctioneerServerConfig{ + Enable: true, + RedisURL: "", + ConsumerConfig: pubsub.DefaultConsumerConfig, + StreamTimeout: 10 * time.Minute, + AuctionResolutionWaitTime: 2 * time.Second, + S3Storage: DefaultS3StorageServiceConfig, +} + +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") + S3StorageServiceConfigAddOptions(prefix+".s3-storage", f) +} + +// 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 + roundTimingInfo RoundTimingInfo + streamTimeout time.Duration + auctionResolutionWaitTime time.Duration + database *SqliteDatabase + s3StorageService *S3StorageService +} + +// 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 + } + var s3StorageService *S3StorageService + if cfg.S3Storage.Enable { + s3StorageService, err = NewS3StorageService(&cfg.S3Storage, database) + 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 + } + rawRoundTimingInfo, err := auctionContract.RoundTimingInfo(&bind.CallOpts{}) + if err != nil { + return nil, err + } + roundTimingInfo, err := NewRoundTimingInfo(rawRoundTimingInfo) + if err != nil { + return nil, err + } + if err = roundTimingInfo.ValidateResolutionWaitTime(cfg.AuctionResolutionWaitTime); err != nil { + return nil, err + } + return &AuctioneerServer{ + txOpts: txOpts, + sequencerRpc: client, + chainId: chainId, + client: sequencerClient, + database: database, + s3StorageService: s3StorageService, + consumer: c, + auctionContract: auctionContract, + auctionContractAddr: auctionContractAddr, + bidsReceiver: make(chan *JsonValidatedBid, 100_000), // TODO(Terence): Is 100k enough? Make this configurable? + bidCache: newBidCache(), + roundTimingInfo: *roundTimingInfo, + auctionResolutionWaitTime: cfg.AuctionResolutionWaitTime, + }, nil +} + +func (a *AuctioneerServer) Start(ctx_in context.Context) { + a.StopWaiter.Start(ctx_in, a) + // Start S3 storage service to persist validated bids to s3 + if a.s3StorageService != nil { + a.s3StorageService.Start(ctx_in) + } + // 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 + } + req.Ack() + 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 := newRoundTicker(a.roundTimingInfo) + go ticker.tickAtAuctionClose() + 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 := a.roundTimingInfo.RoundNumber() + 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 + } + + roundEndTime := a.roundTimingInfo.TimeOfNextRound() + 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..855ec53687 --- /dev/null +++ b/timeboost/auctioneer_test.go @@ -0,0 +1,228 @@ +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. + am.bidCache.Lock() + require.Equal(t, 3, len(am.bidCache.bidsByExpressLaneControllerAddr)) + am.bidCache.Unlock() + 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..3c0a31e553 --- /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..b28d69dd1c --- /dev/null +++ b/timeboost/bid_cache_test.go @@ -0,0 +1,218 @@ +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() + tcpAddr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("failed to cast listener address to *net.TCPAddr") + } + return tcpAddr.Port +} diff --git a/timeboost/bid_validator.go b/timeboost/bid_validator.go new file mode 100644 index 0000000000..fda5bcf58d --- /dev/null +++ b/timeboost/bid_validator.go @@ -0,0 +1,371 @@ +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/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 + auctionContractDomainSeparator [32]byte + bidsReceiver chan *Bid + roundTimingInfo RoundTimingInfo + 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 + } + rawRoundTimingInfo, err := auctionContract.RoundTimingInfo(&bind.CallOpts{}) + if err != nil { + return nil, err + } + roundTimingInfo, err := NewRoundTimingInfo(rawRoundTimingInfo) + if err != nil { + return nil, err + } + + reservePrice, err := auctionContract.ReservePrice(&bind.CallOpts{}) + if err != nil { + return nil, err + } + + domainSeparator, err := auctionContract.DomainSeparator(&bind.CallOpts{ + Context: ctx, + }) + if err != nil { + return nil, err + } + + bidValidator := &BidValidator{ + chainId: chainId, + client: sequencerClient, + redisClient: redisClient, + stack: stack, + auctionContract: auctionContract, + auctionContractAddr: auctionContractAddr, + auctionContractDomainSeparator: domainSeparator, + bidsReceiver: make(chan *Bid, 10_000), + roundTimingInfo: *roundTimingInfo, + 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) + + // Thread to set reserve price and clear per-round map of bid count per account. + bv.StopWaiter.LaunchThread(func(ctx context.Context) { + reservePriceTicker := newRoundTicker(bv.roundTimingInfo) + go reservePriceTicker.tickAtReserveSubmissionDeadline() + auctionCloseTicker := newRoundTicker(bv.roundTimingInfo) + go auctionCloseTicker.tickAtAuctionClose() + + for { + select { + case <-ctx.Done(): + log.Error("Context closed, autonomous auctioneer shutting down") + return + case <-reservePriceTicker.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) + + case <-auctionCloseTicker.c: + 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 := bv.roundTimingInfo.RoundNumber() + 1 + if bid.Round != upcomingRound { + return nil, errors.Wrapf(ErrBadRoundNumber, "wanted %d, got %d", upcomingRound, bid.Round) + } + + // Check if the auction is closed. + if bv.roundTimingInfo.isAuctionRoundClosed() { + 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. + 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 + } + + bidHash, err := bid.ToEIP712Hash(bv.auctionContractDomainSeparator) + if err != nil { + return nil, err + } + pubkey, err := crypto.SigToPub(bidHash[:], 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, errors.Wrapf(ErrNotDepositor, "bidder %s", bidder.Hex()) + } + if depositBal.Cmp(bid.Amount) < 0 { + return nil, errors.Wrapf(ErrInsufficientBalance, "bidder %s, onchain balance %#x, bid amount %#x", bidder.Hex(), 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..80ddc481a5 --- /dev/null +++ b/timeboost/bid_validator_test.go @@ -0,0 +1,194 @@ +package timeboost + +import ( + "context" + "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), + roundTimingInfo: RoundTimingInfo{ + Offset: time.Now().Add(-time.Second * 3), + Round: 10 * time.Second, + AuctionClosing: 5 * time.Second, + }, + reservePrice: big.NewInt(2), + 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), + roundTimingInfo: RoundTimingInfo{ + Offset: time.Now().Add(-time.Second), + Round: time.Minute, + AuctionClosing: 45 * time.Second, + }, + reservePrice: big.NewInt(2), + bidsPerSenderInRound: make(map[common.Address]uint8), + maxBidsPerSenderInRound: 5, + auctionContractAddr: auctionContractAddr, + auctionContractDomainSeparator: common.Hash{}, + } + 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'}, + } + + bidHash, err := bid.ToEIP712Hash(bv.auctionContractDomainSeparator) + require.NoError(t, err) + + signature, err := crypto.Sign(bidHash[:], privateKey) + 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 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'}, + } + + bidHash, err := bid.ToEIP712Hash(common.Hash{}) + require.NoError(t, err) + + signature, err := crypto.Sign(bidHash[:], privateKey) + 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..66c69991f4 --- /dev/null +++ b/timeboost/bidder_client.go @@ -0,0 +1,230 @@ +package timeboost + +import ( + "context" + "fmt" + "math/big" + + "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/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/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 + roundTimingInfo RoundTimingInfo + 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 + } + rawRoundTimingInfo, err := auctionContract.RoundTimingInfo(&bind.CallOpts{ + Context: ctx, + }) + if err != nil { + return nil, err + } + roundTimingInfo, err := NewRoundTimingInfo(rawRoundTimingInfo) + if err != nil { + return nil, err + } + 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, + roundTimingInfo: *roundTimingInfo, + 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 + } + + domainSeparator, err := bd.auctionContract.DomainSeparator(&bind.CallOpts{ + Context: ctx, + }) + if err != nil { + return nil, err + } + newBid := &Bid{ + ChainId: bd.chainId, + ExpressLaneController: expressLaneController, + AuctionContractAddress: bd.auctionContractAddress, + Round: bd.roundTimingInfo.RoundNumber() + 1, + Amount: amount, + } + bidHash, err := newBid.ToEIP712Hash(domainSeparator) + if err != nil { + return nil, err + } + + sig, err := bd.signer(bidHash.Bytes()) + 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 + }) +} 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..8d71a510a8 --- /dev/null +++ b/timeboost/db.go @@ -0,0 +1,168 @@ +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) GetBids(maxDbRows int) ([]*SqliteDatabaseBid, uint64, error) { + d.lock.Lock() + defer d.lock.Unlock() + var maxRound uint64 + query := `SELECT MAX(Round) FROM Bids` + err := d.sqlDB.Get(&maxRound, query) + if err != nil { + return nil, 0, fmt.Errorf("failed to fetch maxRound from bids: %w", err) + } + var sqlDBbids []*SqliteDatabaseBid + if maxDbRows == 0 { + if err := d.sqlDB.Select(&sqlDBbids, "SELECT * FROM Bids WHERE Round < ? ORDER BY Round ASC", maxRound); err != nil { + return nil, 0, err + } + return sqlDBbids, maxRound, nil + } + if err := d.sqlDB.Select(&sqlDBbids, "SELECT * FROM Bids WHERE Round < ? ORDER BY Round ASC LIMIT ?", maxRound, maxDbRows); err != nil { + return nil, 0, err + } + // We should return contiguous set of bids + for i := len(sqlDBbids) - 1; i > 0; i-- { + if sqlDBbids[i].Round != sqlDBbids[i-1].Round { + return sqlDBbids[:i], sqlDBbids[i].Round, nil + } + } + // If we can't determine a contiguous set of bids, we abort and retry again. + // Saves us from cases where we sometime push same batch data twice + return nil, 0, 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..7bfae9c61a --- /dev/null +++ b/timeboost/db_test.go @@ -0,0 +1,133 @@ +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() + + 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([]*SqliteDatabaseBid, 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..1d55cdf201 --- /dev/null +++ b/timeboost/errors.go @@ -0,0 +1,20 @@ +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") + ErrAcceptedTxFailed = errors.New("Accepted timeboost tx failed") +) diff --git a/timeboost/roundtiminginfo.go b/timeboost/roundtiminginfo.go new file mode 100644 index 0000000000..037b44e4cf --- /dev/null +++ b/timeboost/roundtiminginfo.go @@ -0,0 +1,133 @@ +// 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/solgen/go/express_lane_auctiongen" + "github.com/offchainlabs/nitro/util/arbmath" +) + +// Validate the express_lane_auctiongen.RoundTimingInfo fields. +// Returns errors in terms of the solidity field names to ease debugging. +func validateRoundTimingInfo(c *express_lane_auctiongen.RoundTimingInfo) 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) + } + + return nil +} + +// RoundTimingInfo holds the information from the Solidity type of the same name, +// validated and converted into higher level time types, with helpful methods +// for calculating round number, if a round is closed, and time til close. +type RoundTimingInfo struct { + Offset time.Time + Round time.Duration + AuctionClosing time.Duration + ReserveSubmission time.Duration +} + +// Convert from solgen bindings to domain type +func NewRoundTimingInfo(c express_lane_auctiongen.RoundTimingInfo) (*RoundTimingInfo, error) { + if err := validateRoundTimingInfo(&c); err != nil { + return nil, err + } + + return &RoundTimingInfo{ + Offset: time.Unix(c.OffsetTimestamp, 0), + Round: 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, + }, nil +} + +// resolutionWaitTime is an additional parameter that the Auctioneer +// needs to validate against other timing fields. +func (info *RoundTimingInfo) ValidateResolutionWaitTime(resolutionWaitTime time.Duration) error { + // Resolution wait time shouldn't be more than 50% of auction closing time + if resolutionWaitTime > info.AuctionClosing/2 { + return fmt.Errorf("resolution wait time (%v) must not exceed 50%% of auction closing time (%v)", + resolutionWaitTime, info.AuctionClosing) + } + return nil +} + +// RoundNumber returns the round number as of now. +func (info *RoundTimingInfo) RoundNumber() uint64 { + return info.RoundNumberAt(time.Now()) +} + +// RoundNumberAt returns the round number as of some timestamp. +func (info *RoundTimingInfo) RoundNumberAt(currentTime time.Time) uint64 { + return arbmath.SaturatingUCast[uint64](currentTime.Sub(info.Offset) / info.Round) + // info.Round has already been validated to be nonzero during construction. +} + +// TimeTilNextRound returns the time til the next round as of now. +func (info *RoundTimingInfo) TimeTilNextRound() time.Duration { + return info.TimeTilNextRoundAt(time.Now()) +} + +// TimeTilNextRoundAt returns the time til the next round, +// where the next round is determined from the timestamp passed in. +func (info *RoundTimingInfo) TimeTilNextRoundAt(currentTime time.Time) time.Duration { + return info.TimeOfNextRoundAt(currentTime).Sub(currentTime) +} + +func (info *RoundTimingInfo) TimeOfNextRound() time.Time { + return info.TimeOfNextRoundAt(time.Now()) +} + +func (info *RoundTimingInfo) TimeOfNextRoundAt(currentTime time.Time) time.Time { + roundNum := info.RoundNumberAt(currentTime) + return info.Offset.Add(info.Round * arbmath.SaturatingCast[time.Duration](roundNum+1)) +} + +func (info *RoundTimingInfo) durationIntoRound(timestamp time.Time) time.Duration { + secondsSinceOffset := uint64(timestamp.Sub(info.Offset).Seconds()) + roundDurationSeconds := uint64(info.Round.Seconds()) + return arbmath.SaturatingCast[time.Duration](secondsSinceOffset % roundDurationSeconds) +} + +func (info *RoundTimingInfo) isAuctionRoundClosed() bool { + return info.isAuctionRoundClosedAt(time.Now()) +} + +func (info *RoundTimingInfo) isAuctionRoundClosedAt(currentTime time.Time) bool { + if currentTime.Before(info.Offset) { + return false + } + + return info.durationIntoRound(currentTime)*time.Second >= info.Round-info.AuctionClosing +} + +func (info *RoundTimingInfo) IsWithinAuctionCloseWindow(timestamp time.Time) bool { + return info.TimeTilNextRoundAt(timestamp) <= info.AuctionClosing +} diff --git a/timeboost/s3_storage.go b/timeboost/s3_storage.go new file mode 100644 index 0000000000..3235fa844b --- /dev/null +++ b/timeboost/s3_storage.go @@ -0,0 +1,247 @@ +package timeboost + +import ( + "bytes" + "context" + "encoding/csv" + "fmt" + "strconv" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/feature/s3/manager" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/spf13/pflag" + + "github.com/ethereum/go-ethereum/log" + + "github.com/offchainlabs/nitro/util/gzip" + "github.com/offchainlabs/nitro/util/s3client" + "github.com/offchainlabs/nitro/util/stopwaiter" +) + +type S3StorageServiceConfig struct { + Enable bool `koanf:"enable"` + AccessKey string `koanf:"access-key"` + Bucket string `koanf:"bucket"` + ObjectPrefix string `koanf:"object-prefix"` + Region string `koanf:"region"` + SecretKey string `koanf:"secret-key"` + UploadInterval time.Duration `koanf:"upload-interval"` + MaxBatchSize int `koanf:"max-batch-size"` + MaxDbRows int `koanf:"max-db-rows"` +} + +func (c *S3StorageServiceConfig) Validate() error { + if !c.Enable { + return nil + } + if c.MaxBatchSize < 0 { + return fmt.Errorf("invalid max-batch-size value for auctioneer's s3-storage config, it should be non-negative, got: %d", c.MaxBatchSize) + } + if c.MaxDbRows < 0 { + return fmt.Errorf("invalid max-db-rows value for auctioneer's s3-storage config, it should be non-negative, got: %d", c.MaxDbRows) + } + return nil +} + +var DefaultS3StorageServiceConfig = S3StorageServiceConfig{ + Enable: false, + UploadInterval: 15 * time.Minute, + MaxBatchSize: 100000000, + MaxDbRows: 0, // Disabled by default +} + +func S3StorageServiceConfigAddOptions(prefix string, f *pflag.FlagSet) { + f.Bool(prefix+".enable", DefaultS3StorageServiceConfig.Enable, "enable persisting of valdiated bids to AWS S3 bucket") + f.String(prefix+".access-key", DefaultS3StorageServiceConfig.AccessKey, "S3 access key") + f.String(prefix+".bucket", DefaultS3StorageServiceConfig.Bucket, "S3 bucket") + f.String(prefix+".object-prefix", DefaultS3StorageServiceConfig.ObjectPrefix, "prefix to add to S3 objects") + f.String(prefix+".region", DefaultS3StorageServiceConfig.Region, "S3 region") + f.String(prefix+".secret-key", DefaultS3StorageServiceConfig.SecretKey, "S3 secret key") + f.Duration(prefix+".upload-interval", DefaultS3StorageServiceConfig.UploadInterval, "frequency at which batches are uploaded to S3") + f.Int(prefix+".max-batch-size", DefaultS3StorageServiceConfig.MaxBatchSize, "max size of uncompressed batch in bytes to be uploaded to S3") + f.Int(prefix+".max-db-rows", DefaultS3StorageServiceConfig.MaxDbRows, "when the sql db is very large, this enables reading of db in chunks instead of all at once which might cause OOM") +} + +type S3StorageService struct { + stopwaiter.StopWaiter + config *S3StorageServiceConfig + client s3client.FullClient + sqlDB *SqliteDatabase + bucket string + objectPrefix string + lastFailedDeleteRound uint64 +} + +func NewS3StorageService(config *S3StorageServiceConfig, sqlDB *SqliteDatabase) (*S3StorageService, error) { + client, err := s3client.NewS3FullClient(config.AccessKey, config.SecretKey, config.Region) + if err != nil { + return nil, err + } + return &S3StorageService{ + config: config, + client: client, + sqlDB: sqlDB, + bucket: config.Bucket, + objectPrefix: config.ObjectPrefix, + }, nil +} + +func (s *S3StorageService) Start(ctx context.Context) { + s.StopWaiter.Start(ctx, s) + if err := s.LaunchThreadSafe(func(ctx context.Context) { + ticker := time.NewTicker(s.config.UploadInterval) + defer ticker.Stop() + for { + interval := s.uploadBatches(ctx) + if ctx.Err() != nil { + return + } + if interval != s.config.UploadInterval { // Indicates error case, so we'll retry sooner than upload-interval + time.Sleep(interval) + continue + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }); err != nil { + log.Error("Failed to launch s3-storage service of auctioneer", "err", err) + } +} + +// Used in padding round numbers to a fixed length for naming the batch being uploaded to s3. - +const fixedRoundStrLen = 7 + +func (s *S3StorageService) getBatchName(firstRound, lastRound uint64) string { + padder := "%0" + strconv.Itoa(fixedRoundStrLen) + "d" + now := time.Now() + return fmt.Sprintf("%svalidated-timeboost-bids/%d/%02d/%02d/"+padder+"-"+padder+".csv.gzip", s.objectPrefix, now.Year(), now.Month(), now.Day(), firstRound, lastRound) +} +func (s *S3StorageService) uploadBatch(ctx context.Context, batch []byte, firstRound, lastRound uint64) error { + compressedData, err := gzip.CompressGzip(batch) + if err != nil { + return err + } + key := s.getBatchName(firstRound, lastRound) + putObjectInput := s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: bytes.NewReader(compressedData), + } + if _, err = s.client.Upload(ctx, &putObjectInput); err != nil { + return err + } + return nil +} + +// downloadBatch is only used for testing purposes +func (s *S3StorageService) downloadBatch(ctx context.Context, key string) ([]byte, error) { + buf := manager.NewWriteAtBuffer([]byte{}) + if _, err := s.client.Download(ctx, buf, &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }); err != nil { + return nil, err + } + return gzip.DecompressGzip(buf.Bytes()) +} + +func csvRecordSize(record []string) int { + size := len(record) // comma between fields + newline + for _, entry := range record { + size += len(entry) + } + return size +} + +func (s *S3StorageService) uploadBatches(ctx context.Context) time.Duration { + // Before doing anything first try to delete the previously uploaded bids that were not successfully erased from the sqlDB + if s.lastFailedDeleteRound != 0 { + if err := s.sqlDB.DeleteBids(s.lastFailedDeleteRound); err != nil { + log.Error("error deleting s3-persisted bids from sql db using lastFailedDeleteRound", "lastFailedDeleteRound", s.lastFailedDeleteRound, "err", err) + return 5 * time.Second + } + s.lastFailedDeleteRound = 0 + } + + bids, round, err := s.sqlDB.GetBids(s.config.MaxDbRows) + if err != nil { + log.Error("Error fetching validated bids from sql DB", "round", round, "err", err) + return 5 * time.Second + } + // Nothing to persist or a contiguous set of bids wasn't found, so exit early + if len(bids) == 0 { + return s.config.UploadInterval + } + + var csvBuffer bytes.Buffer + var size int + var firstBidId int + csvWriter := csv.NewWriter(&csvBuffer) + uploadAndDeleteBids := func(firstRound, lastRound, deletRound uint64) error { + // End current batch when size exceeds MaxBatchSize and the current round ends + csvWriter.Flush() + if err := csvWriter.Error(); err != nil { + log.Error("Error flushing csv writer", "err", err) + return err + } + if err := s.uploadBatch(ctx, csvBuffer.Bytes(), firstRound, lastRound); err != nil { + log.Error("Error uploading batch to s3", "firstRound", firstRound, "lastRound", lastRound, "err", err) + return err + } + // After successful upload we should go ahead and delete the uploaded bids from DB to prevent duplicate uploads + // If the delete fails, we track the deleteRound until a future delete succeeds. + if err := s.sqlDB.DeleteBids(deletRound); err != nil { + log.Error("error deleting s3-persisted bids from sql db", "round", deletRound, "err", err) + s.lastFailedDeleteRound = deletRound + } else { + // Previously failed deletes dont matter anymore as the recent one (larger round number) succeeded + s.lastFailedDeleteRound = 0 + } + return nil + } + + header := []string{"ChainID", "Bidder", "ExpressLaneController", "AuctionContractAddress", "Round", "Amount", "Signature"} + if err := csvWriter.Write(header); err != nil { + log.Error("Error writing to csv writer", "err", err) + return 5 * time.Second + } + for index, bid := range bids { + record := []string{bid.ChainId, bid.Bidder, bid.ExpressLaneController, bid.AuctionContractAddress, fmt.Sprintf("%d", bid.Round), bid.Amount, bid.Signature} + if err := csvWriter.Write(record); err != nil { + log.Error("Error writing to csv writer", "err", err) + return 5 * time.Second + } + if s.config.MaxBatchSize != 0 { + size += csvRecordSize(record) + if size >= s.config.MaxBatchSize && index < len(bids)-1 && bid.Round != bids[index+1].Round { + if uploadAndDeleteBids(bids[firstBidId].Round, bid.Round, bids[index+1].Round) != nil { + return 5 * time.Second + } + // Reset csv for next batch + csvBuffer.Reset() + if err := csvWriter.Write(header); err != nil { + log.Error("Error writing to csv writer", "err", err) + return 5 * time.Second + } + size = 0 + firstBidId = index + 1 + } + } + } + if s.config.MaxBatchSize == 0 || size > 0 { + if uploadAndDeleteBids(bids[firstBidId].Round, bids[len(bids)-1].Round, round) != nil { + return 5 * time.Second + } + } + + if s.lastFailedDeleteRound != 0 { + return 5 * time.Second + } + + return s.config.UploadInterval +} diff --git a/timeboost/s3_storage_test.go b/timeboost/s3_storage_test.go new file mode 100644 index 0000000000..ae2d9a0c19 --- /dev/null +++ b/timeboost/s3_storage_test.go @@ -0,0 +1,238 @@ +package timeboost + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "math/big" + "testing" + + "github.com/aws/aws-sdk-go-v2/feature/s3/manager" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/common" +) + +type mockS3FullClient struct { + data map[string][]byte +} + +func newmockS3FullClient() *mockS3FullClient { + return &mockS3FullClient{make(map[string][]byte)} +} + +func (m *mockS3FullClient) clear() { + m.data = make(map[string][]byte) +} + +func (m *mockS3FullClient) Client() *s3.Client { + return nil +} + +func (m *mockS3FullClient) Upload(ctx context.Context, input *s3.PutObjectInput, opts ...func(*manager.Uploader)) (*manager.UploadOutput, error) { + buf := new(bytes.Buffer) + _, err := buf.ReadFrom(input.Body) + if err != nil { + return nil, err + } + m.data[*input.Key] = buf.Bytes() + return nil, nil +} + +func (m *mockS3FullClient) Download(ctx context.Context, w io.WriterAt, input *s3.GetObjectInput, options ...func(*manager.Downloader)) (n int64, err error) { + if _, ok := m.data[*input.Key]; ok { + ret, err := w.WriteAt(m.data[*input.Key], 0) + if err != nil { + return 0, err + } + return int64(ret), nil + } + return 0, errors.New("key not found") +} + +func TestS3StorageServiceUploadAndDownload(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + mockClient := newmockS3FullClient() + s3StorageService := &S3StorageService{ + client: mockClient, + config: &S3StorageServiceConfig{MaxBatchSize: 0}, + } + + // Test upload and download of data + testData := []byte{1, 2, 3, 4} + require.NoError(t, s3StorageService.uploadBatch(ctx, testData, 10, 11)) + key := s3StorageService.getBatchName(10, 11) + gotData, err := s3StorageService.downloadBatch(ctx, key) + require.NoError(t, err) + require.Equal(t, testData, gotData) + + // Test interaction with sqlDB and upload of multiple batches + mockClient.clear() + db, err := NewDatabase(t.TempDir()) + require.NoError(t, err) + require.NoError(t, db.InsertBid(&ValidatedBid{ + ChainId: big.NewInt(2), + ExpressLaneController: common.HexToAddress("0x0000000000000000000000000000000000000001"), + AuctionContractAddress: common.HexToAddress("0x0000000000000000000000000000000000000002"), + Bidder: common.HexToAddress("0x0000000000000000000000000000000000000003"), + Round: 0, + Amount: big.NewInt(10), + Signature: []byte("signature0"), + })) + require.NoError(t, db.InsertBid(&ValidatedBid{ + ChainId: big.NewInt(1), + ExpressLaneController: common.HexToAddress("0x0000000000000000000000000000000000000001"), + AuctionContractAddress: common.HexToAddress("0x0000000000000000000000000000000000000002"), + Bidder: common.HexToAddress("0x0000000000000000000000000000000000000003"), + Round: 1, + Amount: big.NewInt(100), + Signature: []byte("signature1"), + })) + require.NoError(t, db.InsertBid(&ValidatedBid{ + ChainId: big.NewInt(2), + ExpressLaneController: common.HexToAddress("0x0000000000000000000000000000000000000004"), + AuctionContractAddress: common.HexToAddress("0x0000000000000000000000000000000000000005"), + Bidder: common.HexToAddress("0x0000000000000000000000000000000000000006"), + Round: 2, + Amount: big.NewInt(200), + Signature: []byte("signature2"), + })) + s3StorageService.sqlDB = db + + // Helper functions to verify correctness of batch uploads and + // Check if all the uploaded bids are removed from sql DB + verifyBatchUploadCorrectness := func(firstRound, lastRound uint64, wantBatch []byte) { + key = s3StorageService.getBatchName(firstRound, lastRound) + data, err := s3StorageService.downloadBatch(ctx, key) + require.NoError(t, err) + require.Equal(t, wantBatch, data) + } + var sqlDBbids []*SqliteDatabaseBid + checkUploadedBidsRemoval := func(remainingRound uint64) { + require.NoError(t, db.sqlDB.Select(&sqlDBbids, "SELECT * FROM Bids")) + require.Equal(t, 1, len(sqlDBbids)) + require.Equal(t, remainingRound, sqlDBbids[0].Round) + } + + // UploadBatches should upload only the first bid and only one bid (round = 2) should remain in the sql database + s3StorageService.uploadBatches(ctx) + verifyBatchUploadCorrectness(0, 1, []byte(`ChainID,Bidder,ExpressLaneController,AuctionContractAddress,Round,Amount,Signature +2,0x0000000000000000000000000000000000000003,0x0000000000000000000000000000000000000001,0x0000000000000000000000000000000000000002,0,10,signature0 +1,0x0000000000000000000000000000000000000003,0x0000000000000000000000000000000000000001,0x0000000000000000000000000000000000000002,1,100,signature1 +`)) + checkUploadedBidsRemoval(2) + + // UploadBatches should continue adding bids to the batch until round ends, even if its past MaxBatchSize + require.NoError(t, db.InsertBid(&ValidatedBid{ + ChainId: big.NewInt(1), + ExpressLaneController: common.HexToAddress("0x0000000000000000000000000000000000000007"), + AuctionContractAddress: common.HexToAddress("0x0000000000000000000000000000000000000008"), + Bidder: common.HexToAddress("0x0000000000000000000000000000000000000009"), + Round: 2, + Amount: big.NewInt(150), + Signature: []byte("signature3"), + })) + require.NoError(t, db.InsertBid(&ValidatedBid{ + ChainId: big.NewInt(2), + ExpressLaneController: common.HexToAddress("0x0000000000000000000000000000000000000001"), + AuctionContractAddress: common.HexToAddress("0x0000000000000000000000000000000000000002"), + Bidder: common.HexToAddress("0x0000000000000000000000000000000000000003"), + Round: 3, + Amount: big.NewInt(250), + Signature: []byte("signature4"), + })) + require.NoError(t, db.InsertBid(&ValidatedBid{ + ChainId: big.NewInt(2), + ExpressLaneController: common.HexToAddress("0x0000000000000000000000000000000000000004"), + AuctionContractAddress: common.HexToAddress("0x0000000000000000000000000000000000000005"), + Bidder: common.HexToAddress("0x0000000000000000000000000000000000000006"), + Round: 4, + Amount: big.NewInt(350), + Signature: []byte("signature5"), + })) + record := []string{sqlDBbids[0].ChainId, sqlDBbids[0].Bidder, sqlDBbids[0].ExpressLaneController, sqlDBbids[0].AuctionContractAddress, fmt.Sprintf("%d", sqlDBbids[0].Round), sqlDBbids[0].Amount, sqlDBbids[0].Signature} + s3StorageService.config.MaxBatchSize = csvRecordSize(record) + + // Round 2 bids should all be in the same batch even though the resulting batch exceeds MaxBatchSize + s3StorageService.uploadBatches(ctx) + verifyBatchUploadCorrectness(2, 2, []byte(`ChainID,Bidder,ExpressLaneController,AuctionContractAddress,Round,Amount,Signature +2,0x0000000000000000000000000000000000000006,0x0000000000000000000000000000000000000004,0x0000000000000000000000000000000000000005,2,200,signature2 +1,0x0000000000000000000000000000000000000009,0x0000000000000000000000000000000000000007,0x0000000000000000000000000000000000000008,2,150,signature3 +`)) + + // After Batching Round 2 bids we end that batch and create a new batch for Round 3 bids to adhere to MaxBatchSize rule + s3StorageService.uploadBatches(ctx) + verifyBatchUploadCorrectness(3, 3, []byte(`ChainID,Bidder,ExpressLaneController,AuctionContractAddress,Round,Amount,Signature +2,0x0000000000000000000000000000000000000003,0x0000000000000000000000000000000000000001,0x0000000000000000000000000000000000000002,3,250,signature4 +`)) + checkUploadedBidsRemoval(4) + + // Verify chunked reading of sql db + require.NoError(t, db.InsertBid(&ValidatedBid{ + ChainId: big.NewInt(1), + ExpressLaneController: common.HexToAddress("0x0000000000000000000000000000000000000007"), + AuctionContractAddress: common.HexToAddress("0x0000000000000000000000000000000000000008"), + Bidder: common.HexToAddress("0x0000000000000000000000000000000000000009"), + Round: 4, + Amount: big.NewInt(450), + Signature: []byte("signature6"), + })) + require.NoError(t, db.InsertBid(&ValidatedBid{ + ChainId: big.NewInt(2), + ExpressLaneController: common.HexToAddress("0x0000000000000000000000000000000000000001"), + AuctionContractAddress: common.HexToAddress("0x0000000000000000000000000000000000000002"), + Bidder: common.HexToAddress("0x0000000000000000000000000000000000000003"), + Round: 5, + Amount: big.NewInt(550), + Signature: []byte("signature7"), + })) + require.NoError(t, db.InsertBid(&ValidatedBid{ + ChainId: big.NewInt(2), + ExpressLaneController: common.HexToAddress("0x0000000000000000000000000000000000000004"), + AuctionContractAddress: common.HexToAddress("0x0000000000000000000000000000000000000005"), + Bidder: common.HexToAddress("0x0000000000000000000000000000000000000006"), + Round: 5, + Amount: big.NewInt(650), + Signature: []byte("signature8"), + })) + require.NoError(t, db.InsertBid(&ValidatedBid{ + ChainId: big.NewInt(2), + ExpressLaneController: common.HexToAddress("0x0000000000000000000000000000000000000004"), + AuctionContractAddress: common.HexToAddress("0x0000000000000000000000000000000000000005"), + Bidder: common.HexToAddress("0x0000000000000000000000000000000000000006"), + Round: 6, + Amount: big.NewInt(750), + Signature: []byte("signature9"), + })) + require.NoError(t, db.InsertBid(&ValidatedBid{ + ChainId: big.NewInt(1), + ExpressLaneController: common.HexToAddress("0x0000000000000000000000000000000000000004"), + AuctionContractAddress: common.HexToAddress("0x0000000000000000000000000000000000000005"), + Bidder: common.HexToAddress("0x0000000000000000000000000000000000000006"), + Round: 7, + Amount: big.NewInt(850), + Signature: []byte("signature10"), + })) + s3StorageService.config.MaxDbRows = 5 + + // Since config.MaxBatchSize is kept same and config.MaxDbRows is 5, sqldb.GetBids would return all bids from round 4 and 5, with round used for DeletBids as 6 + // maxBatchSize would then batch bids from round 4 & 5 separately and uploads them to s3 + s3StorageService.uploadBatches(ctx) + verifyBatchUploadCorrectness(4, 4, []byte(`ChainID,Bidder,ExpressLaneController,AuctionContractAddress,Round,Amount,Signature +2,0x0000000000000000000000000000000000000006,0x0000000000000000000000000000000000000004,0x0000000000000000000000000000000000000005,4,350,signature5 +1,0x0000000000000000000000000000000000000009,0x0000000000000000000000000000000000000007,0x0000000000000000000000000000000000000008,4,450,signature6 +`)) + verifyBatchUploadCorrectness(5, 5, []byte(`ChainID,Bidder,ExpressLaneController,AuctionContractAddress,Round,Amount,Signature +2,0x0000000000000000000000000000000000000003,0x0000000000000000000000000000000000000001,0x0000000000000000000000000000000000000002,5,550,signature7 +2,0x0000000000000000000000000000000000000006,0x0000000000000000000000000000000000000004,0x0000000000000000000000000000000000000005,5,650,signature8 +`)) + require.NoError(t, db.sqlDB.Select(&sqlDBbids, "SELECT * FROM Bids ORDER BY Round ASC")) + require.Equal(t, 2, len(sqlDBbids)) + require.Equal(t, uint64(6), sqlDBbids[0].Round) + require.Equal(t, uint64(7), sqlDBbids[1].Round) +} diff --git a/timeboost/schema.go b/timeboost/schema.go new file mode 100644 index 0000000000..68a70aac63 --- /dev/null +++ b/timeboost/schema.go @@ -0,0 +1,25 @@ +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 +); +CREATE INDEX idx_bids_round ON Bids(Round); +` + 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..f9bfc18ed4 --- /dev/null +++ b/timeboost/ticker.go @@ -0,0 +1,44 @@ +package timeboost + +import ( + "time" +) + +type roundTicker struct { + c chan time.Time + done chan bool + roundTimingInfo RoundTimingInfo +} + +func newRoundTicker(roundTimingInfo RoundTimingInfo) *roundTicker { + return &roundTicker{ + c: make(chan time.Time, 1), + done: make(chan bool), + roundTimingInfo: roundTimingInfo, + } +} + +func (t *roundTicker) tickAtAuctionClose() { + t.start(t.roundTimingInfo.AuctionClosing) +} + +func (t *roundTicker) tickAtReserveSubmissionDeadline() { + t.start(t.roundTimingInfo.AuctionClosing + t.roundTimingInfo.ReserveSubmission) +} + +func (t *roundTicker) start(timeBeforeRoundStart time.Duration) { + for { + nextTick := t.roundTimingInfo.TimeTilNextRound() - timeBeforeRoundStart + if nextTick < 0 { + nextTick += t.roundTimingInfo.Round + } + + select { + case <-time.After(nextTick): + t.c <- time.Now() + case <-t.done: + close(t.c) + return + } + } +} diff --git a/timeboost/ticker_test.go b/timeboost/ticker_test.go new file mode 100644 index 0000000000..f284ba56ae --- /dev/null +++ b/timeboost/ticker_test.go @@ -0,0 +1,42 @@ +package timeboost + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func Test_auctionClosed(t *testing.T) { + t.Parallel() + roundTimingInfo := RoundTimingInfo{ + Offset: time.Now(), + Round: time.Minute, + AuctionClosing: time.Second * 15, + } + + initialTimestamp := time.Now() + + // We should not have closed the round yet, and the time into the round should be less than a second. + isClosed := roundTimingInfo.isAuctionRoundClosedAt(initialTimestamp) + require.False(t, isClosed) + + // Wait right before auction closure (before the 45 second mark). + timestamp := initialTimestamp.Add((roundTimingInfo.Round - roundTimingInfo.AuctionClosing) - time.Second) + isClosed = roundTimingInfo.isAuctionRoundClosedAt(timestamp) + require.False(t, isClosed) + + // Wait a second more and the auction should be closed. + timestamp = initialTimestamp.Add(roundTimingInfo.Round - roundTimingInfo.AuctionClosing) + isClosed = roundTimingInfo.isAuctionRoundClosedAt(timestamp) + require.True(t, isClosed) + + // Future timestamp should also be closed, until we reach the new round + for i := float64(0); i < roundTimingInfo.AuctionClosing.Seconds(); i++ { + timestamp = initialTimestamp.Add((roundTimingInfo.Round - roundTimingInfo.AuctionClosing) + time.Second*time.Duration(i)) + isClosed = roundTimingInfo.isAuctionRoundClosedAt(timestamp) + require.True(t, isClosed) + } + isClosed = roundTimingInfo.isAuctionRoundClosedAt(initialTimestamp.Add(roundTimingInfo.Round)) + require.False(t, isClosed) +} diff --git a/timeboost/types.go b/timeboost/types.go new file mode 100644 index 0000000000..01a60b8484 --- /dev/null +++ b/timeboost/types.go @@ -0,0 +1,284 @@ +package timeboost + +import ( + "bytes" + "encoding/binary" + "fmt" + "math/big" + + "github.com/pkg/errors" + + "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" + "github.com/ethereum/go-ethereum/signer/core/apitypes" +) + +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) ToEIP712Hash(domainSeparator [32]byte) (common.Hash, error) { + types := apitypes.Types{ + "Bid": []apitypes.Type{ + {Name: "round", Type: "uint64"}, + {Name: "expressLaneController", Type: "address"}, + {Name: "amount", Type: "uint256"}, + }, + } + + message := apitypes.TypedDataMessage{ + "round": big.NewInt(0).SetUint64(b.Round), + "expressLaneController": [20]byte(b.ExpressLaneController), + "amount": b.Amount, + } + + typedData := apitypes.TypedData{ + Types: types, + PrimaryType: "Bid", + Message: message, + Domain: apitypes.TypedDataDomain{Salt: "Unused; domain separator fetched from method on contract. This must be nonempty for validation."}, + } + + messageHash, err := typedData.HashStruct(typedData.PrimaryType, typedData.Message) + if err != nil { + return common.Hash{}, err + } + + bidHash := crypto.Keccak256Hash( + []byte("\x19\x01"), + domainSeparator[:], + messageHash, + ) + + return bidHash, nil +} + +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))) +// +// This is only used for breaking ties amongst equivalent bids and not used for +// Bid signing, which uses EIP 712 as the hashing scheme. +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 + + sender common.Address +} + +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 +} + +func (els *ExpressLaneSubmission) Sender() (common.Address, error) { + if (els.sender != common.Address{}) { + return els.sender, nil + } + // Reconstruct the message being signed over and recover the sender address. + signingMessage, err := els.ToMessageBytes() + if err != nil { + return common.Address{}, ErrMalformedData + } + if len(els.Signature) != 65 { + return common.Address{}, errors.Wrap(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(els.Signature)) + copy(sigItem, els.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 common.Address{}, ErrMalformedData + } + els.sender = crypto.PubkeyToAddress(*pubkey) + return els.sender, 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 +} + +type SqliteDatabaseBid 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"` +} diff --git a/util/common.go b/util/common.go new file mode 100644 index 0000000000..fc71e4a704 --- /dev/null +++ b/util/common.go @@ -0,0 +1,9 @@ +package util + +func ArrayToSet[T comparable](arr []T) map[T]struct{} { + ret := make(map[T]struct{}) + for _, elem := range arr { + ret[elem] = struct{}{} + } + return ret +} diff --git a/util/gzip/gzip_compression.go b/util/gzip/gzip_compression.go new file mode 100644 index 0000000000..4ad069767c --- /dev/null +++ b/util/gzip/gzip_compression.go @@ -0,0 +1,34 @@ +package gzip + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" +) + +func CompressGzip(data []byte) ([]byte, error) { + var buffer bytes.Buffer + gzipWriter := gzip.NewWriter(&buffer) + if _, err := gzipWriter.Write(data); err != nil { + return nil, fmt.Errorf("failed to write to gzip writer: %w", err) + } + if err := gzipWriter.Close(); err != nil { + return nil, fmt.Errorf("failed to close gzip writer: %w", err) + } + return buffer.Bytes(), nil +} + +func DecompressGzip(data []byte) ([]byte, error) { + buffer := bytes.NewReader(data) + gzipReader, err := gzip.NewReader(buffer) + if err != nil { + return nil, fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzipReader.Close() + decompressData, err := io.ReadAll(gzipReader) + if err != nil { + return nil, fmt.Errorf("failed to read decompressed data: %w", err) + } + return decompressData, nil +} diff --git a/util/gzip/gzip_compression_test.go b/util/gzip/gzip_compression_test.go new file mode 100644 index 0000000000..c55dfb68c0 --- /dev/null +++ b/util/gzip/gzip_compression_test.go @@ -0,0 +1,21 @@ +package gzip + +import ( + "bytes" + "testing" +) + +func TestCompressDecompress(t *testing.T) { + sampleData := []byte{1, 2, 3, 4} + compressedData, err := CompressGzip(sampleData) + if err != nil { + t.Fatalf("got error gzip-compressing data: %v", err) + } + gotData, err := DecompressGzip(compressedData) + if err != nil { + t.Fatalf("got error gzip-decompressing data: %v", err) + } + if !bytes.Equal(sampleData, gotData) { + t.Fatal("original data and decompression of its compression don't match") + } +} diff --git a/util/redisutil/redis_coordinator.go b/util/redisutil/redis_coordinator.go index 39db7c8645..ed0c9f70c7 100644 --- a/util/redisutil/redis_coordinator.go +++ b/util/redisutil/redis_coordinator.go @@ -20,6 +20,7 @@ const PRIORITIES_KEY string = "coordinator.priorities" // Read o const WANTS_LOCKOUT_KEY_PREFIX string = "coordinator.liveliness." // Per server. Only written by self const MESSAGE_KEY_PREFIX string = "coordinator.msg." // Per Message. Only written by sequencer holding CHOSEN const SIGNATURE_KEY_PREFIX string = "coordinator.msg.sig." // Per Message. Only written by sequencer holding CHOSEN +const BLOCKMETADATA_KEY_PREFIX string = "coordinator.blockMetadata." // Per Message. Only written by sequencer holding CHOSEN const WANTS_LOCKOUT_VAL string = "OK" const SWITCHED_REDIS string = "SWITCHED_REDIS" const INVALID_VAL string = "INVALID" @@ -119,3 +120,7 @@ func MessageKeyFor(pos arbutil.MessageIndex) string { func MessageSigKeyFor(pos arbutil.MessageIndex) string { return fmt.Sprintf("%s%d", SIGNATURE_KEY_PREFIX, pos) } + +func BlockMetadataKeyFor(pos arbutil.MessageIndex) string { + return fmt.Sprintf("%s%d", BLOCKMETADATA_KEY_PREFIX, pos) +} 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/s3client/s3client.go b/util/s3client/s3client.go new file mode 100644 index 0000000000..623107ea14 --- /dev/null +++ b/util/s3client/s3client.go @@ -0,0 +1,62 @@ +package s3client + +import ( + "context" + "io" + + awsConfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/feature/s3/manager" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +type Uploader interface { + Upload(ctx context.Context, input *s3.PutObjectInput, opts ...func(*manager.Uploader)) (*manager.UploadOutput, error) +} + +type Downloader interface { + Download(ctx context.Context, w io.WriterAt, input *s3.GetObjectInput, options ...func(*manager.Downloader)) (n int64, err error) +} + +type FullClient interface { + Uploader + Downloader + Client() *s3.Client +} + +type s3Client struct { + client *s3.Client + uploader Uploader + downloader Downloader +} + +func NewS3FullClient(accessKey, secretKey, region string) (FullClient, error) { + cfg, err := awsConfig.LoadDefaultConfig(context.TODO(), awsConfig.WithRegion(region), func(options *awsConfig.LoadOptions) error { + // remain backward compatible with accessKey and secretKey credentials provided via cli flags + if accessKey != "" && secretKey != "" { + options.Credentials = credentials.NewStaticCredentialsProvider(accessKey, secretKey, "") + } + return nil + }) + if err != nil { + return nil, err + } + client := s3.NewFromConfig(cfg) + return &s3Client{ + client: client, + uploader: manager.NewUploader(client), + downloader: manager.NewDownloader(client), + }, nil +} + +func (s *s3Client) Client() *s3.Client { + return s.client +} + +func (s *s3Client) Upload(ctx context.Context, input *s3.PutObjectInput, opts ...func(*manager.Uploader)) (*manager.UploadOutput, error) { + return s.uploader.Upload(ctx, input, opts...) +} + +func (s *s3Client) Download(ctx context.Context, w io.WriterAt, input *s3.GetObjectInput, options ...func(*manager.Downloader)) (n int64, err error) { + return s.downloader.Download(ctx, w, input, options...) +} diff --git a/util/stopwaiter/stopwaiter.go b/util/stopwaiter/stopwaiter.go index c242ac26ab..ccfa676d49 100644 --- a/util/stopwaiter/stopwaiter.go +++ b/util/stopwaiter/stopwaiter.go @@ -250,6 +250,26 @@ func CallIterativelyWith[T any]( }) } +func CallWhenTriggeredWith[T any]( + s ThreadLauncher, + foo func(context.Context, T), + triggerChan <-chan T, +) error { + return s.LaunchThreadSafe(func(ctx context.Context) { + for { + if ctx.Err() != nil { + return + } + select { + case <-ctx.Done(): + return + case val := <-triggerChan: + foo(ctx, val) + } + } + }) +} + func LaunchPromiseThread[T any]( s ThreadLauncher, foo func(context.Context) (T, error), 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()))