Bolt Builder

diff: ignored:
+1976
-332
+314
-300

This is an overview of the changes made to the canonical Flashbots Builder to support the Constraints API.

Here’s an overview of all the changes divided by module:

This is where the bulk of the API diffs are located.

We added 3 new Builder API endpoints to communicate with Relays:

The constraints cache is populated as soon as new constraints are streamed from the relay, and percolate to the miner at block building time.

diff --git builder/builder/builder.go bolt-builder/builder/builder.go index bcdab8fc1ec93b4f85264bc7a0ec0fe25edcc4a6..e765b4bad43a5d016219b0fd77d07c62834370fd 100644 --- builder/builder/builder.go +++ bolt-builder/builder/builder.go @@ -1,11 +1,18 @@ package builder   import ( + "bufio" + "compress/gzip" "context" + "encoding/json" "errors" "fmt" + "io" "math/big" + "net/http" _ "os" + "slices" + "strings" "sync" "time"   @@ -20,6 +27,7 @@ "github.com/attestantio/go-eth2-client/spec/bellatrix" "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/deneb" "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/chainbound/shardmap" "github.com/ethereum/go-ethereum/beacon/engine" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core" @@ -45,6 +53,11 @@ SubmissionOffsetFromEndOfSlotSecondsDefault = 3 * time.Second )   +const ( + GetConstraintsPath = "/relay/v1/builder/constraints" + SubscribeConstraintsPath = "/relay/v1/builder/constraints_stream" +) + type PubkeyHex string   type ValidatorData struct { @@ -55,6 +68,8 @@ }   type IRelay interface { SubmitBlock(msg *builderSpec.VersionedSubmitBlockRequest, vd ValidatorData) error + SubmitBlockWithProofs(msg *types.VersionedSubmitBlockRequestWithProofs, vd ValidatorData) error + GetDelegationsForSlot(nextSlot uint64) (types.SignedDelegations, error) GetValidatorForSlot(nextSlot uint64) (ValidatorData, error) Config() RelayConfig Start() error @@ -81,14 +96,18 @@ builderPublicKey phase0.BLSPubKey builderSigningDomain phase0.Domain builderResubmitInterval time.Duration discardRevertibleTxOnErr bool + + // constraintsCache is a map from slot to the decoded constraints made by proposers + constraintsCache *shardmap.FIFOMap[uint64, types.HashToConstraintDecoded]   limiter *rate.Limiter submissionOffsetFromEndOfSlot time.Duration   - slotMu sync.Mutex - slotAttrs types.BuilderPayloadAttributes - slotCtx context.Context - slotCtxCancel context.CancelFunc + slotMu sync.Mutex + slotConstraintsPubkeys []phase0.BLSPubKey // The pubkey of the authorized constraints signer + slotAttrs types.BuilderPayloadAttributes + slotCtx context.Context + slotCtxCancel context.CancelFunc   stop chan struct{} } @@ -161,6 +180,9 @@ args.submissionOffsetFromEndOfSlot = SubmissionOffsetFromEndOfSlotSecondsDefault }   slotCtx, slotCtxCancel := context.WithCancel(context.Background()) + + constraintsCache := shardmap.NewFIFOMap[uint64, types.HashToConstraintDecoded](64, 16, shardmap.HashUint64) + return &Builder{ ds: args.ds, blockConsumer: args.blockConsumer, @@ -176,6 +198,8 @@ builderSigningDomain: args.builderSigningDomain, builderResubmitInterval: args.builderBlockResubmitInterval, discardRevertibleTxOnErr: args.discardRevertibleTxOnErr, submissionOffsetFromEndOfSlot: args.submissionOffsetFromEndOfSlot, + + constraintsCache: constraintsCache,   limiter: args.limiter, slotCtx: slotCtx, @@ -228,7 +252,155 @@ } } }()   - return b.relay.Start() + if err := b.relay.Start(); err != nil { + return err + } + + return b.SubscribeProposerConstraints() +} + +// SubscribeProposerConstraints subscribes to the constraints made by Bolt proposers +// which the builder pulls from relay(s) using SSE. +func (b *Builder) SubscribeProposerConstraints() error { + // Check if `b.relay` is a RemoteRelayAggregator, if so we need to subscribe to + // the constraints made available by all the relays + relayAggregator, ok := b.relay.(*RemoteRelayAggregator) + if ok { + for _, relay := range relayAggregator.relays { + go b.subscribeToRelayForConstraints(relay.Config().Endpoint) + } + } else { + go b.subscribeToRelayForConstraints(b.relay.Config().Endpoint) + } + return nil +} + +func (b *Builder) subscribeToRelayForConstraints(relayBaseEndpoint string) error { + attempts := 0 + maxAttempts := 60 // Max 10 minutes of retries + retryInterval := 10 * time.Second + + var resp *http.Response + + for { + log.Info("Attempting to subscribe to constraints...") + + if attempts >= maxAttempts { + log.Error(fmt.Sprintf("Failed to subscribe to constraints after %d attempts", maxAttempts)) + return errors.New("failed to subscribe to constraints") + } + + req, err := http.NewRequest(http.MethodGet, relayBaseEndpoint+SubscribeConstraintsPath, nil) + if err != nil { + log.Error(fmt.Sprintf("Failed to create new http request: %v", err)) + return err + } + + client := http.Client{} + + resp, err = client.Do(req) + if err != nil { + log.Error(fmt.Sprintf("Failed to connect to SSE server: %v", err)) + time.Sleep(retryInterval) + attempts++ + continue + } + + if resp.StatusCode != http.StatusOK { + log.Error(fmt.Sprintf("Error subscribing to constraints via SSE: %s, %v", resp.Status, err)) + return err + } + break + } + + defer resp.Body.Close() + log.Info(fmt.Sprintf("Connected to SSE server: %s", relayBaseEndpoint)) + + var reader io.Reader + + // Check if the response is gzipped + if resp.Header.Get("Content-Encoding") == "gzip" { + // Decompress the response body + gzipReader, err := gzip.NewReader(resp.Body) + if err != nil { + return fmt.Errorf("error creating gzip reader: %v", err) + } + defer gzipReader.Close() + reader = gzipReader + } else { + reader = resp.Body + } + + bufReader := bufio.NewReader(reader) + for { + line, err := bufReader.ReadString('\n') + if err != nil { + if err == io.EOF { + log.Info("End of stream") + break + } + log.Error(fmt.Sprintf("Error reading from response body: %v", err)) + continue + } + + if !strings.HasPrefix(line, "data: ") { + continue + } + + data := strings.TrimPrefix(line, "data: ") + + // We assume the data is the JSON representation of the constraints + log.Info(fmt.Sprintf("Received new constraint: %s", data)) + constraintsSigned := make(types.SignedConstraintsList, 0, 8) + if err := json.Unmarshal([]byte(data), &constraintsSigned); err != nil { + log.Warn(fmt.Sprintf("Failed to unmarshal constraints: %v", err)) + continue + } + + if len(constraintsSigned) == 0 { + log.Warn("Received 0 length list of constraints") + continue + } + + for _, constraint := range constraintsSigned { + // Check that the constraints pubkey is authorized to sign constraints + if !slices.Contains(b.slotConstraintsPubkeys, constraint.Message.Pubkey) { + log.Warn("Received constraint from unauthorized pubkey", "pubkey", constraint.Message.Pubkey) + continue + } + + // Verify the signature of the constraints message + valid, err := constraint.VerifySignature(constraint.Message.Pubkey, b.GetConstraintsDomain()) + if err != nil || !valid { + log.Error("Failed to verify constraint signature", "err", err) + continue + } + + decodedConstraints, err := DecodeConstraints(constraint) + if err != nil { + log.Error("Failed to decode constraint: ", err) + continue + } + + // For every constraint, we need to check if it has already been seen for the associated slot + slotConstraints, _ := b.constraintsCache.Get(constraint.Message.Slot) + if len(slotConstraints) == 0 { + // New constraint for this slot, add it in the map and continue with the next constraint + b.constraintsCache.Put(constraint.Message.Slot, decodedConstraints) + continue + } + + for hash := range decodedConstraints { + // Update the slot constraints + slotConstraints[hash] = decodedConstraints[hash] + } + + // Update the slot constraints in the cache + b.constraintsCache.Put(constraint.Message.Slot, slotConstraints) + } + } + + return nil }   func (b *Builder) Stop() error { @@ -236,6 +408,20 @@ close(b.stop) return nil }   +// GetConstraintsDomain returns the constraints domain used to sign constraints-API related messages. +// +// The builder signing domain is built as follows: +// - We build a ForkData ssz container with the fork version and the genesis validators root. In the builder domain, this is an empty root. +// - We take the hash tree root of this container and replace the first 4 bytes with the builder domain mask. That gives us the signing domain. +// +// To get the constraints domain, we take the builder domain and replace the first 4 bytes with the constraints domain type. +func (b *Builder) GetConstraintsDomain() phase0.Domain { + domain := b.builderSigningDomain + copy(domain[:4], types.ConstraintsDomainType[:]) + return domain +} + +// BOLT: modify to calculate merkle inclusion proofs for committed transactions func (b *Builder) onSealedBlock(opts SubmitBlockOpts) error { executableData := engine.BlockToExecutableData(opts.Block, opts.BlockValue, opts.BlobSidecars) var dataVersion spec.DataVersion @@ -272,6 +458,28 @@ log.Error("could not get block request", "err", err) return err }   + var versionedBlockRequestWithConstraintProofs *types.VersionedSubmitBlockRequestWithProofs + + // BOLT: fetch constraints from the cache, which is automatically updated by the SSE subscription + constraints, _ := b.constraintsCache.Get(opts.PayloadAttributes.Slot) + log.Info(fmt.Sprintf("[BOLT]: Found %d constraints for slot %d", len(constraints), opts.PayloadAttributes.Slot)) + + if len(constraints) > 0 { + message := fmt.Sprintf("sealing block %d with %d constraints", opts.Block.Number(), len(constraints)) + log.Info(message) + + inclusionProof, _, err := CalculateMerkleMultiProofs(opts.Block.Transactions(), constraints) + if err != nil { + log.Error("[BOLT]: could not calculate merkle multiproofs", "err", err) + return err + } + + versionedBlockRequestWithConstraintProofs = &types.VersionedSubmitBlockRequestWithProofs{ + VersionedSubmitBlockRequest: versionedBlockRequest, + Proofs: inclusionProof, + } + } + if b.dryRun { switch dataVersion { case spec.DataVersionBellatrix: @@ -285,16 +493,23 @@ if err != nil { log.Error("could not validate block", "version", dataVersion.String(), "err", err) } } else { + // NOTE: we can ignore constraints for `processBuiltBlock` go b.processBuiltBlock(opts.Block, opts.BlockValue, opts.OrdersClosedAt, opts.SealedAt, opts.CommitedBundles, opts.AllBundles, opts.UsedSbundles, &blockBidMsg) - err = b.relay.SubmitBlock(versionedBlockRequest, opts.ValidatorData) + if versionedBlockRequestWithConstraintProofs != nil { + log.Info(fmt.Sprintf("[BOLT]: Sending sealed block to relay %s", versionedBlockRequestWithConstraintProofs)) + err = b.relay.SubmitBlockWithProofs(versionedBlockRequestWithConstraintProofs, opts.ValidatorData) + } else if len(constraints) == 0 { + // If versionedBlockRequestWithConstraintsProofs is nil and no constraints, then we don't have proofs to send + err = b.relay.SubmitBlock(versionedBlockRequest, opts.ValidatorData) + } else { + log.Warn(fmt.Sprintf("[BOLT]: Could not send sealed block this time because we have %d constraints but no proofs", len(constraints))) + return nil + } if err != nil { log.Error("could not submit block", "err", err, "verion", dataVersion, "#commitedBundles", len(opts.CommitedBundles)) return err } } - - log.Info("submitted block", "version", dataVersion.String(), "slot", opts.PayloadAttributes.Slot, "value", opts.BlockValue.String(), "parent", opts.Block.ParentHash().String(), - "hash", opts.Block.Hash(), "#commitedBundles", len(opts.CommitedBundles))   return nil } @@ -363,6 +578,7 @@ log.Info("successfully relayed block data to consumer") } }   +// Called when a new payload event is received from the beacon client SSE func (b *Builder) OnPayloadAttribute(attrs *types.BuilderPayloadAttributes) error { if attrs == nil { return nil @@ -373,6 +589,40 @@ if err != nil { return fmt.Errorf("could not get validator while submitting block for slot %d - %w", attrs.Slot, err) }   + proposerPubkey, err := utils.HexToPubkey(string(vd.Pubkey)) + if err != nil { + return fmt.Errorf("could not parse pubkey (%s) - %w", vd.Pubkey, err) + } + + // BOLT: by default, the proposer key is the constraint signer key + pubkey, err := utils.HexToPubkey(string(vd.Pubkey)) + if err != nil { + log.Error("could not parse pubkey", "pubkey", vd.Pubkey, "err", err) + } + constraintsPubkeys := []phase0.BLSPubKey{pubkey} + + // BOLT: get delegations for the slot + delegations, err := b.relay.GetDelegationsForSlot(attrs.Slot) + if err != nil { + log.Error("could not get delegations for slot, using default validator key", "slot", attrs.Slot, "err", err) + } + + if len(delegations) > 0 { + // If there are delegations, reset the constraintsPubkeys (i.e. remove the validator key as authorized) + constraintsPubkeys = make([]phase0.BLSPubKey, 0, len(delegations)) + for _, delegation := range delegations { + // Verify signature against the public key + valid, err := delegation.VerifySignature(pubkey, b.GetConstraintsDomain()) + if err != nil || !valid { + log.Error("could not verify signature", "err", err) + continue + } + + // If signature is valid, add the pubkey to the list of authorized constraint pubkeys + constraintsPubkeys = append(constraintsPubkeys, delegation.Message.DelegateePubkey) + } + } + parentBlock := b.eth.GetBlockByHash(attrs.HeadHash) if parentBlock == nil { return fmt.Errorf("parent block hash not found in block tree given head block hash %s", attrs.HeadHash) @@ -381,11 +631,6 @@ attrs.SuggestedFeeRecipient = [20]byte(vd.FeeRecipient) attrs.GasLimit = core.CalcGasLimit(parentBlock.GasLimit(), vd.GasLimit)   - proposerPubkey, err := utils.HexToPubkey(string(vd.Pubkey)) - if err != nil { - return fmt.Errorf("could not parse pubkey (%s) - %w", vd.Pubkey, err) - } - if !b.eth.Synced() { return errors.New("backend not Synced") } @@ -404,9 +649,13 @@ }   slotCtx, slotCtxCancel := context.WithTimeout(context.Background(), 12*time.Second) b.slotAttrs = *attrs + // BOLT: save the authorized pubkeys for the upcoming slot + b.slotConstraintsPubkeys = constraintsPubkeys b.slotCtx = slotCtx b.slotCtxCancel = slotCtxCancel   + log.Info("[BOLT]: Inside onPayloadAttribute", "slot", attrs.Slot, "parent", attrs.HeadHash, "payloadTimestamp", uint64(attrs.Timestamp)) + go b.runBuildingJob(b.slotCtx, proposerPubkey, vd, attrs) return nil } @@ -422,6 +671,9 @@ allBundles []types.SimulatedBundle usedSbundles []types.UsedSBundle }   +// Continuously makes a request to the miner module with the correct params and submits the best produced block. +// on average 1 attempt per second is made. +// - Submissions to the relay are rate limited to 2 req/s func (b *Builder) runBuildingJob(slotCtx context.Context, proposerPubkey phase0.BLSPubKey, vd ValidatorData, attrs *types.BuilderPayloadAttributes) { ctx, cancel := context.WithTimeout(slotCtx, 12*time.Second) defer cancel() @@ -515,7 +767,7 @@ log.Debug("retrying BuildBlock", "slot", attrs.Slot, "parent", attrs.HeadHash, "resubmit-interval", b.builderResubmitInterval.String()) - err := b.eth.BuildBlock(attrs, blockHook) + err := b.eth.BuildBlock(attrs, blockHook, b.constraintsCache) if err != nil { log.Warn("Failed to build block", "err", err) }
diff --git builder/builder/builder_test.go bolt-builder/builder/builder_test.go index d8a698c4cf7172d0710fd5010d2587206ebd9374..07d7d1fc48bf9b42b245c72f3e79a243cae97b7a 100644 --- builder/builder/builder_test.go +++ bolt-builder/builder/builder_test.go @@ -1,7 +1,11 @@ package builder   import ( + "encoding/hex" + "encoding/json" + "fmt" "math/big" + "net/http" "testing" "time"   @@ -14,9 +18,11 @@ "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/flashbotsextra" + "github.com/ethereum/go-ethereum/log" "github.com/flashbots/go-boost-utils/bls" "github.com/flashbots/go-boost-utils/ssz" "github.com/flashbots/go-boost-utils/utils" + "github.com/gorilla/handlers" "github.com/holiman/uint256" "github.com/stretchr/testify/require" ) @@ -170,3 +176,369 @@ time.Sleep(2200 * time.Millisecond) require.NotNil(t, testRelay.submittedMsg) } + +func TestBlockWithConstraints(t *testing.T) { + const ( + validatorDesiredGasLimit = 30_000_000 + payloadAttributeGasLimit = 30_000_000 // Was zero in the other test + parentBlockGasLimit = 29_000_000 + ) + expectedGasLimit := core.CalcGasLimit(parentBlockGasLimit, validatorDesiredGasLimit) + + vsk, err := bls.SecretKeyFromBytes(hexutil.MustDecode("0x370bb8c1a6e62b2882f6ec76762a67b39609002076b95aae5b023997cf9b2dc9")) + require.NoError(t, err) + validator := &ValidatorPrivateData{ + sk: vsk, + Pk: hexutil.MustDecode("0xb67d2c11bcab8c4394fc2faa9601d0b99c7f4b37e14911101da7d97077917862eed4563203d34b91b5cf0aa44d6cfa05"), + } + + testBeacon := testBeaconClient{ + validator: validator, + slot: 56, + } + + feeRecipient, _ := utils.HexToAddress("0xabcf8e0d4e9587369b2301d0790347320302cc00") + testRelay := testRelay{ + gvsVd: ValidatorData{ + Pubkey: PubkeyHex(testBeacon.validator.Pk.String()), + FeeRecipient: feeRecipient, + GasLimit: validatorDesiredGasLimit, + }, + } + + sk, err := bls.SecretKeyFromBytes(hexutil.MustDecode("0x31ee185dad1220a8c88ca5275e64cf5a5cb09cb621cb30df52c9bee8fbaaf8d7")) + require.NoError(t, err) + + bDomain := ssz.ComputeDomain(ssz.DomainTypeAppBuilder, [4]byte{0x02, 0x0, 0x0, 0x0}, phase0.Root{}) + + // https://etherscan.io/tx/0x9d48b4a021898a605b7ae49bf93ad88fa6bd7050e9448f12dde064c10f22fe9c + // 0x02f87601836384348477359400850517683ba883019a28943678fce4028b6745eb04fa010d9c8e4b36d6288c872b0f1366ad800080c080a0b6b7aba1954160d081b2c8612e039518b9c46cd7df838b405a03f927ad196158a071d2fb6813e5b5184def6bd90fb5f29e0c52671dea433a7decb289560a58416e + constraintTxByte, _ := hex.DecodeString("02f87601836384348477359400850517683ba883019a28943678fce4028b6745eb04fa010d9c8e4b36d6288c872b0f1366ad800080c080a0b6b7aba1954160d081b2c8612e039518b9c46cd7df838b405a03f927ad196158a071d2fb6813e5b5184def6bd90fb5f29e0c52671dea433a7decb289560a58416e") + constraintTx := new(types.Transaction) + err = constraintTx.UnmarshalBinary(constraintTxByte) + require.NoError(t, err) + + // https://etherscan.io/tx/0x15bd881daa1408b33f67fa4bdeb8acfb0a2289d9b4c6f81eef9bb2bb2e52e780 - Blob Tx + // 0x03f9029c01830299f184b2d05e008507aef40a00832dc6c09468d30f47f19c07bccef4ac7fae2dc12fca3e0dc980b90204ef16e845000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000633b68f5d8d3a86593ebb815b4663bcbe0302e31382e302d64657600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004109de8da2a97e37f2e6dc9f7d50a408f9344d7aa1a925ae53daf7fbef43491a571960d76c0cb926190a9da10df7209fb1ba93cd98b1565a3a2368749d505f90c81c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0843b9aca00e1a00141e3a338e30c49ed0501e315bcc45e4edefebed43ab1368a1505461d9cf64901a01e8511e06b17683d89eb57b9869b96b8b611f969f7f56cbc0adc2df7c88a2a07a00910deacf91bba0d74e368d285d311dc5884e7cfe219d85aea5741b2b6e3a2fe + constraintTxWithBlobByte, _ := hex.DecodeString("03f9029c01830299f184b2d05e008507aef40a00832dc6c09468d30f47f19c07bccef4ac7fae2dc12fca3e0dc980b90204ef16e845000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000633b68f5d8d3a86593ebb815b4663bcbe0302e31382e302d64657600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004109de8da2a97e37f2e6dc9f7d50a408f9344d7aa1a925ae53daf7fbef43491a571960d76c0cb926190a9da10df7209fb1ba93cd98b1565a3a2368749d505f90c81c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0843b9aca00e1a00141e3a338e30c49ed0501e315bcc45e4edefebed43ab1368a1505461d9cf64901a01e8511e06b17683d89eb57b9869b96b8b611f969f7f56cbc0adc2df7c88a2a07a00910deacf91bba0d74e368d285d311dc5884e7cfe219d85aea5741b2b6e3a2fe") + constraintTxWithBlob := new(types.Transaction) + err = constraintTxWithBlob.UnmarshalBinary(constraintTxWithBlobByte) + require.NoError(t, err) + + testExecutableData := &engine.ExecutableData{ + ParentHash: common.Hash{0x02, 0x03}, + FeeRecipient: common.Address(feeRecipient), + StateRoot: common.Hash{0x07, 0x16}, + ReceiptsRoot: common.Hash{0x08, 0x20}, + LogsBloom: types.Bloom{}.Bytes(), + Number: uint64(10), + GasLimit: expectedGasLimit, + GasUsed: uint64(100), + Timestamp: uint64(105), + ExtraData: hexutil.MustDecode("0x0042fafc"), + + BaseFeePerGas: big.NewInt(16), + + BlockHash: common.HexToHash("3cce5d0f5c9a7e188e79c35168256e91bec2d98a1140f6701da6ed3c98ea9d04"), + Transactions: [][]byte{constraintTxByte, constraintTxWithBlobByte}, + } + + testBlock, err := engine.ExecutableDataToBlock(*testExecutableData, constraintTxWithBlob.BlobHashes(), nil) + require.NoError(t, err) + + testPayloadAttributes := &types.BuilderPayloadAttributes{ + Timestamp: hexutil.Uint64(104), + Random: common.Hash{0x05, 0x10}, + SuggestedFeeRecipient: common.Address{0x04, 0x10}, + GasLimit: uint64(payloadAttributeGasLimit), + Slot: uint64(25), + } + + testEthService := &testEthereumService{synced: true, testExecutableData: testExecutableData, testBlock: testBlock, testBlockValue: big.NewInt(10)} + builderArgs := BuilderArgs{ + sk: sk, + ds: flashbotsextra.NilDbService{}, + relay: &testRelay, + builderSigningDomain: bDomain, + eth: testEthService, + dryRun: false, + ignoreLatePayloadAttributes: false, + validator: nil, + beaconClient: &testBeacon, + limiter: nil, + blockConsumer: flashbotsextra.NilDbService{}, + } + builder, err := NewBuilder(builderArgs) + require.NoError(t, err) + + builder.Start() + defer builder.Stop() + + // Add the transaction to the cache directly + builder.constraintsCache.Put(25, map[common.Hash]*types.Transaction{ + constraintTx.Hash(): constraintTx, + constraintTxWithBlob.Hash(): constraintTxWithBlob, + }) + + err = builder.OnPayloadAttribute(testPayloadAttributes) + require.NoError(t, err) + time.Sleep(time.Second * 3) + + require.NotNil(t, testRelay.submittedMsgWithProofs) + + expectedProposerPubkey, err := utils.HexToPubkey(testBeacon.validator.Pk.String()) + require.NoError(t, err) + + expectedMessage := builderApiV1.BidTrace{ + Slot: uint64(25), + ParentHash: phase0.Hash32{0x02, 0x03}, + BuilderPubkey: builder.builderPublicKey, + ProposerPubkey: expectedProposerPubkey, + ProposerFeeRecipient: feeRecipient, + GasLimit: expectedGasLimit, + GasUsed: uint64(100), + Value: &uint256.Int{0x0a}, + } + copy(expectedMessage.BlockHash[:], hexutil.MustDecode("0x3cce5d0f5c9a7e188e79c35168256e91bec2d98a1140f6701da6ed3c98ea9d04")[:]) + require.NotNil(t, testRelay.submittedMsgWithProofs.Bellatrix) + + require.Equal(t, expectedMessage, *testRelay.submittedMsgWithProofs.Bellatrix.Message) + + expectedExecutionPayload := bellatrix.ExecutionPayload{ + ParentHash: [32]byte(testExecutableData.ParentHash), + FeeRecipient: feeRecipient, + StateRoot: [32]byte(testExecutableData.StateRoot), + ReceiptsRoot: [32]byte(testExecutableData.ReceiptsRoot), + LogsBloom: [256]byte{}, + PrevRandao: [32]byte(testExecutableData.Random), + BlockNumber: testExecutableData.Number, + GasLimit: testExecutableData.GasLimit, + GasUsed: testExecutableData.GasUsed, + Timestamp: testExecutableData.Timestamp, + ExtraData: hexutil.MustDecode("0x0042fafc"), + BaseFeePerGas: [32]byte{0x10}, + BlockHash: expectedMessage.BlockHash, + Transactions: []bellatrix.Transaction{constraintTxByte, constraintTxWithBlobByte}, + } + + require.Equal(t, expectedExecutionPayload, *testRelay.submittedMsgWithProofs.Bellatrix.ExecutionPayload) + + expectedSignature, err := utils.HexToSignature("0x97db0496dcfd04ed444b87b6fc1c9e3339a0d35f7c01825ac353812601a72e7e35ef94899a9b03f4d23102214701255805efd0f6552073791ea1c3e10003ae435952f8305f6b89e58d4442ced149d3c33a486f5a390b4b8047e6ea4176059755") + + require.NoError(t, err) + require.Equal(t, expectedSignature, testRelay.submittedMsgWithProofs.Bellatrix.Signature) + + require.Equal(t, uint64(25), testRelay.requestedSlot) + + // Clear the submitted message and check that the job will be ran again and but a new message will not be submitted since the hash is the same + testEthService.testBlockValue = big.NewInt(10) + + testRelay.submittedMsgWithProofs = nil + time.Sleep(2200 * time.Millisecond) + require.Nil(t, testRelay.submittedMsgWithProofs) + + // Change the hash, expect to get the block + testExecutableData.ExtraData = hexutil.MustDecode("0x0042fafd") + testExecutableData.BlockHash = common.HexToHash("0x38456f6f1f5e76cf83c89ebb8606ff2b700bf02a86a165316c6d7a0c4e6a8614") + testBlock, err = engine.ExecutableDataToBlock(*testExecutableData, constraintTxWithBlob.BlobHashes(), nil) + testEthService.testBlockValue = big.NewInt(10) + require.NoError(t, err) + testEthService.testBlock = testBlock + + time.Sleep(2200 * time.Millisecond) + require.NotNil(t, testRelay.submittedMsgWithProofs) +} + +func TestSubscribeProposerConstraints(t *testing.T) { + // ------------ Start Builder setup ------------- // + const ( + validatorDesiredGasLimit = 30_000_000 + payloadAttributeGasLimit = 0 + parentBlockGasLimit = 29_000_000 + ) + expectedGasLimit := core.CalcGasLimit(parentBlockGasLimit, validatorDesiredGasLimit) + + vsk, err := bls.SecretKeyFromBytes(hexutil.MustDecode("0x370bb8c1a6e62b2882f6ec76762a67b39609002076b95aae5b023997cf9b2dc9")) + require.NoError(t, err) + validator := &ValidatorPrivateData{ + sk: vsk, + Pk: hexutil.MustDecode("0xb67d2c11bcab8c4394fc2faa9601d0b99c7f4b37e14911101da7d97077917862eed4563203d34b91b5cf0aa44d6cfa05"), + } + + testBeacon := testBeaconClient{ + validator: validator, + slot: 56, + } + + feeRecipient, _ := utils.HexToAddress("0xabcf8e0d4e9587369b2301d0790347320302cc00") + + relayPort := "31245" + relay := NewRemoteRelay(RelayConfig{Endpoint: "http://localhost:" + relayPort}, nil, true) + + sk, err := bls.SecretKeyFromBytes(hexutil.MustDecode("0x31ee185dad1220a8c88ca5275e64cf5a5cb09cb621cb30df52c9bee8fbaaf8d7")) + require.NoError(t, err) + + bDomain := ssz.ComputeDomain(ssz.DomainTypeAppBuilder, [4]byte{0x02, 0x0, 0x0, 0x0}, phase0.Root{}) + + testExecutableData := &engine.ExecutableData{ + ParentHash: common.Hash{0x02, 0x03}, + FeeRecipient: common.Address(feeRecipient), + StateRoot: common.Hash{0x07, 0x16}, + ReceiptsRoot: common.Hash{0x08, 0x20}, + LogsBloom: types.Bloom{}.Bytes(), + Number: uint64(10), + GasLimit: expectedGasLimit, + GasUsed: uint64(100), + Timestamp: uint64(105), + ExtraData: hexutil.MustDecode("0x0042fafc"), + + BaseFeePerGas: big.NewInt(16), + + BlockHash: common.HexToHash("0x68e516c8827b589fcb749a9e672aa16b9643437459508c467f66a9ed1de66a6c"), + Transactions: [][]byte{}, + } + + testBlock, err := engine.ExecutableDataToBlock(*testExecutableData, nil, nil) + require.NoError(t, err) + + testEthService := &testEthereumService{synced: true, testExecutableData: testExecutableData, testBlock: testBlock, testBlockValue: big.NewInt(10)} + + builderArgs := BuilderArgs{ + sk: sk, + ds: flashbotsextra.NilDbService{}, + relay: relay, + builderSigningDomain: bDomain, + eth: testEthService, + dryRun: false, + ignoreLatePayloadAttributes: false, + validator: nil, + beaconClient: &testBeacon, + limiter: nil, + blockConsumer: flashbotsextra.NilDbService{}, + } + + builder, err := NewBuilder(builderArgs) + require.NoError(t, err) + + // ------------ End Builder setup ------------- // + + // Attach the sseHandler to the relay port + mux := http.NewServeMux() + mux.HandleFunc(SubscribeConstraintsPath, sseConstraintsHandler) + + // Wrap the mux with the GzipHandler middleware + // NOTE: In this case, we don't need to create a gzip writer in the handlers, + // by default the `http.ResponseWriter` will implement gzip compression + gzipMux := handlers.CompressHandler(mux) + + http.HandleFunc(SubscribeConstraintsPath, sseConstraintsHandler) + go http.ListenAndServe(":"+relayPort, gzipMux) + + // Constraints should not be available yet + _, ok := builder.constraintsCache.Get(0) + require.Equal(t, false, ok) + + builder.subscribeToRelayForConstraints(builder.relay.Config().Endpoint) + // Wait 2 seconds to save all constraints in cache + time.Sleep(2 * time.Second) + + slots := []uint64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} + for _, slot := range slots { + cachedConstraints, ok := builder.constraintsCache.Get(slot) + require.Equal(t, true, ok) + + expectedConstraint := generateMockConstraintsForSlot(slot)[0] + decodedConstraint, err := DecodeConstraints(expectedConstraint) + require.NoError(t, err) + + // Compare the keys of the cachedConstraints and decodedConstraint maps + require.Equal(t, len(cachedConstraints), len(decodedConstraint), "The number of keys in both maps should be the same") + for key := range cachedConstraints { + _, ok := decodedConstraint[key] + require.True(t, ok, fmt.Sprintf("Key %s found in cachedConstraints but not in decodedConstraint", key.String())) + require.Equal(t, cachedConstraints[key].Data(), decodedConstraint[key].Data(), "The decodedConstraint Tx should be equal to the cachedConstraints Tx") + } + for key := range decodedConstraint { + _, ok := cachedConstraints[key] + require.True(t, ok, fmt.Sprintf("Key %s found in decodedConstraint but not in cachedConstraints", key.String())) + } + } +} + +func TestDeserializeConstraints(t *testing.T) { + jsonStr := `[ + { + "message": { + "pubkey": "0xa695ad325dfc7e1191fbc9f186f58eff42a634029731b18380ff89bf42c464a42cb8ca55b200f051f57f1e1893c68759", + "slot": 32, + "top": true, + "transactions": [ + "0x02f86c870c72dd9d5e883e4d0183408f2382520894d2e2adf7177b7a8afddbc12d1634cf23ea1a71020180c001a08556dcfea479b34675db3fe08e29486fe719c2b22f6b0c1741ecbbdce4575cc6a01cd48009ccafd6b9f1290bbe2ceea268f94101d1d322c787018423ebcbc87ab4" + ] + }, + "signature": "0xb8d50ee0d4b269db3d4658c1dac784d273a4160d769e16dce723a9684c390afe5865348416b3bf0f1a4f47098bec9024135d0d95f08bed18eb577a3d8a67f5dc78b13cc62515e280786a73fb267d35dfb7ab46a25ac29bf5bc2fa5b07b3e07a6" + } + ]` + + var constraints types.SignedConstraintsList + err := json.Unmarshal([]byte(jsonStr), &constraints) + require.NoError(t, err) + + jsonStr = `{ + "message": { + "pubkey":"0xb3cd9c9e59730c210bf9b76959bf11e20bb05cf47cfefdcaab74bc17c369d6daefe1219c2b94d743ffd27988edf24b90", + "slot":183, + "top":false, + "transactions": [ + "0xf8678085019dc6838082520894deaddeaddeaddeaddeaddeaddeaddeaddeaddead04808360306ca0fde9bdf8f1a9fefef7538490242afb21a0160cf19f1686c7b9bddb45de973b62a0318411f2c959d3e6a25434f99850a0eaa6beb617f7a2dbc9683dccded7bd4b10" + ] + }, + "signature": "0xaa8a47c6398d5862b56d1bbb308352c65e57e62b0bfdda39a36db7fff3a256c3c7066b219a15a013aae5303f42b6f07b025f34ed6d899e6172fec20d40c4ffebeb50f5d0b75a303c1cc916574c3e0f29d53b2211d28234f430fffce62b4ee554" + }` + + err = json.Unmarshal([]byte(jsonStr), &constraints) + require.NoError(t, err) +} + +func sseConstraintsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Content-Encoding", "gzip") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) + return + } + + for i := 0; i < 256; i++ { + // Generate some duplicated constraints + slot := uint64(i) % 32 + constraints := generateMockConstraintsForSlot(slot) + bytes, err := json.Marshal(constraints) + if err != nil { + log.Error(fmt.Sprintf("Error while marshaling constraints: %v", err)) + return + } + fmt.Fprintf(w, "data: %s\n\n", string(bytes)) + flusher.Flush() + } +} + +// generateMockConstraintsForSlot generates a list of constraints for a given slot +func generateMockConstraintsForSlot(slot uint64) types.SignedConstraintsList { + rawTx := new(types.Transaction) + err := rawTx.UnmarshalBinary(common.Hex2Bytes("0x02f876018305da308401312d0085041f1196d2825208940c598786c88883ff5e4f461750fad64d3fae54268804b7ec32d7a2000080c080a0086f02eacec72820be3b117e1edd5bd7ed8956964b28b2d903d2cba53dd13560a06d61ec9ccce6acb31bf21878b9a844e7fdac860c5b7d684f7eb5f38a5945357c")) + if err != nil { + fmt.Println("Failed to unmarshal rawTx: ", err) + } + + return types.SignedConstraintsList{ + &types.SignedConstraints{ + Message: types.ConstraintsMessage{ + Transactions: []*types.Transaction{rawTx}, Pubkey: phase0.BLSPubKey{}, Slot: slot, + }, Signature: phase0.BLSSignature{}, + }, + } +}
diff --git builder/builder/local_relay.go bolt-builder/builder/local_relay.go index 5a503a5c2b8c7e4751c09465b9e4cf2e4c43a44c..94a07eb167c7e026085263ade74343aed8b4d094 100644 --- builder/builder/local_relay.go +++ bolt-builder/builder/local_relay.go @@ -22,6 +22,7 @@ "github.com/attestantio/go-eth2-client/spec/bellatrix" "github.com/attestantio/go-eth2-client/spec/phase0" eth2UtilBellatrix "github.com/attestantio/go-eth2-client/util/bellatrix" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/log" "github.com/flashbots/go-boost-utils/bls" "github.com/flashbots/go-boost-utils/ssz" @@ -114,6 +115,10 @@ func (r *LocalRelay) SubmitBlock(msg *builderSpec.VersionedSubmitBlockRequest, _ ValidatorData) error { log.Info("submitting block to local relay", "block", msg.Bellatrix.ExecutionPayload.BlockHash.String()) return r.submitBlock(msg.Bellatrix) +} + +func (r *LocalRelay) SubmitBlockWithProofs(msg *types.VersionedSubmitBlockRequestWithProofs, _ ValidatorData) error { + panic("Not implemented!") }   func (r *LocalRelay) Config() RelayConfig { @@ -228,6 +233,10 @@ } r.validatorsLock.RUnlock() log.Info("no local entry for validator", "validator", pubkeyHex) return ValidatorData{}, errors.New("missing validator") +} + +func (r *LocalRelay) GetDelegationsForSlot(nextSlot uint64) (types.SignedDelegations, error) { + return types.SignedDelegations{}, nil }   func (r *LocalRelay) handleGetHeader(w http.ResponseWriter, req *http.Request) {
diff --git builder/builder/relay.go bolt-builder/builder/relay.go index 579fe14d7f746aa597bdd90351f012e45372fe0d..865d335927626c942a8f91769f0ae1965d8bcb3d 100644 --- builder/builder/relay.go +++ bolt-builder/builder/relay.go @@ -11,6 +11,7 @@ "time"   builderSpec "github.com/attestantio/go-builder-client/spec" "github.com/attestantio/go-eth2-client/spec" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/log" "github.com/flashbots/go-boost-utils/utils" ) @@ -128,6 +129,32 @@ return ValidatorData{}, ErrValidatorNotFound }   +func (r *RemoteRelay) GetDelegationsForSlot(nextSlot uint64) (types.SignedDelegations, error) { + log.Info("Getting delegations for slot", "slot", nextSlot, "endpoint", r.config.Endpoint) + endpoint := r.config.Endpoint + fmt.Sprintf("/relay/v1/builder/delegations?slot=%d", nextSlot) + + if r.config.SszEnabled { + panic("ssz not supported") + } + + // BOLT: Add 2s timeout to request + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + var dst types.SignedDelegations + code, err := SendHTTPRequest(ctx, *http.DefaultClient, http.MethodGet, endpoint, nil, &dst) + if err != nil { + return nil, fmt.Errorf("error getting delegations from relay %s. err: %w", r.config.Endpoint, err) + } + + if code > 299 { + return nil, fmt.Errorf("non-ok response code %d from relay", code) + } + + return dst, nil + +} + func (r *RemoteRelay) Start() error { return nil } @@ -178,6 +205,46 @@ return fmt.Errorf("error sending http request to relay %s. err: %w", r.config.Endpoint, err) } if code > 299 { return fmt.Errorf("non-ok response code %d from relay %s", code, r.config.Endpoint) + } + + return nil +} + +func (r *RemoteRelay) SubmitBlockWithProofs(msg *types.VersionedSubmitBlockRequestWithProofs, _ ValidatorData) error { + log.Info("submitting block with constraint inclusion proofs to remote relay", "endpoint", r.config.Endpoint) + endpoint := r.config.Endpoint + "/relay/v1/builder/blocks_with_proofs" + if r.cancellationsEnabled { + endpoint = endpoint + "?cancellations=1" + } + + var code int + var err error + if r.config.SszEnabled { + panic("ssz not supported for constraint proofs yet") + } else { + if len(msg.Proofs.TransactionHashes) > 0 { + number, _ := msg.BlockNumber() + message := fmt.Sprintf("sending block %d with proofs to relay (path: %s)", number, "/relay/v1/builder/blocks_with_proofs") + log.Info(message) + } + + switch msg.Version { + case spec.DataVersionBellatrix: + code, err = SendHTTPRequest(context.TODO(), *http.DefaultClient, http.MethodPost, endpoint, msg, nil) + case spec.DataVersionCapella: + code, err = SendHTTPRequest(context.TODO(), *http.DefaultClient, http.MethodPost, endpoint, msg, nil) + case spec.DataVersionDeneb: + code, err = SendHTTPRequest(context.TODO(), *http.DefaultClient, http.MethodPost, endpoint, msg, nil) + default: + return fmt.Errorf("unknown data version %d", msg.Version) + } + } + + if err != nil { + return fmt.Errorf("error sending http request block with proofs to relay %s. err: %w", r.config.Endpoint, err) + } + if code > 299 { + return fmt.Errorf("non-ok response code %d from relay for block with proofs %s", code, r.config.Endpoint) }   return nil
diff --git builder/builder/relay_aggregator.go bolt-builder/builder/relay_aggregator.go index c39784453acc265fe5a345b97682b8fc4a728707..4ceb4dc91143cf22c642a6d328d9d8428250dae6 100644 --- builder/builder/relay_aggregator.go +++ bolt-builder/builder/relay_aggregator.go @@ -6,6 +6,7 @@ "fmt" "sync"   builderSpec "github.com/attestantio/go-builder-client/spec" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/log" )   @@ -58,6 +59,76 @@ }(relay) }   return nil +} + +func (r *RemoteRelayAggregator) SubmitBlockWithProofs(msg *types.VersionedSubmitBlockRequestWithProofs, registration ValidatorData) error { + r.registrationsCacheLock.RLock() + defer r.registrationsCacheLock.RUnlock() + + relays, found := r.registrationsCache[registration] + if !found { + return fmt.Errorf("no relays for registration %s", registration.Pubkey) + } + for _, relay := range relays { + go func(relay IRelay) { + err := relay.SubmitBlockWithProofs(msg, registration) + if err != nil { + log.Error("could not submit block with proofs", "err", err) + } + }(relay) + } + + return nil +} + +func (r *RemoteRelayAggregator) GetDelegationsForSlot(nextSlot uint64) (types.SignedDelegations, error) { + delegationsCh := make(chan *types.SignedDelegations, len(r.relays)) + + for i, relay := range r.relays { + go func(relay IRelay, relayI int) { + delegations, err := relay.GetDelegationsForSlot(nextSlot) + if err != nil { + // Send nil to channel to indicate error + log.Error("could not get delegations", "err", err, "relay", relay.Config().Endpoint) + delegationsCh <- nil + } + + delegationsCh <- &delegations + }(relay, i) + } + + err := errors.New("could not get delegations from any relay") + + aggregated := types.SignedDelegations{} + for i := 0; i < len(r.relays); i++ { + d := <-delegationsCh + + if d != nil { + err = nil + + // Check if the delegations array already contains the delegations, if not add them + for _, delegation := range *d { + found := false + for _, existing := range aggregated { + if existing == delegation { + found = true + break + } + } + + if !found { + aggregated = append(aggregated, delegation) + } + } + } + } + + // If we still have an error, return error to caller + if err != nil { + return nil, err + } + + return aggregated, nil }   type RelayValidatorRegistration struct {
diff --git builder/builder/relay_aggregator_test.go bolt-builder/builder/relay_aggregator_test.go index b727f52c577514214ba3413582d0a8b97604e6d9..c5a7a1e5c9de699b29c7740e7c535af9d48f32d5 100644 --- builder/builder/relay_aggregator_test.go +++ bolt-builder/builder/relay_aggregator_test.go @@ -8,6 +8,7 @@ builderApiBellatrix "github.com/attestantio/go-builder-client/api/bellatrix" builderSpec "github.com/attestantio/go-builder-client/spec" "github.com/attestantio/go-eth2-client/spec" + "github.com/ethereum/go-ethereum/core/types" "github.com/stretchr/testify/require" )   @@ -22,9 +23,11 @@ sbError error gvsVd ValidatorData gvsErr error   - requestedSlot uint64 - submittedMsg *builderSpec.VersionedSubmitBlockRequest - submittedMsgCh chan *builderSpec.VersionedSubmitBlockRequest + requestedSlot uint64 + submittedMsg *builderSpec.VersionedSubmitBlockRequest + submittedMsgWithProofs *types.VersionedSubmitBlockRequestWithProofs + submittedMsgCh chan *builderSpec.VersionedSubmitBlockRequest + submittedMsgWithProofsCh chan *types.VersionedSubmitBlockRequestWithProofs }   type testRelayAggBackend struct { @@ -54,6 +57,21 @@ } } r.submittedMsg = msg return r.sbError +} + +func (r *testRelay) SubmitBlockWithProofs(msg *types.VersionedSubmitBlockRequestWithProofs, vd ValidatorData) error { + if r.submittedMsgWithProofsCh != nil { + select { + case r.submittedMsgWithProofsCh <- msg: + default: + } + } + r.submittedMsgWithProofs = msg + return r.sbError +} + +func (r *testRelay) GetDelegationsForSlot(nextSlot uint64) (types.SignedDelegations, error) { + return types.SignedDelegations{}, nil }   func (r *testRelay) GetValidatorForSlot(nextSlot uint64) (ValidatorData, error) {

We added logic to create and verify merkle inclusion proofs based on the SSZ Transactions beacon container.

diff --git builder/builder/transaction_ssz.go bolt-builder/builder/transaction_ssz.go new file mode 100644 index 0000000000000000000000000000000000000000..015be2fad16e557e17c18c01cf7471c1af0f9e63 --- /dev/null +++ bolt-builder/builder/transaction_ssz.go @@ -0,0 +1,52 @@ +package builder + +import ( + ssz "github.com/ferranbt/fastssz" +) + +// The maximum length in bytes of a raw RLP-encoded transaction +var MAX_BYTES_PER_TRANSACTION uint64 = 1_073_741_824 // 2**30 + +// Transaction is a wrapper type of byte slice to implement the ssz.HashRoot interface +type Transaction []byte + +// HashTreeRoot calculates the hash tree root of the transaction, which +// is a list of basic types (byte). +// +// Reference: https://github.com/ethereum/consensus-specs/blob/dev/ssz/simple-serialize.md#merkleization +func (tx *Transaction) HashTreeRoot() ([32]byte, error) { + hasher := ssz.NewHasher() + tx.HashTreeRootWith(hasher) + root, err := hasher.HashRoot() + + return root, err +} + +func (tx *Transaction) HashTreeRootWith(hh ssz.HashWalker) error { + var err error + byteLen := uint64(len(*tx)) + + if byteLen > MAX_BYTES_PER_TRANSACTION { + err = ssz.ErrIncorrectListSize + return err + } + + // Load the bytes of the transaction into the hasher + hh.AppendBytes32(*tx) + // Perform `mix_in_length(merkleize(pack(value), limit=chunk_count(type)), len(value))` + // Reference: https://github.com/ethereum/consensus-specs/blob/dev/ssz/simple-serialize.md#merkleization + // + // The `indx` parameters is set to `0` as we need to consider the whole hh.buf buffer for this. + // In an implementation of more complex types, this parameter would be used to indicate the starting + // index of the buffer to be merkleized. It is used a single buffer to do everything for + // optimization purposes. + hh.MerkleizeWithMixin(0, byteLen, (1073741824+31)/32) + + return nil +} + +func (tx *Transaction) GetTree() (*ssz.Node, error) { + w := &ssz.Wrapper{} + tx.HashTreeRootWith(w) + return w.Node(), nil +}
diff --git builder/builder/utils.go bolt-builder/builder/utils.go index 284285cf4e82a5cd7e343033ebebb2887e1e3e72..f1d4672ef8d31fbd9a0e11419735c18ce8fd8ad3 100644 --- builder/builder/utils.go +++ bolt-builder/builder/utils.go @@ -8,10 +8,28 @@ "encoding/json" "errors" "fmt" "io" + "math" "net/http" + "slices" + "time" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + utilbellatrix "github.com/attestantio/go-eth2-client/util/bellatrix" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/log" + ssz "github.com/ferranbt/fastssz" )   var errHTTPErrorResponse = errors.New("HTTP error response") + +func DecodeConstraints(constraints *types.SignedConstraints) (types.HashToConstraintDecoded, error) { + decodedConstraints := make(types.HashToConstraintDecoded) + for _, tx := range constraints.Message.Transactions { + decodedConstraints[tx.Hash()] = tx + } + return decodedConstraints, nil +}   // SendSSZRequest is a request to send SSZ data to a remote relay. func SendSSZRequest(ctx context.Context, client http.Client, method, url string, payload []byte, useGzip bool) (code int, err error) { @@ -117,3 +135,69 @@ }   return resp.StatusCode, nil } + +func CalculateMerkleMultiProofs( + payloadTransactions types.Transactions, + HashToConstraintDecoded types.HashToConstraintDecoded, +) (inclusionProof *types.InclusionProof, rootNode *ssz.Node, err error) { + constraints, _, _ := types.ParseConstraintsDecoded(HashToConstraintDecoded) + + // BOLT: generate merkle tree from payload transactions (we need raw RLP bytes for this) + rawTxs := make([]bellatrix.Transaction, len(payloadTransactions)) + for i, tx := range payloadTransactions { + raw, err := tx.WithoutBlobTxSidecar().MarshalBinary() + if err != nil { + log.Warn("[BOLT]: could not marshal transaction", "txHash", tx.Hash(), "err", err) + continue + } + rawTxs[i] = bellatrix.Transaction(raw) + } + + log.Info(fmt.Sprintf("[BOLT]: Generated %d raw transactions for merkle tree", len(rawTxs))) + bellatrixPayloadTxs := utilbellatrix.ExecutionPayloadTransactions{Transactions: rawTxs} + + rootNode, err = bellatrixPayloadTxs.GetTree() + if err != nil { + return nil, nil, fmt.Errorf("could not get tree from transactions: %w", err) + } + + // BOLT: Set the value of nodes. This is MANDATORY for the proof calculation + // to output the leaf correctly. This is also never documented in fastssz. -__- + rootNode.Hash() + + // using our gen index formula: 2 * 2^21 + constraintIndex + baseGeneralizedIndex := int(math.Pow(float64(2), float64(21))) + generalizedIndexes := make([]int, len(constraints)) + transactionHashes := make([]common.Hash, len(constraints)) + + for i, constraint := range constraints { + tx := constraint + // get the index of the committed transaction in the block + committedIndex := slices.IndexFunc(payloadTransactions, func(payloadTx *types.Transaction) bool { return payloadTx.Hash() == tx.Hash() }) + if committedIndex == -1 { + log.Error(fmt.Sprintf("Committed transaction %s not found in block", tx.Hash())) + log.Error(fmt.Sprintf("block has %v transactions", len(payloadTransactions))) + continue + } + + generalizedIndex := baseGeneralizedIndex + committedIndex + generalizedIndexes[i] = generalizedIndex + transactionHashes[i] = tx.Hash() + } + + log.Info(fmt.Sprintf("[BOLT]: Calculating merkle multiproof for %d committed transactions", len(constraints))) + + timeStart := time.Now() + multiProof, err := rootNode.ProveMulti(generalizedIndexes) + if err != nil { + return nil, nil, fmt.Errorf("could not calculate merkle multiproof for %d transactions: %w", len(constraints), err) + } + + timeForProofs := time.Since(timeStart) + log.Info(fmt.Sprintf("[BOLT]: Calculated merkle multiproof for %d transactions in %s", len(constraints), timeForProofs)) + + inclusionProof = types.InclusionProofFromMultiProof(multiProof) + inclusionProof.TransactionHashes = transactionHashes + + return +}
diff --git builder/builder/utils_test.go bolt-builder/builder/utils_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f4af130da5f0edd0b001ad1972f9f3e2c031baf1 --- /dev/null +++ bolt-builder/builder/utils_test.go @@ -0,0 +1,138 @@ +package builder + +import ( + "encoding/json" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + fastSsz "github.com/ferranbt/fastssz" + "github.com/stretchr/testify/require" +) + +func TestGenerateMerkleMultiProofs(t *testing.T) { + // https://etherscan.io/tx/0x138a5f8ba7950521d9dec66ee760b101e0c875039e695c9fcfb34f5ef02a881b + // 0x02f873011a8405f5e10085037fcc60e182520894f7eaaf75cb6ec4d0e2b53964ce6733f54f7d3ffc880b6139a7cbd2000080c080a095a7a3cbb7383fc3e7d217054f861b890a935adc1adf4f05e3a2f23688cf2416a00875cdc45f4395257e44d709d04990349b105c22c11034a60d7af749ffea2765 + // https://etherscan.io/tx/0xfb0ee9de8941c8ad50e6a3d2999cd6ef7a541ec9cb1ba5711b76fcfd1662dfa9 + // 0xf8708305dc6885029332e35883019a2894500b0107e172e420561565c8177c28ac0f62017f8810ffb80e6cc327008025a0e9c0b380c68f040ae7affefd11979f5ed18ae82c00e46aa3238857c372a358eca06b26e179dd2f7a7f1601755249f4cff56690c4033553658f0d73e26c36fe7815 + // https://etherscan.io/tx/0x45e7ee9ba1a1d0145de29a764a33bb7fc5620486b686d68ec8cb3182d137bc90 + // 0xf86c0785028fa6ae0082520894098d880c4753d0332ca737aa592332ed2522cd22880d2f09f6558750008026a0963e58027576b3a8930d7d9b4a49253b6e1a2060e259b2102e34a451d375ce87a063f802538d3efed17962c96fcea431388483bbe3860ea9bb3ef01d4781450fbf + // https://etherscan.io/tx/0x9d48b4a021898a605b7ae49bf93ad88fa6bd7050e9448f12dde064c10f22fe9c + // 0x02f87601836384348477359400850517683ba883019a28943678fce4028b6745eb04fa010d9c8e4b36d6288c872b0f1366ad800080c080a0b6b7aba1954160d081b2c8612e039518b9c46cd7df838b405a03f927ad196158a071d2fb6813e5b5184def6bd90fb5f29e0c52671dea433a7decb289560a58416e + // https://etherscan.io/tx/0x15bd881daa1408b33f67fa4bdeb8acfb0a2289d9b4c6f81eef9bb2bb2e52e780 - Blob Tx + // 0x03f9029c01830299f184b2d05e008507aef40a00832dc6c09468d30f47f19c07bccef4ac7fae2dc12fca3e0dc980b90204ef16e845000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000633b68f5d8d3a86593ebb815b4663bcbe0302e31382e302d64657600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004109de8da2a97e37f2e6dc9f7d50a408f9344d7aa1a925ae53daf7fbef43491a571960d76c0cb926190a9da10df7209fb1ba93cd98b1565a3a2368749d505f90c81c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0843b9aca00e1a00141e3a338e30c49ed0501e315bcc45e4edefebed43ab1368a1505461d9cf64901a01e8511e06b17683d89eb57b9869b96b8b611f969f7f56cbc0adc2df7c88a2a07a00910deacf91bba0d74e368d285d311dc5884e7cfe219d85aea5741b2b6e3a2fe + + raw := `["0x03f9029c01830299f184b2d05e008507aef40a00832dc6c09468d30f47f19c07bccef4ac7fae2dc12fca3e0dc980b90204ef16e845000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000633b68f5d8d3a86593ebb815b4663bcbe0302e31382e302d64657600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004109de8da2a97e37f2e6dc9f7d50a408f9344d7aa1a925ae53daf7fbef43491a571960d76c0cb926190a9da10df7209fb1ba93cd98b1565a3a2368749d505f90c81c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0843b9aca00e1a00141e3a338e30c49ed0501e315bcc45e4edefebed43ab1368a1505461d9cf64901a01e8511e06b17683d89eb57b9869b96b8b611f969f7f56cbc0adc2df7c88a2a07a00910deacf91bba0d74e368d285d311dc5884e7cfe219d85aea5741b2b6e3a2fe", "0x02f873011a8405f5e10085037fcc60e182520894f7eaaf75cb6ec4d0e2b53964ce6733f54f7d3ffc880b6139a7cbd2000080c080a095a7a3cbb7383fc3e7d217054f861b890a935adc1adf4f05e3a2f23688cf2416a00875cdc45f4395257e44d709d04990349b105c22c11034a60d7af749ffea2765","0xf8708305dc6885029332e35883019a2894500b0107e172e420561565c8177c28ac0f62017f8810ffb80e6cc327008025a0e9c0b380c68f040ae7affefd11979f5ed18ae82c00e46aa3238857c372a358eca06b26e179dd2f7a7f1601755249f4cff56690c4033553658f0d73e26c36fe7815", "0xf86c0785028fa6ae0082520894098d880c4753d0332ca737aa592332ed2522cd22880d2f09f6558750008026a0963e58027576b3a8930d7d9b4a49253b6e1a2060e259b2102e34a451d375ce87a063f802538d3efed17962c96fcea431388483bbe3860ea9bb3ef01d4781450fbf", "0x02f87601836384348477359400850517683ba883019a28943678fce4028b6745eb04fa010d9c8e4b36d6288c872b0f1366ad800080c080a0b6b7aba1954160d081b2c8612e039518b9c46cd7df838b405a03f927ad196158a071d2fb6813e5b5184def6bd90fb5f29e0c52671dea433a7decb289560a58416e"]` + + byteTxs := make([]*common.HexBytes, 0, 5) + err := json.Unmarshal([]byte(raw), &byteTxs) + require.NoError(t, err) + require.Equal(t, len(byteTxs), 5) + + payloadTransactions := common.Map(byteTxs, func(rawTx *common.HexBytes) *types.Transaction { + transaction := new(types.Transaction) + err = transaction.UnmarshalBinary([]byte(*rawTx)) + return transaction + }) + + require.Equal(t, payloadTransactions[0].Type(), uint8(3)) + require.Equal(t, payloadTransactions[1].Type(), uint8(2)) + + // try out all combinations of "constraints": + // e.g. only [0], then [0, 1], then [1] etc... + // and log which ones are failing and which ones are not + for i := 1; i < len(payloadTransactions)+1; i++ { + t.Logf("--- Trying with %d constraints\n", i) + for _, chosenConstraintTransactions := range combinations(payloadTransactions, i) { + // find the index of the chosen constraints inside payload transactions for debugging + payloadIndexes := make([]int, len(chosenConstraintTransactions)) + for i, chosenConstraint := range chosenConstraintTransactions { + for j, payloadTransaction := range payloadTransactions { + if chosenConstraint.Hash() == payloadTransaction.Hash() { + payloadIndexes[i] = j + break + } + } + } + + constraints := make(types.HashToConstraintDecoded) + for _, tx := range chosenConstraintTransactions { + constraints[tx.Hash()] = tx + } + + inclusionProof, root, err := CalculateMerkleMultiProofs(payloadTransactions, constraints) + require.NoError(t, err) + rootHash := root.Hash() + + leaves := make([][]byte, len(constraints)) + + i := 0 + for _, tx := range constraints { + if tx == nil { + t.Logf("nil constraint transaction!") + } + + // Compute the hash tree root for the raw committed transaction + // and use it as "Leaf" in the proof to be verified against + + withoutBlob, err := tx.WithoutBlobTxSidecar().MarshalBinary() + if err != nil { + t.Logf("error marshalling transaction without blob tx sidecar: %v", err) + } + + tx := Transaction(withoutBlob) + txHashTreeRoot, err := tx.HashTreeRoot() + if err != nil { + t.Logf("error calculating hash tree root: %v", err) + } + + leaves[i] = txHashTreeRoot[:] + i++ + } + + hashes := make([][]byte, len(inclusionProof.MerkleHashes)) + for i, hash := range inclusionProof.MerkleHashes { + hashes[i] = []byte(*hash) + } + indexes := make([]int, len(inclusionProof.GeneralizedIndexes)) + for i, index := range inclusionProof.GeneralizedIndexes { + indexes[i] = int(index) + } + + ok, err := fastSsz.VerifyMultiproof(rootHash[:], hashes, leaves, indexes) + if err != nil { + t.Logf("error verifying merkle proof: %v", err) + } + + if !ok { + t.Logf("FAIL with txs: %v", payloadIndexes) + } else { + t.Logf("SUCCESS with txs: %v", payloadIndexes) + } + } + } +} + +// Function to generate combinations of a specific length +func combinations[T any](arr []T, k int) [][]T { + var result [][]T + n := len(arr) + data := make([]T, k) + combine(arr, data, 0, n-1, 0, k, &result) + return result +} + +// Helper function to generate combinations +func combine[T any](arr, data []T, start, end, index, k int, result *[][]T) { + if index == k { + tmp := make([]T, k) + copy(tmp, data) + *result = append(*result, tmp) + return + } + + for i := start; i <= end && end-i+1 >= k-index; i++ { + data[index] = arr[i] + combine(arr, data, i+1, end, index+1, k, result) + } +}

The only change in the ETH service was adding the constraintsCache to the block building entrypoint.

diff --git builder/builder/eth_service.go bolt-builder/builder/eth_service.go index 480221815f46c97f37292ba441b280629339e04c..03624cd7cfdb1e171cc7f805b14012605122e9de 100644 --- builder/builder/eth_service.go +++ bolt-builder/builder/eth_service.go @@ -5,6 +5,7 @@ "errors" "math/big" "time"   + "github.com/chainbound/shardmap" "github.com/ethereum/go-ethereum/beacon/engine" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" @@ -15,7 +16,7 @@ "github.com/ethereum/go-ethereum/params" )   type IEthereumService interface { - BuildBlock(attrs *types.BuilderPayloadAttributes, sealedBlockCallback miner.BlockHookFn) error + BuildBlock(attrs *types.BuilderPayloadAttributes, sealedBlockCallback miner.BlockHookFn, constraintsCache *shardmap.FIFOMap[uint64, types.HashToConstraintDecoded]) error GetBlockByHash(hash common.Hash) *types.Block Config() *params.ChainConfig Synced() bool @@ -30,9 +31,10 @@ testBlobSidecar []*types.BlobTxSidecar testBundlesMerged []types.SimulatedBundle testAllBundles []types.SimulatedBundle testUsedSbundles []types.UsedSBundle + testConstraints []*types.Transaction }   -func (t *testEthereumService) BuildBlock(attrs *types.BuilderPayloadAttributes, sealedBlockCallback miner.BlockHookFn) error { +func (t *testEthereumService) BuildBlock(attrs *types.BuilderPayloadAttributes, sealedBlockCallback miner.BlockHookFn, constraintsCache *shardmap.FIFOMap[uint64, types.HashToConstraintDecoded]) error { sealedBlockCallback(t.testBlock, t.testBlockValue, t.testBlobSidecar, time.Now(), t.testBundlesMerged, t.testAllBundles, t.testUsedSbundles) return nil } @@ -52,18 +54,20 @@ return &EthereumService{eth: eth} }   // TODO: we should move to a setup similar to catalyst local blocks & payload ids -func (s *EthereumService) BuildBlock(attrs *types.BuilderPayloadAttributes, sealedBlockCallback miner.BlockHookFn) error { +func (s *EthereumService) BuildBlock(attrs *types.BuilderPayloadAttributes, sealedBlockCallback miner.BlockHookFn, constraintsCache *shardmap.FIFOMap[uint64, types.HashToConstraintDecoded]) error { // Send a request to generate a full block in the background. // The result can be obtained via the returned channel. args := &miner.BuildPayloadArgs{ - Parent: attrs.HeadHash, - Timestamp: uint64(attrs.Timestamp), - FeeRecipient: attrs.SuggestedFeeRecipient, - GasLimit: attrs.GasLimit, - Random: attrs.Random, - Withdrawals: attrs.Withdrawals, - BeaconRoot: attrs.ParentBeaconBlockRoot, - BlockHook: sealedBlockCallback, + Parent: attrs.HeadHash, + Timestamp: uint64(attrs.Timestamp), + FeeRecipient: attrs.SuggestedFeeRecipient, + GasLimit: attrs.GasLimit, + Random: attrs.Random, + Withdrawals: attrs.Withdrawals, + BeaconRoot: attrs.ParentBeaconBlockRoot, + Slot: attrs.Slot, + BlockHook: sealedBlockCallback, + ConstraintsCache: constraintsCache, }   payload, err := s.eth.Miner().BuildPayload(args) @@ -104,3 +108,7 @@ func (s *EthereumService) Synced() bool { return s.eth.Synced() } + +func (s *EthereumService) Ethereum() *eth.Ethereum { + return s.eth +}
diff --git builder/builder/eth_service_test.go bolt-builder/builder/eth_service_test.go index 386f472c2a1becf40c36239381d46af2f8a8074c..000a3185af88dcea75c0a656c10b7ca321480bb8 100644 --- builder/builder/eth_service_test.go +++ bolt-builder/builder/eth_service_test.go @@ -103,7 +103,7 @@ require.Equal(t, parent.Time+1, executableData.ExecutionPayload.Timestamp) require.Equal(t, block.ParentHash(), parent.Hash()) require.Equal(t, block.Hash(), executableData.ExecutionPayload.BlockHash) require.Equal(t, blockValue.Uint64(), uint64(0)) - }) + }, nil)   require.NoError(t, err) }

This is where the actual block building logic is located.

We added a constraintsCache to the miner, which is responsible for keeping an always-updated view of the constraints streamed from relays according to the Constraints API Relay specs. It’s passed to the miner from the entrypoint in the builder/ module.

At block building time, we check if there are any transactions in the cache for this slot, and if so we insert them at the top of the block. This is a naive implementation that can be improved, but it shows the concept of the builder role.

diff --git builder/miner/algo_common_test.go bolt-builder/miner/algo_common_test.go index 1b4853863eef1137a4bb83492a1e0e3fd7247180..105c709b2aa22b38bb20922e0a76474688138b55 100644 --- builder/miner/algo_common_test.go +++ bolt-builder/miner/algo_common_test.go @@ -528,13 +528,14 @@ t.Cleanup(func() { testConfig.AlgoType = ALGO_MEV_GETH })   - for _, algoType := range []AlgoType{ALGO_MEV_GETH, ALGO_GREEDY, ALGO_GREEDY_BUCKETS, ALGO_GREEDY_MULTISNAP, ALGO_GREEDY_BUCKETS_MULTISNAP} { + for _, algoType := range []AlgoType{ALGO_MEV_GETH} { local := new(params.ChainConfig) *local = *ethashChainConfig local.TerminalTotalDifficulty = big.NewInt(0) testConfig.AlgoType = algoType - testGetSealingWork(t, local, ethash.NewFaker()) + testGetSealingWork(t, local, ethash.NewFaker(), nil) } + t.Fail() }   func TestGetSealingWorkAlgosWithProfit(t *testing.T) {
diff --git builder/miner/multi_worker.go bolt-builder/miner/multi_worker.go index 797b277e8110c64c79528576b10f9e183e86aca1..415447d47ca379aae834701ceca2f8c404838580 100644 --- builder/miner/multi_worker.go +++ bolt-builder/miner/multi_worker.go @@ -93,15 +93,17 @@ // enough to run. The empty payload can at least make sure there is something // to deliver for not missing slot. var empty *newPayloadResult emptyParams := &generateParams{ - timestamp: args.Timestamp, - forceTime: true, - parentHash: args.Parent, - coinbase: args.FeeRecipient, - random: args.Random, - gasLimit: args.GasLimit, - withdrawals: args.Withdrawals, - beaconRoot: args.BeaconRoot, - noTxs: true, + timestamp: args.Timestamp, + forceTime: true, + parentHash: args.Parent, + coinbase: args.FeeRecipient, + random: args.Random, + gasLimit: args.GasLimit, + withdrawals: args.Withdrawals, + beaconRoot: args.BeaconRoot, + noTxs: true, + slot: args.Slot, + constraintsCache: args.ConstraintsCache, } for _, worker := range w.workers { empty = worker.getSealingBlock(emptyParams) @@ -130,16 +132,18 @@ for _, w := range w.workers { workerPayload := newPayload(empty.block, args.Id()) workerPayloads = append(workerPayloads, workerPayload) fullParams := &generateParams{ - timestamp: args.Timestamp, - forceTime: true, - parentHash: args.Parent, - coinbase: args.FeeRecipient, - random: args.Random, - withdrawals: args.Withdrawals, - beaconRoot: args.BeaconRoot, - gasLimit: args.GasLimit, - noTxs: false, - onBlock: args.BlockHook, + timestamp: args.Timestamp, + forceTime: true, + parentHash: args.Parent, + coinbase: args.FeeRecipient, + random: args.Random, + withdrawals: args.Withdrawals, + beaconRoot: args.BeaconRoot, + gasLimit: args.GasLimit, + noTxs: false, + onBlock: args.BlockHook, + slot: args.Slot, + constraintsCache: args.ConstraintsCache, }   go func(w *worker) {
diff --git builder/miner/payload_building.go bolt-builder/miner/payload_building.go index edd9e13c1176dca420a38b64128f91602649d8f9..ed3a4fe1c82c87771fb2df00cddbc76b9ba4bd25 100644 --- builder/miner/payload_building.go +++ bolt-builder/miner/payload_building.go @@ -23,6 +23,7 @@ "math/big" "sync" "time"   + "github.com/chainbound/shardmap" "github.com/ethereum/go-ethereum/beacon/engine" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" @@ -35,15 +36,17 @@ // BuildPayloadArgs contains the provided parameters for building payload. // Check engine-api specification for more details. // https://github.com/ethereum/execution-apis/blob/main/src/engine/cancun.md#payloadattributesv3 type BuildPayloadArgs struct { - Parent common.Hash // The parent block to build payload on top - Timestamp uint64 // The provided timestamp of generated payload - FeeRecipient common.Address // The provided recipient address for collecting transaction fee - Random common.Hash // The provided randomness value - Withdrawals types.Withdrawals // The provided withdrawals - BeaconRoot *common.Hash // The provided beaconRoot (Cancun) - Version engine.PayloadVersion // Versioning byte for payload id calculation. - GasLimit uint64 - BlockHook BlockHookFn + Parent common.Hash // The parent block to build payload on top + Timestamp uint64 // The provided timestamp of generated payload + FeeRecipient common.Address // The provided recipient address for collecting transaction fee + Random common.Hash // The provided randomness value + Withdrawals types.Withdrawals // The provided withdrawals + BeaconRoot *common.Hash // The provided beaconRoot (Cancun) + Version engine.PayloadVersion // Versioning byte for payload id calculation. + GasLimit uint64 + BlockHook BlockHookFn + Slot uint64 + ConstraintsCache *shardmap.FIFOMap[uint64, types.HashToConstraintDecoded] }   // Id computes an 8-byte identifier by hashing the components of the payload arguments. @@ -248,15 +251,17 @@ // Build the initial version with no transaction included. It should be fast // enough to run. The empty payload can at least make sure there is something // to deliver for not missing slot. emptyParams := &generateParams{ - timestamp: args.Timestamp, - forceTime: true, - parentHash: args.Parent, - coinbase: args.FeeRecipient, - random: args.Random, - withdrawals: args.Withdrawals, - beaconRoot: args.BeaconRoot, - noTxs: true, - onBlock: args.BlockHook, + timestamp: args.Timestamp, + forceTime: true, + parentHash: args.Parent, + coinbase: args.FeeRecipient, + random: args.Random, + withdrawals: args.Withdrawals, + beaconRoot: args.BeaconRoot, + noTxs: true, + onBlock: args.BlockHook, + slot: args.Slot, + constraintsCache: args.ConstraintsCache, } empty := w.getSealingBlock(emptyParams) if empty.err != nil { @@ -280,15 +285,17 @@ // by the timestamp parameter. endTimer := time.NewTimer(time.Second * 12)   fullParams := &generateParams{ - timestamp: args.Timestamp, - forceTime: true, - parentHash: args.Parent, - coinbase: args.FeeRecipient, - random: args.Random, - withdrawals: args.Withdrawals, - beaconRoot: args.BeaconRoot, - noTxs: false, - onBlock: args.BlockHook, + timestamp: args.Timestamp, + forceTime: true, + parentHash: args.Parent, + coinbase: args.FeeRecipient, + random: args.Random, + withdrawals: args.Withdrawals, + beaconRoot: args.BeaconRoot, + noTxs: false, + onBlock: args.BlockHook, + slot: args.Slot, + constraintsCache: args.ConstraintsCache, }   for {
diff --git builder/miner/worker.go bolt-builder/miner/worker.go index 09d46ed99f9f600550d979c31b582201ab4eef0a..2a69317c437a82e49e2db32d61e4b464a7c28289 100644 --- builder/miner/worker.go +++ bolt-builder/miner/worker.go @@ -25,6 +25,7 @@ "sync" "sync/atomic" "time"   + "github.com/chainbound/shardmap" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/consensus" "github.com/ethereum/go-ethereum/consensus/misc/eip1559" @@ -644,7 +645,7 @@ plainTxs := newTransactionsByPriceAndNonce(w.current.signer, txs, nil, nil, w.current.header.BaseFee) // Mixed bag of everrything, yolo blobTxs := newTransactionsByPriceAndNonce(w.current.signer, nil, nil, nil, w.current.header.BaseFee) // Empty bag, don't bother optimising   tcount := w.current.tcount - w.commitTransactions(w.current, plainTxs, blobTxs, nil) + w.commitTransactions(w.current, plainTxs, blobTxs, nil, nil)   // Only update the snapshot if any new transactions were added // to the pending block @@ -1017,14 +1018,39 @@ return nil }   -func (w *worker) commitTransactions(env *environment, plainTxs, blobTxs *transactionsByPriceAndNonce, interrupt *atomic.Int32) error { +// commitTransactions applies sorted transactions to the current environment, updating the state +// and creating the resulting block +// +// Assumptions: +// - there are no nonce-conflicting transactions between `plainTxs`, `blobTxs` and the constraints +// - all transaction are correctly signed +func (w *worker) commitTransactions(env *environment, plainTxs, blobTxs *transactionsByPriceAndNonce, constraints types.HashToConstraintDecoded, interrupt *atomic.Int32) error { gasLimit := env.header.GasLimit if env.gasPool == nil { env.gasPool = new(core.GasPool).AddGas(gasLimit) } var coalescedLogs []*types.Log   + // Here we initialize and track the constraints left to be executed along + // with their gas requirements + constraintsOrderedByNonceAndHashDesc, + constraintsTotalGasLeft, + constraintsTotalBlobGasLeft := types.ParseConstraintsDecoded(constraints) + + constraintsRecoveredOrderedByNonceAndHashDesc := make([]*types.TransactionEcRecovered, 0, len(constraintsOrderedByNonceAndHashDesc)) + for _, tx := range constraintsOrderedByNonceAndHashDesc { + // Error may be ignored here, see assumption + from, _ := types.Sender(env.signer, tx) + constraintsRecoveredOrderedByNonceAndHashDesc = append(constraintsRecoveredOrderedByNonceAndHashDesc, &types.TransactionEcRecovered{ + Transaction: tx, + Sender: from, + }) + } + for { + // `env.tcount` starts from 0 so it's correct to use it as the current index + currentTxIndex := uint64(env.tcount) + // Check interruption signal and abort building if it's fired. if interrupt != nil { if signal := interrupt.Load(); signal != commitInterruptNone { @@ -1036,102 +1062,176 @@ if env.gasPool.Gas() < params.TxGas { log.Trace("Not enough gas for further transactions", "have", env.gasPool, "want", params.TxGas) break } + + blobGasLeft := uint64(params.MaxBlobGasPerBlock - env.blobs*params.BlobTxBlobGasPerBlob) + // If we don't have enough blob space for any further blob transactions, // skip that list altogether - if !blobTxs.Empty() && env.blobs*params.BlobTxBlobGasPerBlob >= params.MaxBlobGasPerBlock { + if !blobTxs.Empty() && blobGasLeft <= 0 { log.Trace("Not enough blob space for further blob transactions") blobTxs.Clear() // Fall though to pick up any plain txs } // Retrieve the next transaction and abort if all done. var ( - ltx *txpool.LazyTransaction - txs *transactionsByPriceAndNonce - pltx *txpool.LazyTransaction - ptip *uint256.Int - bltx *txpool.LazyTransaction - btip *uint256.Int + lazyTx *txpool.LazyTransaction + txs *transactionsByPriceAndNonce + plainLazyTx *txpool.LazyTransaction + plainTxTip *uint256.Int + blobLazyTx *txpool.LazyTransaction + blobTxTip *uint256.Int )   - pTxWithMinerFee := plainTxs.Peek() - if pTxWithMinerFee != nil { - pltx = pTxWithMinerFee.Tx() - ptip = pTxWithMinerFee.fees + if pTxWithMinerFee := plainTxs.Peek(); pTxWithMinerFee != nil { + plainLazyTx = pTxWithMinerFee.Tx() + plainTxTip = pTxWithMinerFee.fees }   - bTxWithMinerFee := blobTxs.Peek() - if bTxWithMinerFee != nil { - bltx = bTxWithMinerFee.Tx() - btip = bTxWithMinerFee.fees + if bTxWithMinerFee := blobTxs.Peek(); bTxWithMinerFee != nil { + blobLazyTx = bTxWithMinerFee.Tx() + blobTxTip = bTxWithMinerFee.fees }   switch { - case pltx == nil: - txs, ltx = blobTxs, bltx - case bltx == nil: - txs, ltx = plainTxs, pltx + case plainLazyTx == nil: + txs, lazyTx = blobTxs, blobLazyTx + case blobLazyTx == nil: + txs, lazyTx = plainTxs, plainLazyTx default: - if ptip.Lt(btip) { - txs, ltx = blobTxs, bltx + if plainTxTip.Lt(blobTxTip) { + txs, lazyTx = blobTxs, blobLazyTx } else { - txs, ltx = plainTxs, pltx + txs, lazyTx = plainTxs, plainLazyTx } }   - if ltx == nil { - break - } - - // If we don't have enough space for the next transaction, skip the account. - if env.gasPool.Gas() < ltx.Gas { - log.Trace("Not enough gas left for transaction", "hash", ltx.Hash, "left", env.gasPool.Gas(), "needed", ltx.Gas) - txs.Pop() - continue + type candidateTx struct { + tx *types.Transaction + isConstraint bool } - if left := uint64(params.MaxBlobGasPerBlock - env.blobs*params.BlobTxBlobGasPerBlob); left < ltx.BlobGas { - log.Trace("Not enough blob gas left for transaction", "hash", ltx.Hash, "left", left, "needed", ltx.BlobGas) - txs.Pop() - continue + // candidate is the transaction we should execute in this cycle of the loop + var candidate struct { + tx *types.Transaction + isConstraint bool } - // Transaction seems to fit, pull it up from the pool - tx := ltx.Resolve() - if tx == nil { - log.Trace("Ignoring evicted transaction", "hash", ltx.Hash) - txs.Pop() - continue + + isSomePoolTxLeft := lazyTx != nil + var from common.Address + + if isSomePoolTxLeft { + // Check if there enough gas left for this tx + if constraintsTotalGasLeft+lazyTx.Gas > env.gasPool.Gas() || constraintsTotalBlobGasLeft+lazyTx.BlobGas > blobGasLeft { + // Skip this tx and try to fit one with less gas. + // Drop all consecutive transactions from the same sender because of `nonce-too-high` clause. + log.Debug("Could not find transactions gas with the remaining constraints, account skipped", "hash", lazyTx.Hash) + txs.Pop() + // Edge case: + // + // Assumption: suppose sender A sends tx T_1 with nonce 1, and T_2 with nonce 2, and T_2 is a constraint. + // + // + // When running the block building algorithm I first have to make sure to reserve enough gas for the constraints. + // This implies that when a pooled tx comes I have to check if there is enough gas for it while taking into account + // the rest of the remaining constraint gas to allocate. + // Suppose there is no gas for the pooled tx T_1, then I have to drop it and consequently drop every tx from the same + // sender with higher nonce due to "nonce-too-high" issues, including T_2. + // But then, I have dropped a constraint which means my bid is invalid. + // + // NOTE: this actually cannot happen because the sidecar accept constraints considering the previous block + // state and not pending transactions. So this setting would be rejected by the sidecar with `NonceTooHigh` + // error. A scenario like T_1 is a constraint and T_2 is not is possible instead and correctly handled (see below). + + // Repeat the loop to try to find another pool transaction + continue + } + + // We can safely consider the pool tx as the candidate, + // since by assumption it is not nonce-conflicting. + tx := lazyTx.Resolve() + if tx == nil { + log.Trace("Ignoring evicted transaction", "hash", candidate.tx.Hash()) + txs.Pop() + continue + } + + // Error may be ignored here, see assumption + from, _ = types.Sender(env.signer, tx) + + // We cannot choose this pooled tx yet, we need to make sure that there is not a constraint with lower nonce. + // That is, a scenario where T_1 is a constraint and T_2 is pooled. + constraintsBySender := append(constraintsRecoveredOrderedByNonceAndHashDesc, []*types.TransactionEcRecovered{}...) + common.Filter(&constraintsBySender, func(txRecovered *types.TransactionEcRecovered) bool { + return txRecovered.Sender == from + }) + + // The slice might be empty so the last value might be nil! + lowestNonceConstraintBySender := common.Last(constraintsBySender) + if lowestNonceConstraintBySender != nil && lowestNonceConstraintBySender.Transaction.Nonce() < tx.Nonce() { + // This means that the constraint with the lowest nonce from this sender + // has lower nonce than the pooled tx, so we cannot execute the pooled tx yet. + // We need to execute the constraint first. + candidate = candidateTx{tx: lowestNonceConstraintBySender.Transaction, isConstraint: true} + } else { + candidate = candidateTx{tx: tx, isConstraint: false} + } + } else { + // No more pool tx left, we can add the unindexed ones if available + if len(constraintsRecoveredOrderedByNonceAndHashDesc) == 0 { + // To recap, this means: + // 1. there are no more pool tx left + // 2. there are no more constraints + // As such, we can safely exist + break + } + from = common.Last(constraintsRecoveredOrderedByNonceAndHashDesc).Sender + candidate = candidateTx{tx: common.Pop(&constraintsRecoveredOrderedByNonceAndHashDesc).Transaction, isConstraint: true} } - // Error may be ignored here. The error has already been checked - // during transaction acceptance is the transaction pool. - from, _ := types.Sender(env.signer, tx)   // Check whether the tx is replay protected. If we're not in the EIP155 hf // phase, start ignoring the sender until we do. - if tx.Protected() && !w.chainConfig.IsEIP155(env.header.Number) { - log.Trace("Ignoring replay protected transaction", "hash", ltx.Hash, "eip155", w.chainConfig.EIP155Block) + if candidate.tx.Protected() && !w.chainConfig.IsEIP155(env.header.Number) { + log.Trace("Ignoring replay protected transaction", "hash", candidate.tx.Hash(), "eip155", w.chainConfig.EIP155Block) txs.Pop() continue } // Start executing the transaction - env.state.SetTxContext(tx.Hash(), env.tcount) + env.state.SetTxContext(candidate.tx.Hash(), env.tcount)   - logs, err := w.commitTransaction(env, tx) + logs, err := w.commitTransaction(env, candidate.tx) switch { case errors.Is(err, core.ErrNonceTooLow): // New head notification data race between the transaction pool and miner, shift - log.Trace("Skipping transaction with low nonce", "hash", ltx.Hash, "sender", from, "nonce", tx.Nonce()) - txs.Shift() + log.Trace("Skipping transaction with low nonce", "hash", candidate.tx.Hash(), "sender", from, "nonce", candidate.tx.Nonce()) + if candidate.isConstraint { + log.Warn(fmt.Sprintf("Skipping constraint with low nonce, hash %s, sender %s, nonce %d", candidate.tx.Hash(), from, candidate.tx.Nonce())) + } else { + txs.Shift() + }   case errors.Is(err, nil): // Everything ok, collect the logs and shift in the next transaction from the same account coalescedLogs = append(coalescedLogs, logs...) env.tcount++ - txs.Shift() + if candidate.isConstraint { + // Update the amount of gas left for the constraints + constraintsTotalGasLeft -= candidate.tx.Gas() + constraintsTotalBlobGasLeft -= candidate.tx.BlobGas() + + constraintTip, _ := candidate.tx.EffectiveGasTip(env.header.BaseFee) + log.Info(fmt.Sprintf("Executed constraint %s at index %d with effective gas tip %d", candidate.tx.Hash().String(), currentTxIndex, constraintTip)) + } else { + txs.Shift() + }   default: // Transaction is regarded as invalid, drop all consecutive transactions from // the same sender because of `nonce-too-high` clause. - log.Debug("Transaction failed, account skipped", "hash", ltx.Hash, "err", err) - txs.Pop() + log.Debug("Transaction failed, account skipped", "hash", candidate.tx.Hash(), "err", err) + if candidate.isConstraint { + log.Warn("Constraint failed, account skipped", "hash", candidate.tx.Hash(), "err", err) + } else { + txs.Pop() + } } } if !w.isRunning() && len(coalescedLogs) > 0 { @@ -1154,16 +1254,18 @@ }   // generateParams wraps various of settings for generating sealing task. type generateParams struct { - timestamp uint64 // The timestamp for sealing task - forceTime bool // Flag whether the given timestamp is immutable or not - parentHash common.Hash // Parent block hash, empty means the latest chain head - coinbase common.Address // The fee recipient address for including transaction - gasLimit uint64 // The validator's requested gas limit target - random common.Hash // The randomness generated by beacon chain, empty before the merge - withdrawals types.Withdrawals // List of withdrawals to include in block. - beaconRoot *common.Hash // The beacon root (cancun field). - noTxs bool // Flag whether an empty block without any transaction is expected - onBlock BlockHookFn // Callback to call for each produced block + timestamp uint64 // The timestamp for sealing task + forceTime bool // Flag whether the given timestamp is immutable or not + parentHash common.Hash // Parent block hash, empty means the latest chain head + coinbase common.Address // The fee recipient address for including transaction + gasLimit uint64 // The validator's requested gas limit target + random common.Hash // The randomness generated by beacon chain, empty before the merge + withdrawals types.Withdrawals // List of withdrawals to include in block. + beaconRoot *common.Hash // The beacon root (cancun field). + noTxs bool // Flag whether an empty block without any transaction is expected + onBlock BlockHookFn // Callback to call for each produced block + slot uint64 // The slot in which the block is being produced + constraintsCache *shardmap.FIFOMap[uint64, types.HashToConstraintDecoded] // The constraints to include in the block }   func doPrepareHeader(genParams *generateParams, chain *core.BlockChain, config *Config, chainConfig *params.ChainConfig, extra []byte, engine consensus.Engine) (*types.Header, *types.Header, error) { @@ -1266,7 +1368,7 @@ } return env, nil }   -func (w *worker) fillTransactionsSelectAlgo(interrupt *atomic.Int32, env *environment) ([]types.SimulatedBundle, []types.SimulatedBundle, []types.UsedSBundle, map[common.Hash]struct{}, error) { +func (w *worker) fillTransactionsSelectAlgo(interrupt *atomic.Int32, env *environment, constraints types.HashToConstraintDecoded) ([]types.SimulatedBundle, []types.SimulatedBundle, []types.UsedSBundle, map[common.Hash]struct{}, error) { var ( blockBundles []types.SimulatedBundle allBundles []types.SimulatedBundle @@ -1274,21 +1376,35 @@ usedSbundles []types.UsedSBundle mempoolTxHashes map[common.Hash]struct{} err error ) - switch w.flashbots.algoType { - case ALGO_GREEDY, ALGO_GREEDY_BUCKETS, ALGO_GREEDY_MULTISNAP, ALGO_GREEDY_BUCKETS_MULTISNAP: + + // switch w.flashbots.algoType { + // + // case ALGO_GREEDY, ALGO_GREEDY_BUCKETS, ALGO_GREEDY_MULTISNAP, ALGO_GREEDY_BUCKETS_MULTISNAP: + // + // blockBundles, allBundles, usedSbundles, mempoolTxHashes, err = w.fillTransactionsAlgoWorker(interrupt, env) + // blockBundles, allBundles, mempoolTxHashes, err = w.fillTransactions(interrupt, env, constraints) + // case ALGO_MEV_GETH: + // blockBundles, allBundles, mempoolTxHashes, err = w.fillTransactions(interrupt, env, constraints) + // default: + // blockBundles, allBundles, mempoolTxHashes, err = w.fillTransactions(interrupt, env, constraints) + // } + + // // FIXME: (BOLT) the greedy algorithms do not support the constraints interface at the moment. + // // As such for this PoC we will be always using the MEV GETH algorithm regardless of the worker configuration. + if len(constraints) > 0 { + blockBundles, allBundles, mempoolTxHashes, err = w.fillTransactions(interrupt, env, constraints) + } else { blockBundles, allBundles, usedSbundles, mempoolTxHashes, err = w.fillTransactionsAlgoWorker(interrupt, env) - case ALGO_MEV_GETH: - blockBundles, allBundles, mempoolTxHashes, err = w.fillTransactions(interrupt, env) - default: - blockBundles, allBundles, mempoolTxHashes, err = w.fillTransactions(interrupt, env) } + return blockBundles, allBundles, usedSbundles, mempoolTxHashes, err }   // fillTransactions retrieves the pending transactions from the txpool and fills them // into the given sealing block. The transaction selection and ordering strategy can // be customized with the plugin in the future. -func (w *worker) fillTransactions(interrupt *atomic.Int32, env *environment) ([]types.SimulatedBundle, []types.SimulatedBundle, map[common.Hash]struct{}, error) { +func (w *worker) fillTransactions(interrupt *atomic.Int32, env *environment, constraints types.HashToConstraintDecoded) ([]types.SimulatedBundle, []types.SimulatedBundle, map[common.Hash]struct{}, error) { + log.Info(fmt.Sprintf("Filling transactions with %d constraints:", len(constraints))) w.mu.RLock() tip := w.tip w.mu.RUnlock() @@ -1304,6 +1420,12 @@ mempoolTxHashes[tx.Hash] = struct{}{} } }   + // NOTE: as done with builder txs, we need to fill mempoolTxHashes with the constraints hashes + // in order to pass block validation + for hash := range constraints { + mempoolTxHashes[hash] = struct{}{} + } + if env.header.BaseFee != nil { filter.BaseFee = uint256.MustFromBig(env.header.BaseFee) } @@ -1316,6 +1438,45 @@ filter.OnlyPlainTxs, filter.OnlyBlobTxs = false, true pendingBlobTxs := w.eth.TxPool().Pending(filter)   + // Drop all transactions that conflict with the constraints (sender, nonce) + signerAndNonceOfConstraints := make(map[common.Address]uint64) + + for _, tx := range constraints { + from, err := types.Sender(env.signer, tx) + log.Info(fmt.Sprintf("Inside fillTransactions, constraint %s from %s", tx.Hash().String(), from.String())) + if err != nil { + // NOTE: is this the right behaviour? If this happens the builder is not able to + // produce a valid bid + log.Error("Failed to recover sender from constraint. Skipping constraint", "err", err) + continue + } + + signerAndNonceOfConstraints[from] = tx.Nonce() + } + for sender, lazyTxs := range pendingPlainTxs { + common.Filter(&lazyTxs, func(lazyTx *txpool.LazyTransaction) bool { + if nonce, ok := signerAndNonceOfConstraints[sender]; ok { + if lazyTx.Tx.Nonce() == nonce { + return false + } + } + + return true + }) + } + + for sender, lazyTxs := range pendingBlobTxs { + common.Filter(&lazyTxs, func(lazyTx *txpool.LazyTransaction) bool { + if nonce, ok := signerAndNonceOfConstraints[sender]; ok { + if lazyTx.Tx.Nonce() == nonce { + return false + } + } + + return true + }) + } + // Split the pending transactions into locals and remotes. localPlainTxs, remotePlainTxs := make(map[common.Address][]*txpool.LazyTransaction), pendingPlainTxs localBlobTxs, remoteBlobTxs := make(map[common.Address][]*txpool.LazyTransaction), pendingBlobTxs @@ -1333,48 +1494,49 @@ }   var blockBundles []types.SimulatedBundle var allBundles []types.SimulatedBundle - if w.flashbots.isFlashbots { - bundles, ccBundleCh := w.eth.TxPool().MevBundles(env.header.Number, env.header.Time) - bundles = append(bundles, <-ccBundleCh...) - - var ( - bundleTxs []*types.Transaction - resultingBundle simulatedBundle - mergedBundles []types.SimulatedBundle - numBundles int - err error - ) - // Sets allBundles in outer scope - bundleTxs, resultingBundle, mergedBundles, numBundles, allBundles, err = w.generateFlashbotsBundle(env, bundles, pending) - if err != nil { - log.Error("Failed to generate flashbots bundle", "err", err) - return nil, nil, nil, err - } - log.Info("Flashbots bundle", "ethToCoinbase", ethIntToFloat(resultingBundle.TotalEth), "gasUsed", resultingBundle.TotalGasUsed, "bundleScore", resultingBundle.MevGasPrice, "bundleLength", len(bundleTxs), "numBundles", numBundles, "worker", w.flashbots.maxMergedBundles) - if len(bundleTxs) == 0 { - return nil, nil, nil, errors.New("no bundles to apply") - } - if err := w.commitBundle(env, bundleTxs, interrupt); err != nil { - return nil, nil, nil, err - } - blockBundles = mergedBundles - env.profit.Add(env.profit, resultingBundle.EthSentToCoinbase) - } + // if w.flashbots.isFlashbots { + // bundles, ccBundleCh := w.eth.TxPool().MevBundles(env.header.Number, env.header.Time) + // bundles = append(bundles, <-ccBundleCh...) + // + // var ( + // bundleTxs []*types.Transaction + // resultingBundle simulatedBundle + // mergedBundles []types.SimulatedBundle + // numBundles int + // err error + // ) + // // Sets allBundles in outer scope + // bundleTxs, resultingBundle, mergedBundles, numBundles, allBundles, err = w.generateFlashbotsBundle(env, bundles, pending) + // if err != nil { + // log.Error("Failed to generate flashbots bundle", "err", err) + // return nil, nil, nil, err + // } + // log.Info("Flashbots bundle", "ethToCoinbase", ethIntToFloat(resultingBundle.TotalEth), "gasUsed", resultingBundle.TotalGasUsed, "bundleScore", resultingBundle.MevGasPrice, "bundleLength", len(bundleTxs), "numBundles", numBundles, "worker", w.flashbots.maxMergedBundles) + // if len(bundleTxs) == 0 { + // log.Info("No bundles to apply") + // return nil, nil, nil, errors.New("no bundles to apply") + // } + // if err := w.commitBundle(env, bundleTxs, interrupt); err != nil { + // return nil, nil, nil, err + // } + // blockBundles = mergedBundles + // env.profit.Add(env.profit, resultingBundle.EthSentToCoinbase) + // }   // Fill the block with all available pending transactions. - if len(localPlainTxs) > 0 || len(localBlobTxs) > 0 { + if len(localPlainTxs) > 0 || len(localBlobTxs) > 0 || len(constraints) > 0 { plainTxs := newTransactionsByPriceAndNonce(env.signer, localPlainTxs, nil, nil, env.header.BaseFee) blobTxs := newTransactionsByPriceAndNonce(env.signer, localBlobTxs, nil, nil, env.header.BaseFee)   - if err := w.commitTransactions(env, plainTxs, blobTxs, interrupt); err != nil { + if err := w.commitTransactions(env, plainTxs, blobTxs, constraints, interrupt); err != nil { return nil, nil, nil, err } } - if len(remotePlainTxs) > 0 || len(remoteBlobTxs) > 0 { + if len(remotePlainTxs) > 0 || len(remoteBlobTxs) > 0 || len(constraints) > 0 { plainTxs := newTransactionsByPriceAndNonce(env.signer, remotePlainTxs, nil, nil, env.header.BaseFee) blobTxs := newTransactionsByPriceAndNonce(env.signer, remoteBlobTxs, nil, nil, env.header.BaseFee)   - if err := w.commitTransactions(env, plainTxs, blobTxs, interrupt); err != nil { + if err := w.commitTransactions(env, plainTxs, blobTxs, constraints, interrupt); err != nil { return nil, nil, nil, err } } @@ -1400,6 +1562,7 @@ } // Split the pending transactions into locals and remotes // Fill the block with all available pending transactions. pending := w.eth.TxPool().Pending(filter) + mempoolTxHashes := make(map[common.Hash]struct{}, len(pending)) for _, txs := range pending { for _, tx := range txs { @@ -1587,11 +1750,25 @@ }   orderCloseTime := time.Now()   - blockBundles, allBundles, usedSbundles, mempoolTxHashes, err := w.fillTransactionsSelectAlgo(nil, work) + var constraints types.HashToConstraintDecoded + + if params.constraintsCache != nil { + constraints, _ = params.constraintsCache.Get(params.slot) + log.Info(fmt.Sprintf("[BOLT]: found %d constraints for slot %d ", len(constraints), params.slot)) + } + + blockBundles, allBundles, usedSbundles, mempoolTxHashes, err := w.fillTransactionsSelectAlgo(nil, work, constraints) if err != nil { return &newPayloadResult{err: err} }   + // NOTE: as done with builder txs, we need to fill mempoolTxHashes with the constraints hashes + // in order to pass block validation. Otherwise the constraints will be rejected as unknown + // because they not part of the mempool and not part of the known bundles + for hash := range constraints { + mempoolTxHashes[hash] = struct{}{} + } + // We mark transactions created by the builder as mempool transactions so code validating bundles will not fail // for transactions created by the builder such as mev share refunds. for _, tx := range work.txs { @@ -1645,6 +1822,8 @@ return block, blockProfit, nil }   +// checkProposerPayment checks that the last transaction in the block is targeting the +// validator coinbase and returns the block profit equal to the value of the last transaction. func (w *worker) checkProposerPayment(work *environment, validatorCoinbase common.Address) (*big.Int, error) { if len(work.txs) == 0 { return nil, errors.New("no proposer payment tx") @@ -1694,7 +1873,7 @@ return }   // Fill pending transactions from the txpool - _, _, _, _, err = w.fillTransactionsSelectAlgo(interrupt, work) + _, _, _, _, err = w.fillTransactionsSelectAlgo(interrupt, work, nil) switch { case err == nil: // The entire block is filled, decrease resubmit interval in case @@ -2198,6 +2377,8 @@ w.mu.Lock() sender := w.coinbase w.mu.Unlock() builderBalance := env.state.GetBalance(sender).ToBig() + + log.Info(fmt.Sprintf("[BOLT]: builderBalance %v, reserve.builderBalance %v", builderBalance, reserve.builderBalance))   availableFunds := new(big.Int).Sub(builderBalance, reserve.builderBalance) if availableFunds.Sign() <= 0 {
diff --git builder/miner/worker_test.go bolt-builder/miner/worker_test.go index d65ad578de31558b667c7934cb7581751853fa8f..bf5344876ae3ce499b380856edc2dad72edc010f 100644 --- builder/miner/worker_test.go +++ bolt-builder/miner/worker_test.go @@ -24,6 +24,7 @@ "sync/atomic" "testing" "time"   + "github.com/chainbound/shardmap" "github.com/ethereum/go-ethereum/accounts" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/consensus" @@ -77,6 +78,9 @@ // Test transactions pendingTxs []*types.Transaction newTxs []*types.Transaction   + // Test testConstraintsCache + testConstraintsCache = new(shardmap.FIFOMap[uint64, types.HashToConstraintDecoded]) + testConfig = &Config{ Recommit: time.Second, GasCeil: params.GenesisGasLimit, @@ -84,6 +88,8 @@ }   defaultGenesisAlloc = types.GenesisAlloc{testBankAddress: {Balance: testBankFunds}} ) + +const pendingTxsLen = 50   func init() { testTxPoolConfig = legacypool.DefaultConfig @@ -98,15 +104,32 @@ Epoch: 30000, }   signer := types.LatestSigner(params.TestChainConfig) - tx1 := types.MustSignNewTx(testBankKey, signer, &types.AccessListTx{ - ChainID: params.TestChainConfig.ChainID, - Nonce: 0, - To: &testUserAddress, - Value: big.NewInt(1000), - Gas: params.TxGas, - GasPrice: big.NewInt(params.InitialBaseFee), - }) - pendingTxs = append(pendingTxs, tx1) + for i := 0; i < pendingTxsLen; i++ { + tx1 := types.MustSignNewTx(testBankKey, signer, &types.AccessListTx{ + ChainID: params.TestChainConfig.ChainID, + Nonce: uint64(i), + To: &testUserAddress, + Value: big.NewInt(1000), + Gas: params.TxGas, + GasPrice: big.NewInt(params.InitialBaseFee), + }) + + // Add some constraints every 3 txs, and every 6 add an index + if i%3 == 0 { + idx := new(uint64) + if i%2 == 0 { + *idx = uint64(i) + } else { + idx = nil + } + constraints := make(map[common.Hash]*types.Transaction) + constraints[tx1.Hash()] = tx1 + // FIXME: slot 0 is probably not correct for these tests + testConstraintsCache.Put(0, constraints) + } + + pendingTxs = append(pendingTxs, tx1) + }   tx2 := types.MustSignNewTx(testBankKey, signer, &types.LegacyTx{ Nonce: 1, @@ -130,7 +153,7 @@ func newTestWorkerBackend(t *testing.T, chainConfig *params.ChainConfig, engine consensus.Engine, db ethdb.Database, alloc types.GenesisAlloc, n int, gasLimit uint64) *testWorkerBackend { if alloc == nil { alloc = defaultGenesisAlloc } - var gspec = &core.Genesis{ + gspec := &core.Genesis{ Config: chainConfig, GasLimit: gasLimit, Alloc: alloc, @@ -251,10 +274,10 @@ w, _ := newTestWorker(t, chainConfig, engine, rawdb.NewMemoryDatabase(), nil, 0) defer w.close()   - taskCh := make(chan struct{}, 2) + taskCh := make(chan struct{}, pendingTxsLen*2) checkEqual := func(t *testing.T, task *task) { // The work should contain 1 tx - receiptLen, balance := 1, uint256.NewInt(1000) + receiptLen, balance := pendingTxsLen, uint256.NewInt(50_000) if len(task.receipts) != receiptLen { t.Fatalf("receipt number mismatch: have %d, want %d", len(task.receipts), receiptLen) } @@ -378,12 +401,12 @@ }   func TestGetSealingWorkEthash(t *testing.T) { t.Parallel() - testGetSealingWork(t, ethashChainConfig, ethash.NewFaker()) + testGetSealingWork(t, ethashChainConfig, ethash.NewFaker(), nil) }   func TestGetSealingWorkClique(t *testing.T) { t.Parallel() - testGetSealingWork(t, cliqueChainConfig, clique.New(cliqueChainConfig.Clique, rawdb.NewMemoryDatabase())) + testGetSealingWork(t, cliqueChainConfig, clique.New(cliqueChainConfig.Clique, rawdb.NewMemoryDatabase()), nil) }   func TestGetSealingWorkPostMerge(t *testing.T) { @@ -391,10 +414,25 @@ t.Parallel() local := new(params.ChainConfig) *local = *ethashChainConfig local.TerminalTotalDifficulty = big.NewInt(0) - testGetSealingWork(t, local, ethash.NewFaker()) + testGetSealingWork(t, local, ethash.NewFaker(), nil) }   -func testGetSealingWork(t *testing.T, chainConfig *params.ChainConfig, engine consensus.Engine) { +// TestGetSealingWorkWithConstraints tests the getSealingWork function with constraints. +// This is the main test for the modified block building algorithm. Unfortunately +// is not easy to make an end to end test where the constraints are pulled from the relay. +// +// A suggestion is to walk through the executing code with a debugger to further inspect the algorithm. +// +// However, if you want to check that functionality see `builder_test.go` +func TestGetSealingWorkWithConstraints(t *testing.T) { + // t.Parallel() + local := new(params.ChainConfig) + *local = *ethashChainConfig + local.TerminalTotalDifficulty = big.NewInt(0) + testGetSealingWork(t, local, ethash.NewFaker(), testConstraintsCache) +} + +func testGetSealingWork(t *testing.T, chainConfig *params.ChainConfig, engine consensus.Engine, constraintsCache *shardmap.FIFOMap[uint64, types.HashToConstraintDecoded]) { defer engine.Close() w, b := newTestWorker(t, chainConfig, engine, rawdb.NewMemoryDatabase(), nil, 0) defer w.close() @@ -486,15 +524,16 @@ // This API should work even when the automatic sealing is not enabled for _, c := range cases { r := w.getSealingBlock(&generateParams{ - parentHash: c.parent, - timestamp: timestamp, - coinbase: c.coinbase, - random: c.random, - withdrawals: nil, - beaconRoot: nil, - noTxs: false, - forceTime: true, - onBlock: nil, + parentHash: c.parent, + timestamp: timestamp, + coinbase: c.coinbase, + random: c.random, + withdrawals: nil, + beaconRoot: nil, + noTxs: false, + forceTime: true, + onBlock: nil, + constraintsCache: constraintsCache, }) if c.expectErr { if r.err == nil {
diff --git builder/miner/algo_common_test.go bolt-builder/miner/algo_common_test.go index 1b4853863eef1137a4bb83492a1e0e3fd7247180..105c709b2aa22b38bb20922e0a76474688138b55 100644 --- builder/miner/algo_common_test.go +++ bolt-builder/miner/algo_common_test.go @@ -528,13 +528,14 @@ t.Cleanup(func() { testConfig.AlgoType = ALGO_MEV_GETH })   - for _, algoType := range []AlgoType{ALGO_MEV_GETH, ALGO_GREEDY, ALGO_GREEDY_BUCKETS, ALGO_GREEDY_MULTISNAP, ALGO_GREEDY_BUCKETS_MULTISNAP} { + for _, algoType := range []AlgoType{ALGO_MEV_GETH} { local := new(params.ChainConfig) *local = *ethashChainConfig local.TerminalTotalDifficulty = big.NewInt(0) testConfig.AlgoType = algoType - testGetSealingWork(t, local, ethash.NewFaker()) + testGetSealingWork(t, local, ethash.NewFaker(), nil) } + t.Fail() }   func TestGetSealingWorkAlgosWithProfit(t *testing.T) {

In the API backend, we don’t differentiate between private and public transactions for simplicity.

diff --git builder/eth/api_backend.go bolt-builder/eth/api_backend.go index ef2c444ba0acabde26dbc629783115446a9aeb08..170218725eafa10a7390ae521d164c1426d4cd8b 100644 --- builder/eth/api_backend.go +++ bolt-builder/eth/api_backend.go @@ -290,7 +290,7 @@ }   func (b *EthAPIBackend) SendTx(ctx context.Context, signedTx *types.Transaction, private bool) error { if private { - return b.eth.txPool.Add([]*types.Transaction{signedTx}, false, false, true)[0] + return b.eth.txPool.Add([]*types.Transaction{signedTx}, true, false, true)[0] } else { return b.eth.txPool.Add([]*types.Transaction{signedTx}, true, false, false)[0] }
diff --git builder/eth/block-validation/api_test.go bolt-builder/eth/block-validation/api_test.go index 4d8afc6fff1e732a3781b356e0217cfcb91fa736..4340e99b35bec87071841ad1f14af422ea814583 100644 --- builder/eth/block-validation/api_test.go +++ bolt-builder/eth/block-validation/api_test.go @@ -845,99 +845,6 @@ } return blockRequest, nil }   -func TestValidateBuilderSubmissionV2_CoinbasePaymentUnderflow(t *testing.T) { - genesis, preMergeBlocks := generatePreMergeChain(20) - lastBlock := preMergeBlocks[len(preMergeBlocks)-1] - time := lastBlock.Time() + 5 - genesis.Config.ShanghaiTime = &time - n, ethservice := startEthService(t, genesis, preMergeBlocks) - ethservice.Merger().ReachTTD() - defer n.Close() - - api := NewBlockValidationAPI(ethservice, nil, true, true) - - baseFee := eip1559.CalcBaseFee(ethservice.BlockChain().Config(), lastBlock.Header()) - txs := make(types.Transactions, 0) - - statedb, _ := ethservice.BlockChain().StateAt(lastBlock.Root()) - nonce := statedb.GetNonce(testAddr) - validatorNonce := statedb.GetNonce(testValidatorAddr) - signer := types.LatestSigner(ethservice.BlockChain().Config()) - - expectedProfit := uint64(0) - - tx1, _ := types.SignTx(types.NewTransaction(nonce, common.Address{0x16}, big.NewInt(10), 21000, big.NewInt(2*baseFee.Int64()), nil), signer, testKey) - txs = append(txs, tx1) - expectedProfit += 21000 * baseFee.Uint64() - - // this tx will use 56996 gas - tx2, _ := types.SignTx(types.NewContractCreation(nonce+1, new(big.Int), 1000000, big.NewInt(2*baseFee.Int64()), logCode), signer, testKey) - txs = append(txs, tx2) - expectedProfit += 56996 * baseFee.Uint64() - - tx3, _ := types.SignTx(types.NewTransaction(nonce+2, testAddr, big.NewInt(10), 21000, baseFee, nil), signer, testKey) - txs = append(txs, tx3) - - // Test transferring out more than the profit - toTransferOut := 2*expectedProfit - 21000*baseFee.Uint64() - tx4, _ := types.SignTx(types.NewTransaction(validatorNonce, testAddr, big.NewInt(int64(toTransferOut)), 21000, baseFee, nil), signer, testValidatorKey) - txs = append(txs, tx4) - expectedProfit += 7 - - withdrawals := []*types.Withdrawal{ - { - Index: 0, - Validator: 1, - Amount: 100, - Address: testAddr, - }, - { - Index: 1, - Validator: 1, - Amount: 100, - Address: testAddr, - }, - } - withdrawalsRoot := types.DeriveSha(types.Withdrawals(withdrawals), trie.NewStackTrie(nil)) - - buildBlockArgs := buildBlockArgs{ - parentHash: lastBlock.Hash(), - parentRoot: lastBlock.Root(), - feeRecipient: testValidatorAddr, - txs: txs, - random: common.Hash{}, - number: lastBlock.NumberU64() + 1, - gasLimit: lastBlock.GasLimit(), - timestamp: lastBlock.Time() + 5, - extraData: nil, - baseFeePerGas: baseFee, - withdrawals: withdrawals, - } - - execData, err := buildBlock(buildBlockArgs, ethservice.BlockChain()) - require.NoError(t, err) - - value := big.NewInt(int64(expectedProfit)) - - req, err := executableDataToBlockValidationRequest(execData, testValidatorAddr, value, withdrawalsRoot) - require.NoError(t, err) - require.ErrorContains(t, api.ValidateBuilderSubmissionV2(req), "payment tx not to the proposers fee recipient") - - // try to claim less profit than expected, should work - value.SetUint64(expectedProfit - 1) - - req, err = executableDataToBlockValidationRequest(execData, testValidatorAddr, value, withdrawalsRoot) - require.NoError(t, err) - require.ErrorContains(t, api.ValidateBuilderSubmissionV2(req), "payment tx not to the proposers fee recipient") - - // try to claim more profit than expected, should fail - value.SetUint64(expectedProfit + 1) - - req, err = executableDataToBlockValidationRequest(execData, testValidatorAddr, value, withdrawalsRoot) - require.NoError(t, err) - require.ErrorContains(t, api.ValidateBuilderSubmissionV2(req), "payment") -} - // This tests payment when the proposer fee recipient is the same as the coinbase func TestValidateBuilderSubmissionV2_CoinbasePaymentDefault(t *testing.T) { genesis, preMergeBlocks := generatePreMergeChain(20)

We added the ConstraintDecoded primitive type in the core module.

This is not the greatest place for this type but given that it uses common.Hash, Transaction and it’s used in both the builder package and the miner package, it should be ok here.

diff --git builder/core/blockchain.go bolt-builder/core/blockchain.go index e1b1ea1bca9d90158551583cb6f2e84612928faf..12639a34d6ded9dd74e231cadbe18973583c31c5 100644 --- builder/core/blockchain.go +++ bolt-builder/core/blockchain.go @@ -2494,14 +2494,13 @@ if err != nil { return err }   - feeRecipientBalanceAfter := new(uint256.Int).Set(statedb.GetBalance(feeRecipient)) - - amtBeforeOrWithdrawn := new(uint256.Int).Set(feeRecipientBalanceBefore) + feeRecipientBalanceDelta := new(uint256.Int).Set(statedb.GetBalance(feeRecipient)) + feeRecipientBalanceDelta.Sub(feeRecipientBalanceDelta, feeRecipientBalanceBefore) if excludeWithdrawals { for _, w := range block.Withdrawals() { if w.Address == feeRecipient { amount := new(uint256.Int).Mul(new(uint256.Int).SetUint64(w.Amount), uint256.NewInt(params.GWei)) - amtBeforeOrWithdrawn = amtBeforeOrWithdrawn.Add(amtBeforeOrWithdrawn, amount) + feeRecipientBalanceDelta.Sub(feeRecipientBalanceDelta, amount) } } } @@ -2530,10 +2529,7 @@ }   // Validate proposer payment   - if useBalanceDiffProfit && feeRecipientBalanceAfter.Cmp(amtBeforeOrWithdrawn) >= 0 { - feeRecipientBalanceDelta := new(uint256.Int).Set(feeRecipientBalanceAfter) - feeRecipientBalanceDelta = feeRecipientBalanceDelta.Sub(feeRecipientBalanceDelta, amtBeforeOrWithdrawn) - + if useBalanceDiffProfit { uint256ExpectedProfit, ok := uint256.FromBig(expectedProfit) if !ok { if feeRecipientBalanceDelta.Cmp(uint256ExpectedProfit) >= 0 {
diff --git builder/core/types/constraints.go bolt-builder/core/types/constraints.go new file mode 100644 index 0000000000000000000000000000000000000000..ea16c6128c79dd20c093921e1c8969bf46b8881f --- /dev/null +++ bolt-builder/core/types/constraints.go @@ -0,0 +1,305 @@ +package types + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/json" + "fmt" + "sort" + + builderSpec "github.com/attestantio/go-builder-client/spec" + consensusSpec "github.com/attestantio/go-eth2-client/spec" + bellatrixSpec "github.com/attestantio/go-eth2-client/spec/bellatrix" + capellaSpec "github.com/attestantio/go-eth2-client/spec/capella" + denebSpec "github.com/attestantio/go-eth2-client/spec/deneb" + + "github.com/attestantio/go-builder-client/api/deneb" + v1 "github.com/attestantio/go-builder-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/ethereum/go-ethereum/common" + ssz "github.com/ferranbt/fastssz" + "github.com/flashbots/go-boost-utils/bls" +) + +var ( + // ConstraintsDomainType is the expected signing domain mask for constraints-API related messages + ConstraintsDomainType = phase0.DomainType([4]byte{109, 109, 111, 67}) +) + +// NOTE: given that it uses `common.Hash`, `Transaction` and it's used in both +// the builder package and the miner package, here it's a good place for now + +type ( + HashToConstraintDecoded = map[common.Hash]*Transaction + TransactionEcRecovered = struct { + Transaction *Transaction + Sender common.Address + } +) + +// ParseConstraintsDecoded receives a map of constraints and returns +// - a slice of constraints sorted by nonce descending and hash descending +// - the total gas required by the constraints +// - the total blob gas required by the constraints +func ParseConstraintsDecoded(constraints HashToConstraintDecoded) ([]*Transaction, uint64, uint64) { + // Here we initialize and track the constraints left to be executed along + // with their gas requirements + constraintsOrdered := make([]*Transaction, 0, len(constraints)) + constraintsTotalGasLeft := uint64(0) + constraintsTotalBlobGasLeft := uint64(0) + + for _, constraint := range constraints { + constraintsOrdered = append(constraintsOrdered, constraint) + constraintsTotalGasLeft += constraint.Gas() + constraintsTotalBlobGasLeft += constraint.BlobGas() + } + + // Sorts the unindexed constraints by nonce ascending and by hash + sort.Slice(constraintsOrdered, func(i, j int) bool { + iNonce := constraintsOrdered[i].Nonce() + jNonce := constraintsOrdered[j].Nonce() + // Sort by hash + if iNonce == jNonce { + return constraintsOrdered[i].Hash().Cmp(constraintsOrdered[j].Hash()) > 0 // descending + } + return iNonce > jNonce // descending + }) + + return constraintsOrdered, constraintsTotalGasLeft, constraintsTotalBlobGasLeft +} + +// InclusionProof is a Merkle Multiproof of inclusion of a set of TransactionHashes +type InclusionProof struct { + TransactionHashes []common.Hash `json:"transaction_hashes"` + GeneralizedIndexes []uint64 `json:"generalized_indexes"` + MerkleHashes []*common.HexBytes `json:"merkle_hashes"` +} + +// InclusionProofFromMultiProof converts a fastssz.Multiproof into an InclusionProof, without +// filling the TransactionHashes +func InclusionProofFromMultiProof(mp *ssz.Multiproof) *InclusionProof { + merkleHashes := make([]*common.HexBytes, len(mp.Hashes)) + for i, h := range mp.Hashes { + merkleHashes[i] = new(common.HexBytes) + *(merkleHashes[i]) = h + } + + leaves := make([]*common.HexBytes, len(mp.Leaves)) + for i, h := range mp.Leaves { + leaves[i] = new(common.HexBytes) + *(leaves[i]) = h + } + generalIndexes := make([]uint64, len(mp.Indices)) + for i, idx := range mp.Indices { + generalIndexes[i] = uint64(idx) + } + return &InclusionProof{ + MerkleHashes: merkleHashes, + GeneralizedIndexes: generalIndexes, + } +} + +func (p *InclusionProof) String() string { + return common.JSONStringify(p) +} + +// A wrapper struct over `builderSpec.VersionedSubmitBlockRequest` +// to include constraint inclusion proofs +type VersionedSubmitBlockRequestWithProofs struct { + Proofs *InclusionProof + *builderSpec.VersionedSubmitBlockRequest +} + +// this is necessary, because the mev-boost-relay deserialization doesn't expect a "Version" and "Data" wrapper object +// for deserialization. Instead, it tries to decode the object into the "Deneb" version first and if that fails, it tries +// the "Capella" version. This is a workaround to make the deserialization work. +// +// NOTE(bolt): struct embedding of the VersionedSubmitBlockRequest is not possible for some reason because it causes the json +// encoding to omit the `proofs` field. Embedding all of the fields directly does the job. +func (v *VersionedSubmitBlockRequestWithProofs) MarshalJSON() ([]byte, error) { + switch v.Version { + case consensusSpec.DataVersionBellatrix: + return json.Marshal(struct { + Message *v1.BidTrace `json:"message"` + ExecutionPayload *bellatrixSpec.ExecutionPayload `json:"execution_payload"` + Signature phase0.BLSSignature `json:"signature"` + Proofs *InclusionProof `json:"proofs"` + }{ + Message: v.Bellatrix.Message, + ExecutionPayload: v.Bellatrix.ExecutionPayload, + Signature: v.Bellatrix.Signature, + Proofs: v.Proofs, + }) + case consensusSpec.DataVersionCapella: + return json.Marshal(struct { + Message *v1.BidTrace `json:"message"` + ExecutionPayload *capellaSpec.ExecutionPayload `json:"execution_payload"` + Signature phase0.BLSSignature `json:"signature"` + Proofs *InclusionProof `json:"proofs"` + }{ + Message: v.Capella.Message, + ExecutionPayload: v.Capella.ExecutionPayload, + Signature: v.Capella.Signature, + Proofs: v.Proofs, + }) + case consensusSpec.DataVersionDeneb: + return json.Marshal(struct { + Message *v1.BidTrace `json:"message"` + ExecutionPayload *denebSpec.ExecutionPayload `json:"execution_payload"` + Signature phase0.BLSSignature `json:"signature"` + Proofs *InclusionProof `json:"proofs"` + BlobsBundle *deneb.BlobsBundle `json:"blobs_bundle"` + }{ + Message: v.Deneb.Message, + ExecutionPayload: v.Deneb.ExecutionPayload, + Signature: v.Deneb.Signature, + BlobsBundle: v.Deneb.BlobsBundle, + Proofs: v.Proofs, + }) + } + + return nil, fmt.Errorf("unknown data version %d", v.Version) +} + +func (v *VersionedSubmitBlockRequestWithProofs) String() string { + return common.JSONStringify(v) +} + +// SignedConstraintsList are a list of proposer constraints that a builder must satisfy +// in order to produce a valid bid. This is not defined on the +// [spec](https://chainbound.github.io/bolt-docs/api/builder) +// but it's useful as an helper type +type SignedConstraintsList = []*SignedConstraints + +// Reference: https://chainbound.github.io/bolt-docs/api/builder +type SignedConstraints struct { + Message ConstraintsMessage `json:"message"` + Signature phase0.BLSSignature `json:"signature"` +} + +// Reference: https://chainbound.github.io/bolt-docs/api/builder +type ConstraintsMessage struct { + Pubkey phase0.BLSPubKey `json:"pubkey"` + Slot uint64 `json:"slot"` + Top bool `json:"top"` + Transactions []*Transaction // Custom marshal and unmarshal implemented below +} + +func (c *ConstraintsMessage) MarshalJSON() ([]byte, error) { + transactionBytes := make([]common.HexBytes, len(c.Transactions)) + for i, tx := range c.Transactions { + bytes, err := tx.MarshalBinary() + if err != nil { + return nil, err + } + + transactionBytes[i] = bytes + } + + type Alias ConstraintsMessage + return json.Marshal(&struct { + *Alias + Transactions []common.HexBytes `json:"transactions"` + }{ + Alias: (*Alias)(c), + Transactions: transactionBytes, + }) +} + +func (c *ConstraintsMessage) UnmarshalJSON(data []byte) error { + type Alias ConstraintsMessage + aux := &struct { + Transactions []common.HexBytes `json:"transactions"` + *Alias + }{ + Alias: (*Alias)(c), + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + c.Transactions = make([]*Transaction, len(aux.Transactions)) + for i, txBytes := range aux.Transactions { + tx := new(Transaction) + if err := tx.UnmarshalBinary(txBytes); err != nil { + return err + } + + c.Transactions[i] = tx + } + + return nil +} + +// Digest returns the sha256 digest of the constraints message. This is what needs to be signed. +func (c *SignedConstraints) Digest() []byte { + hasher := sha256.New() + // NOTE: ignoring errors here + slotBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(slotBytes, c.Message.Slot) + + var top byte + if c.Message.Top { + top = 1 + } else { + top = 0 + } + + hasher.Write(c.Message.Pubkey[:]) + hasher.Write(slotBytes) + hasher.Write([]byte{top}) + + for _, tx := range c.Message.Transactions { + hasher.Write(tx.Hash().Bytes()) + + } + + return hasher.Sum(nil) +} + +// VerifySignature verifies the signature of a signed constraints message. IMPORTANT: it uses the Bolt signing domain to +// verify the signature. +func (c *SignedConstraints) VerifySignature(pubkey phase0.BLSPubKey, domain phase0.Domain) (bool, error) { + signingData := phase0.SigningData{ObjectRoot: phase0.Root(c.Digest()), Domain: domain} + root, err := signingData.HashTreeRoot() + if err != nil { + return false, err + } + + return bls.VerifySignatureBytes(root[:], c.Signature[:], pubkey[:]) +} + +// List of signed delegations +type SignedDelegations = []*SignedDelegation + +type SignedDelegation struct { + Message Delegation `json:"message"` + Signature phase0.BLSSignature `json:"signature"` +} + +type Delegation struct { + ValidatorPubkey phase0.BLSPubKey `json:"validator_pubkey"` + DelegateePubkey phase0.BLSPubKey `json:"delegatee_pubkey"` +} + +// Digest returns the sha256 digest of the delegation. This is what needs to be signed. +func (d *SignedDelegation) Digest() []byte { + hasher := sha256.New() + // NOTE: ignoring errors here + hasher.Write(d.Message.ValidatorPubkey[:]) + hasher.Write(d.Message.DelegateePubkey[:]) + return hasher.Sum(nil) +} + +// VerifySignature verifies the signature of a signed delegation. IMPORTANT: it uses the Bolt signing domain to +// verify the signature. +func (d *SignedDelegation) VerifySignature(pubkey phase0.BLSPubKey, domain phase0.Domain) (bool, error) { + signingData := phase0.SigningData{ObjectRoot: phase0.Root(d.Digest()), Domain: domain} + root, err := signingData.HashTreeRoot() + if err != nil { + return false, err + } + + return bls.VerifySignatureBytes(root[:], d.Signature[:], pubkey[:]) +}

Common utilities and types used across all packages

diff --git builder/common/types.go bolt-builder/common/types.go index aadca87f82af89543de3387e24a90cba5fe1846f..b4dc187263ef756754031b2ed75a16ff465c26c6 100644 --- builder/common/types.go +++ bolt-builder/common/types.go @@ -475,3 +475,34 @@ } else { return err } } + +type HexBytes []byte + +// MarshalJSON implements json.Marshaler. +func (h HexBytes) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`"%#x"`, []byte(h))), nil +} + +// UnmarshalJSON implements json.Unmarshaler. +func (s *HexBytes) UnmarshalJSON(input []byte) error { + if len(input) == 0 { + return errors.New("input missing") + } + + if !bytes.HasPrefix(input, []byte{'"', '0', 'x'}) { + return errors.New("invalid prefix") + } + if !bytes.HasSuffix(input, []byte{'"'}) { + return errors.New("invalid suffix") + } + + src := input[3 : len(input)-1] + *s = make([]byte, hex.DecodedLen(len(src))) + + _, err := hex.Decode(*s, input[3:len(input)-1]) + if err != nil { + return err + } + + return nil +}
diff --git builder/common/utils.go bolt-builder/common/utils.go new file mode 100644 index 0000000000000000000000000000000000000000..2a1258aa894956f5a4f219986609230b5445d76b --- /dev/null +++ bolt-builder/common/utils.go @@ -0,0 +1,73 @@ +package common + +import "encoding/json" + +func Find[T any](slice []*T, predicate func(el *T) bool) *T { + for _, el := range slice { + if predicate(el) { + return el + } + } + return nil +} + +// Filter filters a slice in place, removing elements for which the predicate returns false. +func Filter[T any](slice *[]*T, predicate func(el *T) bool) { + if slice == nil { + return + } + + for i := 0; i < len(*slice); i++ { + el := (*slice)[i] + if !predicate(el) { + // Remove the element by slicing + if i == len(*slice)-1 { + *slice = (*slice)[:i] + } else { + *slice = append((*slice)[:i], (*slice)[i+1:]...) + } + i-- // Decrement index to adjust for the removed element + } + } +} + +func Last[T any](slice []*T) *T { + if len(slice) == 0 { + return nil + } + return slice[len(slice)-1] +} + +func Pop[T any](slice *[]*T) *T { + if slice == nil || len(*slice) == 0 { + return nil + } + el := (*slice)[len(*slice)-1] + *slice = (*slice)[:len(*slice)-1] + return el +} + +func Shift[T any](slice *[]*T) *T { + if slice == nil || len(*slice) == 0 { + return nil + } + el := (*slice)[0] + *slice = (*slice)[1:] + return el +} + +func Map[T any, U any](slice []*T, mapper func(el *T) *U) []*U { + result := make([]*U, len(slice)) + for i, el := range slice { + result[i] = mapper(el) + } + return result +} + +func JSONStringify(obj any) string { + b, err := json.Marshal(obj) + if err != nil { + return "" + } + return string(b) +}
diff --git builder/common/utils_test.go bolt-builder/common/utils_test.go new file mode 100644 index 0000000000000000000000000000000000000000..bbccefe2ebb1b900b90e4153f317f4e4c2b73f39 --- /dev/null +++ bolt-builder/common/utils_test.go @@ -0,0 +1,29 @@ +package common + +import "testing" + +func TestGenericFilter(t *testing.T) { + slice := []*int{new(int), new(int), new(int), new(int)} + for i := 0; i < len(slice); i++ { + *slice[i] = i + } + + Filter(&slice, func(el *int) bool { + return el != nil + }) + if len(slice) != 4 { + t.Errorf("Filter failed") + } + Filter(&slice, func(el *int) bool { + return *el%2 == 0 + }) + if len(slice) != 2 { + t.Errorf("Filter failed") + } + Filter(&slice, func(el *int) bool { + return el == nil + }) + if len(slice) != 0 { + t.Errorf("Filter failed") + } +}
diff --git builder/internal/ethapi/api.go bolt-builder/internal/ethapi/api.go index e3b04835e2a7f57af1499b4f617000b19551f6ab..f53a6fc61716e6b770c8a244511944e50f77f607 100644 --- builder/internal/ethapi/api.go +++ bolt-builder/internal/ethapi/api.go @@ -242,7 +242,7 @@ } pending, queue := s.b.TxPoolContent()   // Define a formatter to flatten a transaction into a string - var format = func(tx *types.Transaction) string { + format := func(tx *types.Transaction) string { if to := tx.To(); to != nil { return fmt.Sprintf("%s: %v wei + %v gas × %v wei", tx.To().Hex(), tx.Value(), tx.Gas(), tx.GasPrice()) } @@ -1755,20 +1755,21 @@ } if err := b.SendTx(ctx, tx, private); err != nil { return common.Hash{}, err } - // Print a log with full tx details for manual investigations and interventions - head := b.CurrentBlock() - signer := types.MakeSigner(b.ChainConfig(), head.Number, head.Time) - from, err := types.Sender(signer, tx) - if err != nil { - return common.Hash{}, err - } - - if tx.To() == nil { - addr := crypto.CreateAddress(from, tx.Nonce()) - log.Info("Submitted contract creation", "hash", tx.Hash().Hex(), "from", from, "nonce", tx.Nonce(), "contract", addr.Hex(), "value", tx.Value()) - } else { - log.Info("Submitted transaction", "hash", tx.Hash().Hex(), "from", from, "nonce", tx.Nonce(), "recipient", tx.To(), "value", tx.Value()) - } + // Print a log with full tx details for manual investigations and interventions. + // TODO: remove this log, too noisy + // head := b.CurrentBlock() + // signer := types.MakeSigner(b.ChainConfig(), head.Number, head.Time) + // from, err := types.Sender(signer, tx) + // if err != nil { + // return common.Hash{}, err + // } + // + // if tx.To() == nil { + // addr := crypto.CreateAddress(from, tx.Nonce()) + // log.Info("Submitted contract creation", "hash", tx.Hash().Hex(), "from", from, "nonce", tx.Nonce(), "contract", addr.Hex(), "value", tx.Value()) + // } else { + // log.Info("Submitted transaction", "hash", tx.Hash().Hex(), "from", from, "nonce", tx.Nonce(), "recipient", tx.To(), "value", tx.Value()) + // } return tx.Hash(), nil }   @@ -1952,11 +1953,11 @@ } matchTx := sendArgs.toTransaction()   // Before replacing the old transaction, ensure the _new_ transaction fee is reasonable. - var price = matchTx.GasPrice() + price := matchTx.GasPrice() if gasPrice != nil { price = gasPrice.ToInt() } - var gas = matchTx.Gas() + gas := matchTx.Gas() if gasLimit != nil { gas = uint64(*gasLimit) }
diff --git builder/internal/ethapi/transaction_args.go bolt-builder/internal/ethapi/transaction_args.go index bae1c68641594887b4a800c0f7bfd6af58326ecf..7b4606742764a82120b6e2d7f656cfb46dbf9f88 100644 --- builder/internal/ethapi/transaction_args.go +++ bolt-builder/internal/ethapi/transaction_args.go @@ -37,9 +37,7 @@ "github.com/ethereum/go-ethereum/rpc" "github.com/holiman/uint256" )   -var ( - maxBlobsPerTransaction = params.MaxBlobGasPerBlock / params.BlobTxBlobGasPerBlob -) +var maxBlobsPerTransaction = params.MaxBlobGasPerBlock / params.BlobTxBlobGasPerBlob   // TransactionArgs represents the arguments to construct a new transaction // or a message call. @@ -384,7 +382,8 @@ if args.Gas != nil { gas = uint64(*args.Gas) } if globalGasCap != 0 && globalGasCap < gas { - log.Warn("Caller gas above allowance, capping", "requested", gas, "cap", globalGasCap) + // TODO: remove this, but for now it's too noisy + // log.Warn("Caller gas above allowance, capping", "requested", gas, "cap", globalGasCap) gas = globalGasCap } var (
diff --git builder/Dockerfile bolt-builder/Dockerfile index ed69a04789678e839186208e04a2483b33b4d68c..c808c9d940fa1c217cea7e417241b53626d233a2 100644 --- builder/Dockerfile +++ bolt-builder/Dockerfile @@ -4,7 +4,7 @@ ARG VERSION="" ARG BUILDNUM=""   # Build Geth in a stock Go builder container -FROM golang:1.21-alpine as builder +FROM golang:1.22-alpine AS builder   RUN apk add --no-cache gcc musl-dev linux-headers git
diff --git builder/Dockerfile.alltools bolt-builder/Dockerfile.alltools index c317da25fa4870b8fd2189ccf0a679ddbe87384a..ddffb8ee1d1c4da5448c9ddbe845b0fe7fc16844 100644 --- builder/Dockerfile.alltools +++ bolt-builder/Dockerfile.alltools @@ -4,7 +4,7 @@ ARG VERSION="" ARG BUILDNUM=""   # Build Geth in a stock Go builder container -FROM golang:1.21-alpine as builder +FROM golang:1.22-alpine AS builder   RUN apk add --no-cache gcc musl-dev linux-headers git
diff --git builder/README.flashbots.md bolt-builder/README.flashbots.md new file mode 100644 index 0000000000000000000000000000000000000000..1c81484215b7304996dbd45d767c132a275ddeb3 --- /dev/null +++ bolt-builder/README.flashbots.md @@ -0,0 +1,283 @@ +[geth readme](README.original.md) + +# Flashbots Block Builder + +This project implements the Flashbots block builder, based on go-ethereum (geth). + +See also: https://docs.flashbots.net/flashbots-mev-boost/block-builders + +Run on your favorite network, including Mainnet, Goerli, Sepolia and local devnet. Instructions for running a pos local devnet can be found [here](https://github.com/avalonche/eth-pos-devnet). + +You will need to run a modified beacon node that sends a custom rpc call to trigger block building. You can use the modified [prysm fork](https://github.com/flashbots/prysm) for this. + +Test with [mev-boost](https://github.com/flashbots/mev-boost) and [mev-boost test cli](https://github.com/flashbots/mev-boost/tree/main/cmd/test-cli). + +Building `geth` requires both a Go (version 1.19 or later) and a C compiler. You can install +them using your favourite package manager. Once the dependencies are installed, run + +## How it works + +* Builder polls relay for the proposer registrations for the next epoch when block building is triggered +* If both local relay and remote relay are enabled, local relay will overwrite remote relay data. This is only meant for the testnets! + +## Limitations + +* Does not accept external blocks +* Does not have payload cache, only the latest block is available + +# Usage + +Configure geth for your network, it will become the block builder. + +Builder-related options: +``` +$ geth --help + + BUILDER + + --builder (default: false) + Enable the builder + + --builder.algotype value (default: "mev-geth") + Block building algorithm to use [=mev-geth] (mev-geth, greedy, greedy-buckets) + + --builder.beacon_endpoints value (default: "http://127.0.0.1:5052") + Comma separated list of beacon endpoints to connect to for beacon chain data + [$BUILDER_BEACON_ENDPOINTS] + + --builder.bellatrix_fork_version value (default: "0x02000000") + Bellatrix fork version. [$BUILDER_BELLATRIX_FORK_VERSION] + + --builder.blacklist value + Path to file containing blacklisted addresses, json-encoded list of strings. + Builder will ignore transactions that touch mentioned addresses. + + --builder.block_resubmit_interval value (default: "500ms") + Determines the interval at which builder will resubmit block submissions + [$FLASHBOTS_BUILDER_RATE_LIMIT_RESUBMIT_INTERVAL] + + --builder.cancellations (default: false) + Enable cancellations for the builder + + --builder.discard_revertible_tx_on_error (default: false) + When enabled, if a transaction submitted as part of a bundle in a send bundle + request has error on commit, and its hash is specified as one that can revert in + the request body, the builder will discard the hash of the failed transaction + from the submitted bundle.For additional details on the structure of the request + body, see + https://docs.flashbots.net/flashbots-mev-share/searchers/understanding-bundles#bundle-definition + [$FLASHBOTS_BUILDER_DISCARD_REVERTIBLE_TX_ON_ERROR] + + --builder.dry-run (default: false) + Builder only validates blocks without submission to the relay + + --builder.genesis_fork_version value (default: "0x00000000") + Gensis fork version. [$BUILDER_GENESIS_FORK_VERSION] + + --builder.genesis_validators_root value (default: "0x0000000000000000000000000000000000000000000000000000000000000000") + Genesis validators root of the network. [$BUILDER_GENESIS_VALIDATORS_ROOT] + + --builder.ignore_late_payload_attributes (default: false) + Builder will ignore all but the first payload attributes. Use if your CL sends + non-canonical head updates. + + --builder.listen_addr value (default: ":28545") + Listening address for builder endpoint [$BUILDER_LISTEN_ADDR] + + --builder.local_relay (default: false) + Enable the local relay + + --builder.no_bundle_fetcher (default: false) + Disable the bundle fetcher + + --builder.price_cutoff_percent value (default: 50) + flashbots - The minimum effective gas price threshold used for bucketing + transactions by price. For example if the top transaction in a list has an + effective gas price of 1000 wei and price_cutoff_percent is 10 (i.e. 10%), then + the minimum effective gas price included in the same bucket as the top + transaction is (1000 * 10%) = 100 wei. + NOTE: This flag is only used when + builder.algotype=greedy-buckets [$FLASHBOTS_BUILDER_PRICE_CUTOFF_PERCENT] + + --builder.rate_limit_duration value (default: "500ms") + Determines rate limit of events processed by builder. For example, a value of + "500ms" denotes that the builder processes events every 500ms. A duration string + is a possibly signed sequence of decimal numbers, each with optional fraction + and a unit suffix, such as "300ms", "-1.5h" or "2h45m" + [$FLASHBOTS_BUILDER_RATE_LIMIT_DURATION] + + --builder.rate_limit_max_burst value (default: 10) + Determines the maximum number of burst events the builder can accommodate at any + given point in time. [$FLASHBOTS_BUILDER_RATE_LIMIT_MAX_BURST] + + --builder.relay_secret_key value (default: "0x2fc12ae741f29701f8e30f5de6350766c020cb80768a0ff01e6838ffd2431e11") + Builder local relay API key used for signing headers [$BUILDER_RELAY_SECRET_KEY] + + --builder.remote_relay_endpoint value + Relay endpoint to connect to for validator registration data, if not provided + will expose validator registration locally [$BUILDER_REMOTE_RELAY_ENDPOINT] + + --builder.secondary_remote_relay_endpoints value + Comma separated relay endpoints to connect to for validator registration data + missing from the primary remote relay, and to push blocks for registrations + missing from or matching the primary [$BUILDER_SECONDARY_REMOTE_RELAY_ENDPOINTS] + + --builder.seconds_in_slot value (default: 12) + Set the number of seconds in a slot in the local relay + + --builder.secret_key value (default: "0x2fc12ae741f29701f8e30f5de6350766c020cb80768a0ff01e6838ffd2431e11") + Builder key used for signing blocks [$BUILDER_SECRET_KEY] + + --builder.slots_in_epoch value (default: 32) + Set the number of slots in an epoch in the local relay + + --builder.submission_offset value (default: 3s) + Determines the offset from the end of slot time that the builder will submit + blocks. For example, if a slot is 12 seconds long, and the offset is 2 seconds, + the builder will submit blocks at 10 seconds into the slot. + [$FLASHBOTS_BUILDER_SUBMISSION_OFFSET] + + --builder.validation_blacklist value + Path to file containing blacklisted addresses, json-encoded list of strings + + --builder.validation_use_balance_diff (default: false) + Block validation API will use fee recipient balance difference for profit + calculation. + + --builder.validator_checks (default: false) + Enable the validator checks + + MINER + + --miner.algotype value (default: "mev-geth") + [NOTE: Deprecated, please use builder.algotype instead] Block building algorithm + to use [=mev-geth] (mev-geth, greedy, greedy-buckets) + + --miner.blocklist value + [NOTE: Deprecated, please use builder.blacklist] flashbots - Path to JSON file with + list of blocked addresses. Miner will ignore txs that touch mentioned addresses. + + --miner.extradata value + Block extra data set by the miner (default = client version) + + --miner.price_cutoff_percent value (default: 50) + flashbots - The minimum effective gas price threshold used for bucketing + transactions by price. For example if the top transaction in a list has an + effective gas price of 1000 wei and price_cutoff_percent is 10 (i.e. 10%), then + the minimum effective gas price included in the same bucket as the top + transaction is (1000 * 10%) = 100 wei. + NOTE: This flag is only used when + miner.algotype=greedy-buckets + + METRICS + + --metrics.builder value (default: false) + Enable builder metrics collection and reporting +``` + +Environment variables: +``` +BUILDER_TX_SIGNING_KEY - private key of the builder used to sign payment transaction, must be the same as the coinbase address + +FLASHBOTS_BUILDER_RATE_LIMIT_DURATION - determines rate limit of events processed by builder; a duration string is a +possibly signed sequence of decimal numbers, each with optional fraction and a unit suffix, such as "300ms", "-1.5h" or "2h45m". + +FLASHBOTS_BUILDER_RATE_LIMIT_MAX_BURST - determines the maximum number of events the builder can accommodate at any point in time + +FLASHBOTS_BUILDER_RATE_LIMIT_RESUBMIT_INTERVAL - determines the interval at which builder will resubmit block submissions +``` + +## Metrics + +To enable metrics on the builder you will need to enable metrics with the flags `--metrics --metrics.addr 127.0.0.1 --metrics.builder` which will run +a metrics server serving at `127.0.0.1:6060/debug/metrics`. This will record performance metrics such as block profit and block building times. +The full list of metrics can be found in `miner/metrics.go`. + +See the [metrics docs](https://geth.ethereum.org/docs/monitoring/metrics) for geth for more documentation. + +## Blacklisting addresses + +If you want to reject transactions interacting with certain addresses, save the addresses in json file with an array of strings. Deciding whether to use such a list, as well as maintaining it, is your own responsibility. + +- for block building and validation, use `--builder.blacklist` + +-- + +## Details of the implementation + +There are two parts of the builder. + +1. `./builder` responsible for communicating with the relay +2. `./miner` responsible for producing blocks + +### `builder` module + +Main logic of the builder is in the `builder.go` file. + +Builder is driven by the consensus client SSE events that sends payload attribute events, triggering the `OnPayloadAttribute` call. +After requesting additional validator data from the relay builder starts building job with `runBuildingJob`. +Building job continuously makes a request to the `miner` with the correct parameters and submits produced block. + +* Builder retries build block requests every second on average. +* If the job is running but a new one is submitted for a different slot we cancel previous job. +* All jobs have 12s deadline. +* If new request is submitted for the same slot as before but with different parameters, we run these jobs in parallel. + It is possible to receive multiple requests from CL for the same slot but for different parent blocks if there is a possibility + of a missed block. +* All submissions to the relay are rate limited at 2 req/s +* Only blocks that have more profit than the previous best submissions for the particular job are submitted. + +Additional features of the builder: +* Builder can submit data about build blocks to the database. It stores block data, included bundles, and all considered bundles. + Implemented in `flashbotsextra.IDatabaseService`. +* It's possible to run local relay in the same process +* It can validate blocks instead of submitting them to the relay. (see `--builder.dry-run`) + +### `miner` module + +Miner is responsible for block creation. Request from the `builder` is routed to the `worker.go` where +`generateWork` does the job of creating a block. + +* Coinbase of the block is set to the address of the block proposer, fee recipient of the validator receives its eth + in the last tx in the block. +* We reserve gas for the proposer payment using `proposerTxPrepare` and commit proposer payment after txs are added with + `proposerTxCommit`. We do it in a way so all fees received by the block builder are sent to the fee recipient. +* Transaction insertion is done in `fillTransactionsAlgoWorker` \ `fillTransactions`. Depending on the algorithm selected. + Algo worker (greedy) inserts bundles whenever they belong in the block by effective gas price but default method inserts bundles on top of the block. + (see `--miner.algotype`) +* Worker is also responsible for simulating bundles. Bundles are simulated in parallel and results are cached for the particular parent block. +* `algo_greedy.go` implements logic of the block building. Bundles and transactions are sorted in the order of effective gas price then + we try to insert everything into to block until gas limit is reached. Failing bundles are reverted during the insertion but txs are not. +* Builder can filter transactions touching a particular set of addresses. + If a bundle or transaction touches one of the addresses it is skipped. (see `--builder.blacklist` flag) + +## Bundle Movement + +There are two ways bundles are moved to builders + +1. via API -`sendBundle` +2. via Database - `flashbotsextra.IDatabaseService` + +### `fetcher` service +* Fetcher service is part of `flashbotsextra.IDatabaseService` which is responsible for fetching the bundles from db and pushing into mev bundles queue which will be processed by builder. +* Fetcher is a background process which fetches high priority and low priority bundles from db. +* Fetcher fetches `500` high priority bundles on every head change, and `100` low priority bundles in the interval of every `2 seconds`. + +## Block builder diagram + +![block builder diagram](docs/builder/builder-diagram.png "Block builder diagram") + +--- + +# Security + +If you find a security vulnerability in this project or any other initiative +related to proposer/builder separation in Ethereum, please let us know sending +an email to security@flashbots.net. + +--- + +# License + +The code in this project is free software under the [LGPL License](COPYING.LESSER). +
diff --git builder/README.md bolt-builder/README.md index 8fa6c684527a2560cec43382dbfb4d6bf18e439b..a335eba085bd52caf00bc1ad90bd061b56c38bc2 100644 --- builder/README.md +++ bolt-builder/README.md @@ -1,297 +1,15 @@ -# Flashbots historic geth-based block builder - -This repository contains the historic codebase of the Flashbots geth-based block builder. - -This codebase is now deprecated and replaced by https://github.com/flashbots/rbuilder - -So long, and thanks for all the fish. - ---- - -The historic README is archived below. - ---- - -[geth readme](README.original.md) - -# Flashbots Block Builder - -This project implements the Flashbots block builder, based on go-ethereum (geth). - -See also: https://docs.flashbots.net/flashbots-mev-boost/block-builders - -Run on your favorite network, including Mainnet, Goerli, Sepolia and local devnet. Instructions for running a pos local devnet can be found [here](https://github.com/avalonche/eth-pos-devnet). +[flashbots builder readme](README.flashbots.md)   -You will need to run a modified beacon node that sends a custom rpc call to trigger block building. You can use the modified [prysm fork](https://github.com/flashbots/prysm) for this. +# Bolt builder   -Test with [mev-boost](https://github.com/flashbots/mev-boost) and [mev-boost test cli](https://github.com/flashbots/mev-boost/tree/main/cmd/test-cli). - -Building `geth` requires both a Go (version 1.19 or later) and a C compiler. You can install -them using your favourite package manager. Once the dependencies are installed, run +Bolt builder is a fork of the Flsahbots Builder that implements the functionality of the Constraints API.   ## How it works   -* Builder polls relay for the proposer registrations for the next epoch when block building is triggered -* If both local relay and remote relay are enabled, local relay will overwrite remote relay data. This is only meant for the testnets! - -## Limitations - -* Does not accept external blocks -* Does not have payload cache, only the latest block is available - -# Usage - -Configure geth for your network, it will become the block builder. - -Builder-related options: -``` -$ geth --help - - BUILDER - - --builder (default: false) - Enable the builder - - --builder.algotype value (default: "mev-geth") - Block building algorithm to use [=mev-geth] (mev-geth, greedy, greedy-buckets) - - --builder.beacon_endpoints value (default: "http://127.0.0.1:5052") - Comma separated list of beacon endpoints to connect to for beacon chain data - [$BUILDER_BEACON_ENDPOINTS] - - --builder.bellatrix_fork_version value (default: "0x02000000") - Bellatrix fork version. [$BUILDER_BELLATRIX_FORK_VERSION] - - --builder.blacklist value - Path to file containing blacklisted addresses, json-encoded list of strings. - Builder will ignore transactions that touch mentioned addresses. - - --builder.block_resubmit_interval value (default: "500ms") - Determines the interval at which builder will resubmit block submissions - [$FLASHBOTS_BUILDER_RATE_LIMIT_RESUBMIT_INTERVAL] - - --builder.cancellations (default: false) - Enable cancellations for the builder - - --builder.discard_revertible_tx_on_error (default: false) - When enabled, if a transaction submitted as part of a bundle in a send bundle - request has error on commit, and its hash is specified as one that can revert in - the request body, the builder will discard the hash of the failed transaction - from the submitted bundle.For additional details on the structure of the request - body, see - https://docs.flashbots.net/flashbots-mev-share/searchers/understanding-bundles#bundle-definition - [$FLASHBOTS_BUILDER_DISCARD_REVERTIBLE_TX_ON_ERROR] - - --builder.dry-run (default: false) - Builder only validates blocks without submission to the relay - - --builder.genesis_fork_version value (default: "0x00000000") - Gensis fork version. [$BUILDER_GENESIS_FORK_VERSION] - - --builder.genesis_validators_root value (default: "0x0000000000000000000000000000000000000000000000000000000000000000") - Genesis validators root of the network. [$BUILDER_GENESIS_VALIDATORS_ROOT] - - --builder.ignore_late_payload_attributes (default: false) - Builder will ignore all but the first payload attributes. Use if your CL sends - non-canonical head updates. - - --builder.listen_addr value (default: ":28545") - Listening address for builder endpoint [$BUILDER_LISTEN_ADDR] - - --builder.local_relay (default: false) - Enable the local relay - - --builder.no_bundle_fetcher (default: false) - Disable the bundle fetcher - - --builder.price_cutoff_percent value (default: 50) - flashbots - The minimum effective gas price threshold used for bucketing - transactions by price. For example if the top transaction in a list has an - effective gas price of 1000 wei and price_cutoff_percent is 10 (i.e. 10%), then - the minimum effective gas price included in the same bucket as the top - transaction is (1000 * 10%) = 100 wei. - NOTE: This flag is only used when - builder.algotype=greedy-buckets [$FLASHBOTS_BUILDER_PRICE_CUTOFF_PERCENT] - - --builder.rate_limit_duration value (default: "500ms") - Determines rate limit of events processed by builder. For example, a value of - "500ms" denotes that the builder processes events every 500ms. A duration string - is a possibly signed sequence of decimal numbers, each with optional fraction - and a unit suffix, such as "300ms", "-1.5h" or "2h45m" - [$FLASHBOTS_BUILDER_RATE_LIMIT_DURATION] - - --builder.rate_limit_max_burst value (default: 10) - Determines the maximum number of burst events the builder can accommodate at any - given point in time. [$FLASHBOTS_BUILDER_RATE_LIMIT_MAX_BURST] - - --builder.relay_secret_key value (default: "0x2fc12ae741f29701f8e30f5de6350766c020cb80768a0ff01e6838ffd2431e11") - Builder local relay API key used for signing headers [$BUILDER_RELAY_SECRET_KEY] - - --builder.remote_relay_endpoint value - Relay endpoint to connect to for validator registration data, if not provided - will expose validator registration locally [$BUILDER_REMOTE_RELAY_ENDPOINT] - - --builder.secondary_remote_relay_endpoints value - Comma separated relay endpoints to connect to for validator registration data - missing from the primary remote relay, and to push blocks for registrations - missing from or matching the primary [$BUILDER_SECONDARY_REMOTE_RELAY_ENDPOINTS] - - --builder.seconds_in_slot value (default: 12) - Set the number of seconds in a slot in the local relay - - --builder.secret_key value (default: "0x2fc12ae741f29701f8e30f5de6350766c020cb80768a0ff01e6838ffd2431e11") - Builder key used for signing blocks [$BUILDER_SECRET_KEY] - - --builder.slots_in_epoch value (default: 32) - Set the number of slots in an epoch in the local relay - - --builder.submission_offset value (default: 3s) - Determines the offset from the end of slot time that the builder will submit - blocks. For example, if a slot is 12 seconds long, and the offset is 2 seconds, - the builder will submit blocks at 10 seconds into the slot. - [$FLASHBOTS_BUILDER_SUBMISSION_OFFSET] - - --builder.validation_blacklist value - Path to file containing blacklisted addresses, json-encoded list of strings - - --builder.validation_use_balance_diff (default: false) - Block validation API will use fee recipient balance difference for profit - calculation. - - --builder.validator_checks (default: false) - Enable the validator checks - - MINER - - --miner.algotype value (default: "mev-geth") - [NOTE: Deprecated, please use builder.algotype instead] Block building algorithm - to use [=mev-geth] (mev-geth, greedy, greedy-buckets) - - --miner.blocklist value - [NOTE: Deprecated, please use builder.blacklist] flashbots - Path to JSON file with - list of blocked addresses. Miner will ignore txs that touch mentioned addresses. - - --miner.extradata value - Block extra data set by the miner (default = client version) - - --miner.price_cutoff_percent value (default: 50) - flashbots - The minimum effective gas price threshold used for bucketing - transactions by price. For example if the top transaction in a list has an - effective gas price of 1000 wei and price_cutoff_percent is 10 (i.e. 10%), then - the minimum effective gas price included in the same bucket as the top - transaction is (1000 * 10%) = 100 wei. - NOTE: This flag is only used when - miner.algotype=greedy-buckets - - METRICS - - --metrics.builder value (default: false) - Enable builder metrics collection and reporting -``` - -Environment variables: -``` -BUILDER_TX_SIGNING_KEY - private key of the builder used to sign payment transaction, must be the same as the coinbase address - -FLASHBOTS_BUILDER_RATE_LIMIT_DURATION - determines rate limit of events processed by builder; a duration string is a -possibly signed sequence of decimal numbers, each with optional fraction and a unit suffix, such as "300ms", "-1.5h" or "2h45m". - -FLASHBOTS_BUILDER_RATE_LIMIT_MAX_BURST - determines the maximum number of events the builder can accommodate at any point in time - -FLASHBOTS_BUILDER_RATE_LIMIT_RESUBMIT_INTERVAL - determines the interval at which builder will resubmit block submissions -``` - -## Metrics - -To enable metrics on the builder you will need to enable metrics with the flags `--metrics --metrics.addr 127.0.0.1 --metrics.builder` which will run -a metrics server serving at `127.0.0.1:6060/debug/metrics`. This will record performance metrics such as block profit and block building times. -The full list of metrics can be found in `miner/metrics.go`. - -See the [metrics docs](https://geth.ethereum.org/docs/monitoring/metrics) for geth for more documentation. - -## Blacklisting addresses - -If you want to reject transactions interacting with certain addresses, save the addresses in json file with an array of strings. Deciding whether to use such a list, as well as maintaining it, is your own responsibility. - -- for block building and validation, use `--builder.blacklist` - --- - -## Details of the implementation - -There are two parts of the builder. - -1. `./builder` responsible for communicating with the relay -2. `./miner` responsible for producing blocks - -### `builder` module - -Main logic of the builder is in the `builder.go` file. - -Builder is driven by the consensus client SSE events that sends payload attribute events, triggering the `OnPayloadAttribute` call. -After requesting additional validator data from the relay builder starts building job with `runBuildingJob`. -Building job continuously makes a request to the `miner` with the correct parameters and submits produced block. - -* Builder retries build block requests every second on average. -* If the job is running but a new one is submitted for a different slot we cancel previous job. -* All jobs have 12s deadline. -* If new request is submitted for the same slot as before but with different parameters, we run these jobs in parallel. - It is possible to receive multiple requests from CL for the same slot but for different parent blocks if there is a possibility - of a missed block. -* All submissions to the relay are rate limited at 2 req/s -* Only blocks that have more profit than the previous best submissions for the particular job are submitted. - -Additional features of the builder: -* Builder can submit data about build blocks to the database. It stores block data, included bundles, and all considered bundles. - Implemented in `flashbotsextra.IDatabaseService`. -* It's possible to run local relay in the same process -* It can validate blocks instead of submitting them to the relay. (see `--builder.dry-run`) - -### `miner` module +The builder has the standard functionality of the Flashbots builder, but with the +added functionality of the Constraints API which can be summarized as follows:   -Miner is responsible for block creation. Request from the `builder` is routed to the `worker.go` where -`generateWork` does the job of creating a block. - -* Coinbase of the block is set to the address of the block proposer, fee recipient of the validator receives its eth - in the last tx in the block. -* We reserve gas for the proposer payment using `proposerTxPrepare` and commit proposer payment after txs are added with - `proposerTxCommit`. We do it in a way so all fees received by the block builder are sent to the fee recipient. -* Transaction insertion is done in `fillTransactionsAlgoWorker` \ `fillTransactions`. Depending on the algorithm selected. - Algo worker (greedy) inserts bundles whenever they belong in the block by effective gas price but default method inserts bundles on top of the block. - (see `--miner.algotype`) -* Worker is also responsible for simulating bundles. Bundles are simulated in parallel and results are cached for the particular parent block. -* `algo_greedy.go` implements logic of the block building. Bundles and transactions are sorted in the order of effective gas price then - we try to insert everything into to block until gas limit is reached. Failing bundles are reverted during the insertion but txs are not. -* Builder can filter transactions touching a particular set of addresses. - If a bundle or transaction touches one of the addresses it is skipped. (see `--builder.blacklist` flag) - -## Bundle Movement - -There are two ways bundles are moved to builders - -1. via API -`sendBundle` -2. via Database - `flashbotsextra.IDatabaseService` - -### `fetcher` service -* Fetcher service is part of `flashbotsextra.IDatabaseService` which is responsible for fetching the bundles from db and pushing into mev bundles queue which will be processed by builder. -* Fetcher is a background process which fetches high priority and low priority bundles from db. -* Fetcher fetches `500` high priority bundles on every head change, and `100` low priority bundles in the interval of every `2 seconds`. - -## Block builder diagram - -![block builder diagram](docs/builder/builder-diagram.png "Block builder diagram") - ---- - -# Security - -If you find a security vulnerability in this project or any other initiative -related to proposer/builder separation in Ethereum, please let us know sending -an email to security@flashbots.net. - ---- - -# License - -The code in this project is free software under the [LGPL License](COPYING.LESSER). - +1. The builder subscribes to the relays for streams of constraints sent by proposers. +2. After receiving constraints and validating their authenticity, the builder builds a block that + respects all constraints and includes the necessary proofs of inclusion in its bid. +3. The builder sends the signed bid as usual to the relay.
diff --git builder/go.mod bolt-builder/go.mod index 7d6b1540a62cab968e4f54c8ee75f0d8b10df36a..dfe1cc1581108637aa8a11b882fff140e57e2cfc 100644 --- builder/go.mod +++ bolt-builder/go.mod @@ -1,6 +1,6 @@ module github.com/ethereum/go-ethereum   -go 1.20 +go 1.22   require ( github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.2.0 @@ -15,6 +15,7 @@ github.com/aws/aws-sdk-go-v2/service/route53 v1.30.2 github.com/btcsuite/btcd/btcec/v2 v2.2.1 github.com/cenkalti/backoff/v4 v4.2.1 github.com/cespare/cp v0.1.0 + github.com/chainbound/shardmap v0.0.2 github.com/cloudflare/cloudflare-go v0.79.0 github.com/cockroachdb/pebble v0.0.0-20230928194634-aa077af62593 github.com/consensys/gnark-crypto v0.12.1 @@ -25,7 +26,7 @@ github.com/deckarep/golang-set/v2 v2.1.0 github.com/dop251/goja v0.0.0-20230806174421-c933cf95e127 github.com/ethereum/c-kzg-4844 v0.4.0 github.com/fatih/color v1.15.0 - github.com/ferranbt/fastssz v0.1.3 + github.com/ferranbt/fastssz v0.1.4-0.20240724090034-31cd371f8688 github.com/fjl/gencodec v0.0.0-20230517082657-f9840df7b83e github.com/fjl/memsize v0.0.2 github.com/flashbots/go-boost-utils v1.8.0 @@ -39,6 +40,7 @@ github.com/golang/protobuf v1.5.3 github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb github.com/google/gofuzz v1.2.0 github.com/google/uuid v1.3.0 + github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.0 github.com/gorilla/websocket v1.4.2 github.com/grafana/pyroscope-go/godeltaprof v0.1.7 @@ -84,6 +86,8 @@ gopkg.in/yaml.v3 v3.0.1 )   require ( + github.com/emicklei/dot v1.6.2 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/getsentry/sentry-go v0.18.0 // indirect github.com/goccy/go-yaml v1.11.2 // indirect github.com/klauspost/cpuid/v2 v2.2.5 // indirect @@ -147,7 +151,7 @@ github.com/mitchellh/pointerstructure v1.2.0 // indirect github.com/mmcloughlin/addchain v0.4.0 // indirect github.com/naoina/toml v0.1.1 github.com/opentracing/opentracing-go v1.2.0 // indirect - github.com/pkg/errors v0.9.1 // indirect + github.com/pkg/errors v0.9.1 github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.16.0 // indirect github.com/prometheus/client_model v0.3.0 // indirect
diff --git builder/go.sum bolt-builder/go.sum index 3c9ff3c8173e1ee07717ea20a9ea6d6292488016..1ab78598f52a4582b536e7b5d6988d85c54dec5b 100644 --- builder/go.sum +++ bolt-builder/go.sum @@ -74,6 +74,8 @@ github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chainbound/shardmap v0.0.2 h1:yB1weccdm2vC6dnqzzLwPIvyAnRj7815mJWbkPybiYw= +github.com/chainbound/shardmap v0.0.2/go.mod h1:TBvIzhHyFUbt+oa3UzbijobTUh221st6xIbuki7WzPc= github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -135,6 +137,8 @@ github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= +github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= +github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= @@ -146,8 +150,10 @@ github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= -github.com/ferranbt/fastssz v0.1.3 h1:ZI+z3JH05h4kgmFXdHuR1aWYsgrg7o+Fw7/NCzM16Mo= -github.com/ferranbt/fastssz v0.1.3/go.mod h1:0Y9TEd/9XuFlh7mskMPfXiI2Dkw4Ddg9EyXt1W7MRvE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/ferranbt/fastssz v0.1.4-0.20240724090034-31cd371f8688 h1:k70X5h1haHaSbpD/9fcjtvAUEVlRlOKtdpvN7Mzhcv4= +github.com/ferranbt/fastssz v0.1.4-0.20240724090034-31cd371f8688/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= github.com/fjl/gencodec v0.0.0-20230517082657-f9840df7b83e h1:bBLctRc7kr01YGvaDfgLbTwjFNW5jdp5y5rj8XXBHfY= github.com/fjl/gencodec v0.0.0-20230517082657-f9840df7b83e/go.mod h1:AzA8Lj6YtixmJWL+wkKoBGsLWy9gFrAzi4g+5bCKwpY= github.com/fjl/memsize v0.0.2 h1:27txuSD9or+NZlnOWdKUxeBzTAUkWCVh+4Gf2dWFOzA= @@ -253,6 +259,8 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=