diff --git a/blockdb/types/execdata_sections_ssz.go b/blockdb/types/execdata_sections_ssz.go index 0307bb476..97375edec 100644 --- a/blockdb/types/execdata_sections_ssz.go +++ b/blockdb/types/execdata_sections_ssz.go @@ -1,6 +1,6 @@ // Code generated by dynamic-ssz. DO NOT EDIT. // Hash: d3acce19a9e9c0afc0038f9dfd949d804d4b82beafef97cc4d618d03c202d4af -// Version: v1.3.1 (https://github.com/pk910/dynamic-ssz) +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) package types import ( @@ -13,6 +13,12 @@ import ( var _ = sszutils.ErrListTooBig +var _ = sszutils.Annotate[ReceiptMetaData](`ssz-static:"true"`) +var _ = sszutils.Annotate[BlockReceiptMeta](`ssz-static:"true"`) +var _ = sszutils.Annotate[StateChangeAccount](`ssz-static:"false"`) +var _ = sszutils.Annotate[FlatCallFrame](`ssz-static:"false"`) +var _ = sszutils.Annotate[EventData](`ssz-static:"false"`) + // MarshalSSZ marshals the *ReceiptMetaData to SSZ-encoded bytes. func (t *ReceiptMetaData) MarshalSSZ() ([]byte, error) { return dynssz.GetGlobalDynSsz().MarshalSSZ(t) @@ -70,6 +76,9 @@ func (t *ReceiptMetaData) UnmarshalSSZ(buf []byte) (err error) { if buflen < 377 { return sszutils.ErrFixedFieldsEOFFn(buflen, 377) } + if buflen > 377 { + return sszutils.ErrTrailingDataFn(buflen - 377) + } { // Field #0 'Version' (static) buf := buf[0:2] t.Version = binary.LittleEndian.Uint16(buf) @@ -220,6 +229,9 @@ func (t *BlockReceiptMeta) UnmarshalSSZ(buf []byte) (err error) { if buflen < 10 { return sszutils.ErrFixedFieldsEOFFn(buflen, 10) } + if buflen > 10 { + return sszutils.ErrTrailingDataFn(buflen - 10) + } { // Field #0 'Version' (static) buf := buf[0:2] t.Version = binary.LittleEndian.Uint16(buf) @@ -403,6 +415,9 @@ func (t *StateChangeAccount) UnmarshalSSZ(buf []byte) (err error) { if buflen < 96 { return sszutils.ErrorWithPathf(sszutils.ErrFixedFieldsEOFFn(buflen, 96), "Slots[%d]", idx1) } + if buflen > 96 { + return sszutils.ErrorWithPathf(sszutils.ErrTrailingDataFn(buflen-96), "Slots[%d]", idx1) + } { // Field #0 'Slot' (static) buf := buf[0:32] copy(val4.Slot[:], buf) diff --git a/clients/consensus/chainspec.go b/clients/consensus/chainspec.go index 33788b2ac..ef53f8738 100644 --- a/clients/consensus/chainspec.go +++ b/clients/consensus/chainspec.go @@ -228,11 +228,13 @@ type ChainSpecPreset struct { NumberOfColumns *uint64 `yaml:"NUMBER_OF_COLUMNS" check-if-fork:"FuluForkEpoch"` // Gloas - PtcSize uint64 `yaml:"PTC_SIZE" check-if-fork:"GloasForkEpoch"` - MaxPayloadAttestations uint64 `yaml:"MAX_PAYLOAD_ATTESTATIONS" check-if-fork:"GloasForkEpoch"` - BuilderRegistryLimit uint64 `yaml:"BUILDER_REGISTRY_LIMIT" check-if-fork:"GloasForkEpoch"` - BuilderPendingWithdrawalsLimit uint64 `yaml:"BUILDER_PENDING_WITHDRAWALS_LIMIT" check-if-fork:"GloasForkEpoch"` - MaxBuildersPerWithdrawalsSweep uint64 `yaml:"MAX_BUILDERS_PER_WITHDRAWALS_SWEEP" check-if-fork:"GloasForkEpoch"` + PtcSize uint64 `yaml:"PTC_SIZE" check-if-fork:"GloasForkEpoch"` + MaxPayloadAttestations uint64 `yaml:"MAX_PAYLOAD_ATTESTATIONS" check-if-fork:"GloasForkEpoch"` + BuilderRegistryLimit uint64 `yaml:"BUILDER_REGISTRY_LIMIT" check-if-fork:"GloasForkEpoch"` + BuilderPendingWithdrawalsLimit uint64 `yaml:"BUILDER_PENDING_WITHDRAWALS_LIMIT" check-if-fork:"GloasForkEpoch"` + MaxBuildersPerWithdrawalsSweep uint64 `yaml:"MAX_BUILDERS_PER_WITHDRAWALS_SWEEP" check-if-fork:"GloasForkEpoch"` + MaxBuilderDepositRequestsPerPayload uint64 `yaml:"MAX_BUILDER_DEPOSIT_REQUESTS_PER_PAYLOAD" check-if-fork:"GloasForkEpoch"` + MaxBuilderExitRequestsPerPayload uint64 `yaml:"MAX_BUILDER_EXIT_REQUESTS_PER_PAYLOAD" check-if-fork:"GloasForkEpoch"` // Heze InclusionListCommitteeSize uint64 `yaml:"INCLUSION_LIST_COMMITTEE_SIZE" check-if-fork:"HezeForkEpoch"` diff --git a/clients/execution/chainstate.go b/clients/execution/chainstate.go index 72d65f662..bdc893863 100644 --- a/clients/execution/chainstate.go +++ b/clients/execution/chainstate.go @@ -14,10 +14,12 @@ import ( ) var DefaultSystemContractAddresses = map[string]common.Address{ - rpc.ConsolidationRequestContract: common.HexToAddress("0x0000BBdDc7CE488642fb579F8B00f3a590007251"), - rpc.WithdrawalRequestContract: common.HexToAddress("0x00000961Ef480Eb55e80D19ad83579A64c007002"), - rpc.BeaconRootsContract: common.HexToAddress("0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02"), - rpc.HistoryStorageContract: common.HexToAddress("0x0000F90827F1C53a10cb7A02335B175320002935"), + rpc.ConsolidationRequestContract: common.HexToAddress("0x0000BBdDc7CE488642fb579F8B00f3a590007251"), + rpc.WithdrawalRequestContract: common.HexToAddress("0x00000961Ef480Eb55e80D19ad83579A64c007002"), + rpc.BuilderDepositRequestContract: common.HexToAddress("0x0000884d2AA32eAa155F59A2f24eFa73D9008282"), + rpc.BuilderExitRequestContract: common.HexToAddress("0x000014574A74c805590AFF9499fc7A690f008282"), + rpc.BeaconRootsContract: common.HexToAddress("0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02"), + rpc.HistoryStorageContract: common.HexToAddress("0x0000F90827F1C53a10cb7A02335B175320002935"), } type ChainState struct { diff --git a/clients/execution/clientlogic.go b/clients/execution/clientlogic.go index 0a7a8aa1a..46e22bf26 100644 --- a/clients/execution/clientlogic.go +++ b/clients/execution/clientlogic.go @@ -270,20 +270,16 @@ func (client *Client) pollClientHead() error { ctx, cancel := context.WithTimeout(client.clientCtx, 10*time.Second) defer cancel() - latestHeader, err := client.rpcClient.GetLatestHeader(ctx) + headNumber, headHash, err := client.rpcClient.GetLatestHead(ctx) if err != nil { - return fmt.Errorf("could not get latest header: %v", err) - } - - if latestHeader == nil { - return fmt.Errorf("could not find latest header") + return fmt.Errorf("could not get latest head: %v", err) } client.headMutex.Lock() defer client.headMutex.Unlock() - client.headNumber = latestHeader.Number.Uint64() - client.headHash = latestHeader.Hash() + client.headNumber = headNumber + client.headHash = headHash return nil } diff --git a/clients/execution/rpc/ethconfig.go b/clients/execution/rpc/ethconfig.go index c4540ae7d..043c2b597 100644 --- a/clients/execution/rpc/ethconfig.go +++ b/clients/execution/rpc/ethconfig.go @@ -8,11 +8,13 @@ import ( ) const ( - DepositContract = "DEPOSIT_CONTRACT_ADDRESS" - ConsolidationRequestContract = "CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS" - WithdrawalRequestContract = "WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS" - BeaconRootsContract = "BEACON_ROOTS_ADDRESS" - HistoryStorageContract = "HISTORY_STORAGE_ADDRESS" + DepositContract = "DEPOSIT_CONTRACT_ADDRESS" + ConsolidationRequestContract = "CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS" + WithdrawalRequestContract = "WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS" + BuilderDepositRequestContract = "BUILDER_DEPOSIT_REQUEST_PREDEPLOY_ADDRESS" + BuilderExitRequestContract = "BUILDER_EXIT_REQUEST_PREDEPLOY_ADDRESS" + BeaconRootsContract = "BEACON_ROOTS_ADDRESS" + HistoryStorageContract = "HISTORY_STORAGE_ADDRESS" ) type EthConfigFork struct { diff --git a/clients/execution/rpc/executionapi.go b/clients/execution/rpc/executionapi.go index 9f3ebf6e0..490fed640 100644 --- a/clients/execution/rpc/executionapi.go +++ b/clients/execution/rpc/executionapi.go @@ -8,6 +8,7 @@ import ( "strconv" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/p2p" @@ -192,13 +193,26 @@ func (ec *ExecutionClient) UninstallBlockFilter(ctx context.Context, filterId Bl return result, err } -func (ec *ExecutionClient) GetLatestHeader(ctx context.Context) (*types.Header, error) { - header, err := ec.ethClient.HeaderByNumber(ctx, nil) +// GetLatestHead returns the latest block's number and hash as reported by the +// execution node. The hash is read from the node's response rather than +// recomputed from the header locally, which would be wrong whenever the header +// carries fields the local go-ethereum types do not know about. +func (ec *ExecutionClient) GetLatestHead(ctx context.Context) (uint64, common.Hash, error) { + var head struct { + Number *hexutil.Big `json:"number"` + Hash common.Hash `json:"hash"` + } + + err := ec.rpcClient.CallContext(ctx, &head, "eth_getBlockByNumber", "latest", false) if err != nil { - return nil, err + return 0, common.Hash{}, err } - return header, nil + if head.Number == nil { + return 0, common.Hash{}, fmt.Errorf("no latest block returned") + } + + return head.Number.ToInt().Uint64(), head.Hash, nil } func (ec *ExecutionClient) GetLatestBlock(ctx context.Context) (*types.Block, error) { diff --git a/cmd/dora-explorer/main.go b/cmd/dora-explorer/main.go index c61dc13b4..1abd8a2cb 100644 --- a/cmd/dora-explorer/main.go +++ b/cmd/dora-explorer/main.go @@ -236,6 +236,10 @@ func startFrontend(router *mux.Router) { router.HandleFunc("/validator/{idxOrPubKey}", handlers.Validator).Methods("GET") router.HandleFunc("/validator/{index}/slots", handlers.ValidatorSlots).Methods("GET") router.HandleFunc("/builders", handlers.Builders).Methods("GET") + router.HandleFunc("/builders/deposits", handlers.BuilderDeposits).Methods("GET") + router.HandleFunc("/builders/exits", handlers.BuilderExits).Methods("GET") + router.HandleFunc("/builders/submit_deposit", handlers.SubmitBuilderDeposit).Methods("GET") + router.HandleFunc("/builders/submit_exit", handlers.SubmitBuilderExit).Methods("GET") router.HandleFunc("/builder/{idxOrPubKey}", handlers.BuilderDetail).Methods("GET") if utils.Config.Frontend.Pprof { diff --git a/db/builder_deposit_request_txs.go b/db/builder_deposit_request_txs.go new file mode 100644 index 000000000..bab156cbe --- /dev/null +++ b/db/builder_deposit_request_txs.go @@ -0,0 +1,192 @@ +package db + +import ( + "context" + "fmt" + "strings" + + "github.com/ethpandaops/dora/dbtypes" + "github.com/jmoiron/sqlx" +) + +func InsertBuilderDepositTxs(ctx context.Context, tx *sqlx.Tx, depositTxs []*dbtypes.BuilderDepositTx) error { + var sql strings.Builder + fmt.Fprint(&sql, + EngineQuery(map[dbtypes.DBEngineType]string{ + dbtypes.DBEnginePgsql: "INSERT INTO builder_deposit_request_txs ", + dbtypes.DBEngineSqlite: "INSERT OR REPLACE INTO builder_deposit_request_txs ", + }), + "(block_number, block_index, block_time, block_root, fork_id, public_key, withdrawal_credentials, amount, signature, builder_index, tx_hash, tx_sender, tx_target, dequeue_block)", + " VALUES ", + ) + argIdx := 0 + fieldCount := 14 + + args := make([]any, len(depositTxs)*fieldCount) + for i, depositTx := range depositTxs { + if i > 0 { + fmt.Fprintf(&sql, ", ") + } + fmt.Fprintf(&sql, "(") + for f := 0; f < fieldCount; f++ { + if f > 0 { + fmt.Fprintf(&sql, ", ") + } + fmt.Fprintf(&sql, "$%v", argIdx+f+1) + } + fmt.Fprintf(&sql, ")") + + args[argIdx+0] = depositTx.BlockNumber + args[argIdx+1] = depositTx.BlockIndex + args[argIdx+2] = depositTx.BlockTime + args[argIdx+3] = depositTx.BlockRoot + args[argIdx+4] = depositTx.ForkId + args[argIdx+5] = depositTx.PublicKey + args[argIdx+6] = depositTx.WithdrawalCredentials + args[argIdx+7] = depositTx.Amount + args[argIdx+8] = depositTx.Signature + args[argIdx+9] = depositTx.BuilderIndex + args[argIdx+10] = depositTx.TxHash + args[argIdx+11] = depositTx.TxSender + args[argIdx+12] = depositTx.TxTarget + args[argIdx+13] = depositTx.DequeueBlock + argIdx += fieldCount + } + fmt.Fprint(&sql, EngineQuery(map[dbtypes.DBEngineType]string{ + dbtypes.DBEnginePgsql: " ON CONFLICT (block_root, block_index) DO UPDATE SET builder_index = excluded.builder_index, dequeue_block = excluded.dequeue_block, fork_id = excluded.fork_id", + dbtypes.DBEngineSqlite: "", + })) + + _, err := tx.ExecContext(ctx, sql.String(), args...) + if err != nil { + return err + } + return nil +} + +func GetBuilderDepositTxsByDequeueRange(ctx context.Context, dequeueFirst uint64, dequeueLast uint64) []*dbtypes.BuilderDepositTx { + depositTxs := []*dbtypes.BuilderDepositTx{} + + err := ReaderDb.SelectContext(ctx, &depositTxs, `SELECT builder_deposit_request_txs.* + FROM builder_deposit_request_txs + WHERE dequeue_block >= $1 AND dequeue_block <= $2 + ORDER BY dequeue_block ASC, block_number ASC, block_index ASC + `, dequeueFirst, dequeueLast) + if err != nil { + logger.Errorf("Error while fetching builder deposit txs: %v", err) + return nil + } + + return depositTxs +} + +func GetBuilderDepositTxsByTxHashes(ctx context.Context, txHashes [][]byte) []*dbtypes.BuilderDepositTx { + var sql strings.Builder + args := make([]any, len(txHashes)) + + fmt.Fprint(&sql, `SELECT builder_deposit_request_txs.* + FROM builder_deposit_request_txs + WHERE tx_hash IN ( + `) + + for idx, txHash := range txHashes { + args[idx] = txHash + } + appendDollarPlaceholders(&sql, 1, len(txHashes), ", ") + fmt.Fprintf(&sql, ")") + + depositTxs := []*dbtypes.BuilderDepositTx{} + err := ReaderDb.SelectContext(ctx, &depositTxs, sql.String(), args...) + if err != nil { + logger.Errorf("Error while fetching builder deposit txs: %v", err) + return nil + } + + return depositTxs +} + +func GetBuilderDepositTxsFiltered(ctx context.Context, offset uint64, limit uint32, filter *dbtypes.BuilderDepositTxFilter) ([]*dbtypes.BuilderDepositTx, uint64, error) { + var sql strings.Builder + args := []interface{}{} + fmt.Fprint(&sql, ` + WITH cte AS ( + SELECT + block_number, block_index, block_time, block_root, fork_id, public_key, withdrawal_credentials, amount, signature, builder_index, tx_hash, tx_sender, tx_target, dequeue_block + FROM builder_deposit_request_txs + `) + + filterOp := "WHERE" + if filter.MinDequeue > 0 { + args = append(args, filter.MinDequeue) + fmt.Fprintf(&sql, " %v dequeue_block >= $%v", filterOp, len(args)) + filterOp = "AND" + } + if filter.MaxDequeue > 0 { + args = append(args, filter.MaxDequeue) + fmt.Fprintf(&sql, " %v dequeue_block <= $%v", filterOp, len(args)) + filterOp = "AND" + } + if len(filter.PublicKey) > 0 { + args = append(args, filter.PublicKey) + fmt.Fprintf(&sql, " %v public_key = $%v", filterOp, len(args)) + filterOp = "AND" + } + if filter.MinIndex > 0 { + args = append(args, filter.MinIndex) + fmt.Fprintf(&sql, " %v builder_index >= $%v", filterOp, len(args)) + filterOp = "AND" + } + if filter.MaxIndex > 0 { + args = append(args, filter.MaxIndex) + fmt.Fprintf(&sql, " %v builder_index <= $%v", filterOp, len(args)) + filterOp = "AND" + } + if filter.MinAmount != nil { + args = append(args, *filter.MinAmount) + fmt.Fprintf(&sql, " %v amount >= $%v", filterOp, len(args)) + filterOp = "AND" + } + if filter.MaxAmount != nil { + args = append(args, *filter.MaxAmount) + fmt.Fprintf(&sql, " %v amount <= $%v", filterOp, len(args)) + } + + args = append(args, limit) + fmt.Fprintf(&sql, `) + SELECT + count(*) AS block_number, + 0 AS block_index, + 0 AS block_time, + null AS block_root, + 0 AS fork_id, + null AS public_key, + null AS withdrawal_credentials, + 0 AS amount, + null AS signature, + null AS builder_index, + null AS tx_hash, + null AS tx_sender, + null AS tx_target, + 0 AS dequeue_block + FROM cte + UNION ALL SELECT * FROM ( + SELECT * FROM cte + ORDER BY block_time DESC, block_index DESC + LIMIT $%v + `, len(args)) + + if offset > 0 { + args = append(args, offset) + fmt.Fprintf(&sql, " OFFSET $%v ", len(args)) + } + fmt.Fprintf(&sql, ") AS t1") + + depositTxs := []*dbtypes.BuilderDepositTx{} + err := ReaderDb.SelectContext(ctx, &depositTxs, sql.String(), args...) + if err != nil { + logger.Errorf("Error while fetching filtered builder deposit txs: %v", err) + return nil, 0, err + } + + return depositTxs[1:], depositTxs[0].BlockNumber, nil +} diff --git a/db/builder_deposits.go b/db/builder_deposits.go new file mode 100644 index 000000000..33f2b57f9 --- /dev/null +++ b/db/builder_deposits.go @@ -0,0 +1,176 @@ +package db + +import ( + "context" + "fmt" + "strings" + + "github.com/ethpandaops/dora/dbtypes" + "github.com/jmoiron/sqlx" +) + +func InsertBuilderDeposits(ctx context.Context, tx *sqlx.Tx, deposits []*dbtypes.BuilderDeposit) error { + var sql strings.Builder + fmt.Fprint(&sql, + EngineQuery(map[dbtypes.DBEngineType]string{ + dbtypes.DBEnginePgsql: "INSERT INTO builder_deposits ", + dbtypes.DBEngineSqlite: "INSERT OR REPLACE INTO builder_deposits ", + }), + "(slot_number, slot_root, slot_index, orphaned, fork_id, public_key, withdrawal_credentials, amount, signature, builder_index, tx_hash, block_number, result)", + " VALUES ", + ) + argIdx := 0 + fieldCount := 13 + + args := make([]interface{}, len(deposits)*fieldCount) + for i, deposit := range deposits { + if i > 0 { + fmt.Fprintf(&sql, ", ") + } + fmt.Fprintf(&sql, "(") + for f := 0; f < fieldCount; f++ { + if f > 0 { + fmt.Fprintf(&sql, ", ") + } + fmt.Fprintf(&sql, "$%v", argIdx+f+1) + } + fmt.Fprintf(&sql, ")") + + args[argIdx+0] = deposit.SlotNumber + args[argIdx+1] = deposit.SlotRoot + args[argIdx+2] = deposit.SlotIndex + args[argIdx+3] = deposit.Orphaned + args[argIdx+4] = deposit.ForkId + args[argIdx+5] = deposit.PublicKey + args[argIdx+6] = deposit.WithdrawalCredentials + args[argIdx+7] = deposit.Amount + args[argIdx+8] = deposit.Signature + args[argIdx+9] = deposit.BuilderIndex + args[argIdx+10] = deposit.TxHash + args[argIdx+11] = deposit.BlockNumber + args[argIdx+12] = deposit.Result + argIdx += fieldCount + } + fmt.Fprint(&sql, EngineQuery(map[dbtypes.DBEngineType]string{ + dbtypes.DBEnginePgsql: " ON CONFLICT (slot_root, slot_index) DO UPDATE SET orphaned = excluded.orphaned, fork_id = excluded.fork_id, builder_index = excluded.builder_index, result = excluded.result", + dbtypes.DBEngineSqlite: "", + })) + _, err := tx.ExecContext(ctx, sql.String(), args...) + if err != nil { + return err + } + return nil +} + +func GetBuilderDepositsFiltered(ctx context.Context, offset uint64, limit uint32, canonicalForkIds []uint64, filter *dbtypes.BuilderDepositFilter) ([]*dbtypes.BuilderDeposit, uint64, error) { + var sql strings.Builder + args := []interface{}{} + fmt.Fprint(&sql, ` + WITH cte AS ( + SELECT + slot_number, slot_root, slot_index, orphaned, fork_id, public_key, withdrawal_credentials, amount, signature, builder_index, tx_hash, block_number, result + FROM builder_deposits + `) + + filterOp := "WHERE" + if filter.MinSlot > 0 { + args = append(args, filter.MinSlot) + fmt.Fprintf(&sql, " %v slot_number >= $%v", filterOp, len(args)) + filterOp = "AND" + } + if filter.MaxSlot > 0 { + args = append(args, filter.MaxSlot) + fmt.Fprintf(&sql, " %v slot_number <= $%v", filterOp, len(args)) + filterOp = "AND" + } + if len(filter.PublicKey) > 0 { + args = append(args, filter.PublicKey) + fmt.Fprintf(&sql, " %v public_key = $%v", filterOp, len(args)) + filterOp = "AND" + } + if filter.MinIndex > 0 { + args = append(args, filter.MinIndex) + fmt.Fprintf(&sql, " %v builder_index >= $%v", filterOp, len(args)) + filterOp = "AND" + } + if filter.MaxIndex > 0 { + args = append(args, filter.MaxIndex) + fmt.Fprintf(&sql, " %v builder_index <= $%v", filterOp, len(args)) + filterOp = "AND" + } + if filter.MinAmount != nil { + args = append(args, *filter.MinAmount) + fmt.Fprintf(&sql, " %v amount >= $%v", filterOp, len(args)) + filterOp = "AND" + } + if filter.MaxAmount != nil { + args = append(args, *filter.MaxAmount) + fmt.Fprintf(&sql, " %v amount <= $%v", filterOp, len(args)) + filterOp = "AND" + } + + appendWithOrphanedFilter(&sql, &args, &filterOp, filter.WithOrphaned, canonicalForkIds, "fork_id") + + args = append(args, limit) + fmt.Fprintf(&sql, `) + SELECT + count(*) AS slot_number, + null AS slot_root, + 0 AS slot_index, + false AS orphaned, + 0 AS fork_id, + null AS public_key, + null AS withdrawal_credentials, + 0 AS amount, + null AS signature, + null AS builder_index, + null AS tx_hash, + 0 AS block_number, + 0 AS result + FROM cte + UNION ALL SELECT * FROM ( + SELECT * FROM cte + ORDER BY slot_number DESC, slot_index DESC + LIMIT $%v + `, len(args)) + + if offset > 0 { + args = append(args, offset) + fmt.Fprintf(&sql, " OFFSET $%v ", len(args)) + } + fmt.Fprintf(&sql, ") AS t1") + + deposits := []*dbtypes.BuilderDeposit{} + err := ReaderDb.SelectContext(ctx, &deposits, sql.String(), args...) + if err != nil { + logger.Errorf("Error while fetching filtered builder deposits: %v", err) + return nil, 0, err + } + + return deposits[1:], deposits[0].SlotNumber, nil +} + +func GetBuilderDepositsByElBlockRange(ctx context.Context, firstBlock uint64, lastBlock uint64) []*dbtypes.BuilderDeposit { + deposits := []*dbtypes.BuilderDeposit{} + + err := ReaderDb.SelectContext(ctx, &deposits, ` + SELECT builder_deposits.* + FROM builder_deposits + WHERE block_number >= $1 AND block_number <= $2 + ORDER BY block_number ASC, slot_index ASC + `, firstBlock, lastBlock) + if err != nil { + logger.Errorf("Error while fetching builder deposits: %v", err) + return nil + } + + return deposits +} + +func UpdateBuilderDepositTxHash(ctx context.Context, tx *sqlx.Tx, slotRoot []byte, slotIndex uint64, txHash []byte) error { + _, err := tx.ExecContext(ctx, `UPDATE builder_deposits SET tx_hash = $1 WHERE slot_root = $2 AND slot_index = $3`, txHash, slotRoot, slotIndex) + if err != nil { + return err + } + return nil +} diff --git a/db/builder_exit_request_txs.go b/db/builder_exit_request_txs.go new file mode 100644 index 000000000..eaaab21cb --- /dev/null +++ b/db/builder_exit_request_txs.go @@ -0,0 +1,183 @@ +package db + +import ( + "context" + "fmt" + "strings" + + "github.com/ethpandaops/dora/dbtypes" + "github.com/jmoiron/sqlx" +) + +func InsertBuilderExitTxs(ctx context.Context, tx *sqlx.Tx, exitTxs []*dbtypes.BuilderExitTx) error { + var sql strings.Builder + fmt.Fprint(&sql, + EngineQuery(map[dbtypes.DBEngineType]string{ + dbtypes.DBEnginePgsql: "INSERT INTO builder_exit_request_txs ", + dbtypes.DBEngineSqlite: "INSERT OR REPLACE INTO builder_exit_request_txs ", + }), + "(block_number, block_index, block_time, block_root, fork_id, source_address, public_key, builder_index, tx_hash, tx_sender, tx_target, dequeue_block)", + " VALUES ", + ) + argIdx := 0 + fieldCount := 12 + + args := make([]any, len(exitTxs)*fieldCount) + for i, exitTx := range exitTxs { + if i > 0 { + fmt.Fprintf(&sql, ", ") + } + fmt.Fprintf(&sql, "(") + for f := 0; f < fieldCount; f++ { + if f > 0 { + fmt.Fprintf(&sql, ", ") + } + fmt.Fprintf(&sql, "$%v", argIdx+f+1) + } + fmt.Fprintf(&sql, ")") + + args[argIdx+0] = exitTx.BlockNumber + args[argIdx+1] = exitTx.BlockIndex + args[argIdx+2] = exitTx.BlockTime + args[argIdx+3] = exitTx.BlockRoot + args[argIdx+4] = exitTx.ForkId + args[argIdx+5] = exitTx.SourceAddress + args[argIdx+6] = exitTx.PublicKey + args[argIdx+7] = exitTx.BuilderIndex + args[argIdx+8] = exitTx.TxHash + args[argIdx+9] = exitTx.TxSender + args[argIdx+10] = exitTx.TxTarget + args[argIdx+11] = exitTx.DequeueBlock + argIdx += fieldCount + } + fmt.Fprint(&sql, EngineQuery(map[dbtypes.DBEngineType]string{ + dbtypes.DBEnginePgsql: " ON CONFLICT (block_root, block_index) DO UPDATE SET builder_index = excluded.builder_index, dequeue_block = excluded.dequeue_block, fork_id = excluded.fork_id", + dbtypes.DBEngineSqlite: "", + })) + + _, err := tx.ExecContext(ctx, sql.String(), args...) + if err != nil { + return err + } + return nil +} + +func GetBuilderExitTxsByDequeueRange(ctx context.Context, dequeueFirst uint64, dequeueLast uint64) []*dbtypes.BuilderExitTx { + exitTxs := []*dbtypes.BuilderExitTx{} + + err := ReaderDb.SelectContext(ctx, &exitTxs, `SELECT builder_exit_request_txs.* + FROM builder_exit_request_txs + WHERE dequeue_block >= $1 AND dequeue_block <= $2 + ORDER BY dequeue_block ASC, block_number ASC, block_index ASC + `, dequeueFirst, dequeueLast) + if err != nil { + logger.Errorf("Error while fetching builder exit txs: %v", err) + return nil + } + + return exitTxs +} + +func GetBuilderExitTxsByTxHashes(ctx context.Context, txHashes [][]byte) []*dbtypes.BuilderExitTx { + var sql strings.Builder + args := make([]any, len(txHashes)) + + fmt.Fprint(&sql, `SELECT builder_exit_request_txs.* + FROM builder_exit_request_txs + WHERE tx_hash IN ( + `) + + for idx, txHash := range txHashes { + args[idx] = txHash + } + appendDollarPlaceholders(&sql, 1, len(txHashes), ", ") + fmt.Fprintf(&sql, ")") + + exitTxs := []*dbtypes.BuilderExitTx{} + err := ReaderDb.SelectContext(ctx, &exitTxs, sql.String(), args...) + if err != nil { + logger.Errorf("Error while fetching builder exit txs: %v", err) + return nil + } + + return exitTxs +} + +func GetBuilderExitTxsFiltered(ctx context.Context, offset uint64, limit uint32, filter *dbtypes.BuilderExitTxFilter) ([]*dbtypes.BuilderExitTx, uint64, error) { + var sql strings.Builder + args := []interface{}{} + fmt.Fprint(&sql, ` + WITH cte AS ( + SELECT + block_number, block_index, block_time, block_root, fork_id, source_address, public_key, builder_index, tx_hash, tx_sender, tx_target, dequeue_block + FROM builder_exit_request_txs + `) + + filterOp := "WHERE" + if filter.MinDequeue > 0 { + args = append(args, filter.MinDequeue) + fmt.Fprintf(&sql, " %v dequeue_block >= $%v", filterOp, len(args)) + filterOp = "AND" + } + if filter.MaxDequeue > 0 { + args = append(args, filter.MaxDequeue) + fmt.Fprintf(&sql, " %v dequeue_block <= $%v", filterOp, len(args)) + filterOp = "AND" + } + if len(filter.PublicKey) > 0 { + args = append(args, filter.PublicKey) + fmt.Fprintf(&sql, " %v public_key = $%v", filterOp, len(args)) + filterOp = "AND" + } + if len(filter.SourceAddress) > 0 { + args = append(args, filter.SourceAddress) + fmt.Fprintf(&sql, " %v source_address = $%v", filterOp, len(args)) + filterOp = "AND" + } + if filter.MinIndex > 0 { + args = append(args, filter.MinIndex) + fmt.Fprintf(&sql, " %v builder_index >= $%v", filterOp, len(args)) + filterOp = "AND" + } + if filter.MaxIndex > 0 { + args = append(args, filter.MaxIndex) + fmt.Fprintf(&sql, " %v builder_index <= $%v", filterOp, len(args)) + } + + args = append(args, limit) + fmt.Fprintf(&sql, `) + SELECT + count(*) AS block_number, + 0 AS block_index, + 0 AS block_time, + null AS block_root, + 0 AS fork_id, + null AS source_address, + null AS public_key, + null AS builder_index, + null AS tx_hash, + null AS tx_sender, + null AS tx_target, + 0 AS dequeue_block + FROM cte + UNION ALL SELECT * FROM ( + SELECT * FROM cte + ORDER BY block_time DESC, block_index DESC + LIMIT $%v + `, len(args)) + + if offset > 0 { + args = append(args, offset) + fmt.Fprintf(&sql, " OFFSET $%v ", len(args)) + } + fmt.Fprintf(&sql, ") AS t1") + + exitTxs := []*dbtypes.BuilderExitTx{} + err := ReaderDb.SelectContext(ctx, &exitTxs, sql.String(), args...) + if err != nil { + logger.Errorf("Error while fetching filtered builder exit txs: %v", err) + return nil, 0, err + } + + return exitTxs[1:], exitTxs[0].BlockNumber, nil +} diff --git a/db/builder_exits.go b/db/builder_exits.go new file mode 100644 index 000000000..efce5c594 --- /dev/null +++ b/db/builder_exits.go @@ -0,0 +1,167 @@ +package db + +import ( + "context" + "fmt" + "strings" + + "github.com/ethpandaops/dora/dbtypes" + "github.com/jmoiron/sqlx" +) + +func InsertBuilderExits(ctx context.Context, tx *sqlx.Tx, exits []*dbtypes.BuilderExit) error { + var sql strings.Builder + fmt.Fprint(&sql, + EngineQuery(map[dbtypes.DBEngineType]string{ + dbtypes.DBEnginePgsql: "INSERT INTO builder_exits ", + dbtypes.DBEngineSqlite: "INSERT OR REPLACE INTO builder_exits ", + }), + "(slot_number, slot_root, slot_index, orphaned, fork_id, source_address, public_key, builder_index, tx_hash, block_number, result)", + " VALUES ", + ) + argIdx := 0 + fieldCount := 11 + + args := make([]interface{}, len(exits)*fieldCount) + for i, exit := range exits { + if i > 0 { + fmt.Fprintf(&sql, ", ") + } + fmt.Fprintf(&sql, "(") + for f := 0; f < fieldCount; f++ { + if f > 0 { + fmt.Fprintf(&sql, ", ") + } + fmt.Fprintf(&sql, "$%v", argIdx+f+1) + } + fmt.Fprintf(&sql, ")") + + args[argIdx+0] = exit.SlotNumber + args[argIdx+1] = exit.SlotRoot + args[argIdx+2] = exit.SlotIndex + args[argIdx+3] = exit.Orphaned + args[argIdx+4] = exit.ForkId + args[argIdx+5] = exit.SourceAddress + args[argIdx+6] = exit.PublicKey + args[argIdx+7] = exit.BuilderIndex + args[argIdx+8] = exit.TxHash + args[argIdx+9] = exit.BlockNumber + args[argIdx+10] = exit.Result + argIdx += fieldCount + } + fmt.Fprint(&sql, EngineQuery(map[dbtypes.DBEngineType]string{ + dbtypes.DBEnginePgsql: " ON CONFLICT (slot_root, slot_index) DO UPDATE SET orphaned = excluded.orphaned, fork_id = excluded.fork_id, builder_index = excluded.builder_index, result = excluded.result", + dbtypes.DBEngineSqlite: "", + })) + _, err := tx.ExecContext(ctx, sql.String(), args...) + if err != nil { + return err + } + return nil +} + +func GetBuilderExitsFiltered(ctx context.Context, offset uint64, limit uint32, canonicalForkIds []uint64, filter *dbtypes.BuilderExitFilter) ([]*dbtypes.BuilderExit, uint64, error) { + var sql strings.Builder + args := []interface{}{} + fmt.Fprint(&sql, ` + WITH cte AS ( + SELECT + slot_number, slot_root, slot_index, orphaned, fork_id, source_address, public_key, builder_index, tx_hash, block_number, result + FROM builder_exits + `) + + filterOp := "WHERE" + if filter.MinSlot > 0 { + args = append(args, filter.MinSlot) + fmt.Fprintf(&sql, " %v slot_number >= $%v", filterOp, len(args)) + filterOp = "AND" + } + if filter.MaxSlot > 0 { + args = append(args, filter.MaxSlot) + fmt.Fprintf(&sql, " %v slot_number <= $%v", filterOp, len(args)) + filterOp = "AND" + } + if len(filter.PublicKey) > 0 { + args = append(args, filter.PublicKey) + fmt.Fprintf(&sql, " %v public_key = $%v", filterOp, len(args)) + filterOp = "AND" + } + if len(filter.SourceAddress) > 0 { + args = append(args, filter.SourceAddress) + fmt.Fprintf(&sql, " %v source_address = $%v", filterOp, len(args)) + filterOp = "AND" + } + if filter.MinIndex > 0 { + args = append(args, filter.MinIndex) + fmt.Fprintf(&sql, " %v builder_index >= $%v", filterOp, len(args)) + filterOp = "AND" + } + if filter.MaxIndex > 0 { + args = append(args, filter.MaxIndex) + fmt.Fprintf(&sql, " %v builder_index <= $%v", filterOp, len(args)) + filterOp = "AND" + } + + appendWithOrphanedFilter(&sql, &args, &filterOp, filter.WithOrphaned, canonicalForkIds, "fork_id") + + args = append(args, limit) + fmt.Fprintf(&sql, `) + SELECT + count(*) AS slot_number, + null AS slot_root, + 0 AS slot_index, + false AS orphaned, + 0 AS fork_id, + null AS source_address, + null AS public_key, + null AS builder_index, + null AS tx_hash, + 0 AS block_number, + 0 AS result + FROM cte + UNION ALL SELECT * FROM ( + SELECT * FROM cte + ORDER BY slot_number DESC, slot_index DESC + LIMIT $%v + `, len(args)) + + if offset > 0 { + args = append(args, offset) + fmt.Fprintf(&sql, " OFFSET $%v ", len(args)) + } + fmt.Fprintf(&sql, ") AS t1") + + exits := []*dbtypes.BuilderExit{} + err := ReaderDb.SelectContext(ctx, &exits, sql.String(), args...) + if err != nil { + logger.Errorf("Error while fetching filtered builder exits: %v", err) + return nil, 0, err + } + + return exits[1:], exits[0].SlotNumber, nil +} + +func GetBuilderExitsByElBlockRange(ctx context.Context, firstBlock uint64, lastBlock uint64) []*dbtypes.BuilderExit { + exits := []*dbtypes.BuilderExit{} + + err := ReaderDb.SelectContext(ctx, &exits, ` + SELECT builder_exits.* + FROM builder_exits + WHERE block_number >= $1 AND block_number <= $2 + ORDER BY block_number ASC, slot_index ASC + `, firstBlock, lastBlock) + if err != nil { + logger.Errorf("Error while fetching builder exits: %v", err) + return nil + } + + return exits +} + +func UpdateBuilderExitTxHash(ctx context.Context, tx *sqlx.Tx, slotRoot []byte, slotIndex uint64, txHash []byte) error { + _, err := tx.ExecContext(ctx, `UPDATE builder_exits SET tx_hash = $1 WHERE slot_root = $2 AND slot_index = $3`, txHash, slotRoot, slotIndex) + if err != nil { + return err + } + return nil +} diff --git a/db/deposit_txs.go b/db/deposit_txs.go index 849d4f5c7..4d5297524 100644 --- a/db/deposit_txs.go +++ b/db/deposit_txs.go @@ -139,6 +139,50 @@ func GetDepositTxsByIndexes(ctx context.Context, indexes []uint64) []*dbtypes.De return depositTxs } +func GetDepositTxsByPublicKeys(ctx context.Context, publicKeys [][]byte) []*dbtypes.DepositTx { + depositTxs := []*dbtypes.DepositTx{} + if len(publicKeys) == 0 { + return depositTxs + } + + // SQLite's default SQLITE_MAX_VARIABLE_NUMBER is 999 (32766 in newer + // builds). Chunk to stay well below that on every supported build, + // otherwise high-volume queries fail with "too many SQL variables". + const maxParams = 900 + + for start := 0; start < len(publicKeys); start += maxParams { + end := start + maxParams + if end > len(publicKeys) { + end = len(publicKeys) + } + chunk := publicKeys[start:end] + + var sql strings.Builder + args := make([]any, len(chunk)) + + fmt.Fprint(&sql, `SELECT deposit_txs.* + FROM deposit_txs + WHERE publickey IN ( + `) + + for idx, publicKey := range chunk { + args[idx] = publicKey + } + appendDollarPlaceholders(&sql, 1, len(chunk), ", ") + fmt.Fprintf(&sql, ") ORDER BY block_number ASC, deposit_index ASC") + + var chunkResult []*dbtypes.DepositTx + err := ReaderDb.SelectContext(ctx, &chunkResult, sql.String(), args...) + if err != nil { + logger.Errorf("Error while fetching deposit txs by public keys: %v", err) + return nil + } + depositTxs = append(depositTxs, chunkResult...) + } + + return depositTxs +} + func GetDepositTxsFiltered(ctx context.Context, offset uint64, limit uint32, canonicalForkIds []uint64, filter *dbtypes.DepositTxFilter) ([]*dbtypes.DepositTx, uint64, error) { var sql strings.Builder args := []any{} diff --git a/db/schema/pgsql/20260617140000_builder-requests.sql b/db/schema/pgsql/20260617140000_builder-requests.sql new file mode 100644 index 000000000..2fecf2788 --- /dev/null +++ b/db/schema/pgsql/20260617140000_builder-requests.sql @@ -0,0 +1,134 @@ +-- +goose Up +-- +goose StatementBegin + +-- builder deposit requests (CL view, attributed to the processing slot) +CREATE TABLE IF NOT EXISTS public."builder_deposits" ( + slot_number BIGINT NOT NULL, + slot_root bytea NOT NULL, + slot_index INT NOT NULL, + orphaned bool NOT NULL DEFAULT FALSE, + fork_id BIGINT NOT NULL DEFAULT 0, + public_key bytea NOT NULL, + withdrawal_credentials bytea NOT NULL, + amount BIGINT NOT NULL, + signature bytea NULL, + builder_index BIGINT NULL, + tx_hash bytea NULL, + block_number BIGINT NOT NULL DEFAULT 0, + result smallint NOT NULL DEFAULT 0, + CONSTRAINT builder_deposits_pkey PRIMARY KEY (slot_root, slot_index) +); + +CREATE INDEX IF NOT EXISTS "builder_deposits_slot_number_idx" + ON public."builder_deposits" ("slot_number" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_deposits_public_key_idx" + ON public."builder_deposits" ("public_key" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_deposits_builder_index_idx" + ON public."builder_deposits" ("builder_index" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_deposits_amount_idx" + ON public."builder_deposits" ("amount" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_deposits_block_number_idx" + ON public."builder_deposits" ("block_number" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_deposits_fork_idx" + ON public."builder_deposits" ("fork_id" ASC NULLS FIRST); + +-- builder deposit requests (EL view, from the builder deposit system contract) +CREATE TABLE IF NOT EXISTS public."builder_deposit_request_txs" ( + block_number BIGINT NOT NULL, + block_index INT NOT NULL, + block_time BIGINT NOT NULL, + block_root bytea NOT NULL, + fork_id BIGINT NOT NULL DEFAULT 0, + public_key bytea NOT NULL, + withdrawal_credentials bytea NOT NULL, + amount BIGINT NOT NULL, + signature bytea NULL, + builder_index BIGINT NULL, + tx_hash bytea NULL, + tx_sender bytea NOT NULL, + tx_target bytea NOT NULL, + dequeue_block BIGINT NOT NULL, + CONSTRAINT builder_deposit_request_txs_pkey PRIMARY KEY (block_root, block_index) +); + +CREATE INDEX IF NOT EXISTS "builder_deposit_request_txs_block_number_idx" + ON public."builder_deposit_request_txs" ("block_number" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_deposit_request_txs_public_key_idx" + ON public."builder_deposit_request_txs" ("public_key" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_deposit_request_txs_builder_index_idx" + ON public."builder_deposit_request_txs" ("builder_index" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_deposit_request_txs_amount_idx" + ON public."builder_deposit_request_txs" ("amount" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_deposit_request_txs_tx_hash_idx" + ON public."builder_deposit_request_txs" ("tx_hash" ASC); +CREATE INDEX IF NOT EXISTS "builder_deposit_request_txs_fork_idx" + ON public."builder_deposit_request_txs" ("fork_id" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_deposit_request_txs_dequeue_block_idx" + ON public."builder_deposit_request_txs" ("dequeue_block" ASC NULLS FIRST); + +-- builder exit requests (CL view, attributed to the processing slot) +CREATE TABLE IF NOT EXISTS public."builder_exits" ( + slot_number BIGINT NOT NULL, + slot_root bytea NOT NULL, + slot_index INT NOT NULL, + orphaned bool NOT NULL DEFAULT FALSE, + fork_id BIGINT NOT NULL DEFAULT 0, + source_address bytea NOT NULL, + public_key bytea NOT NULL, + builder_index BIGINT NULL, + tx_hash bytea NULL, + block_number BIGINT NOT NULL DEFAULT 0, + result smallint NOT NULL DEFAULT 0, + CONSTRAINT builder_exits_pkey PRIMARY KEY (slot_root, slot_index) +); + +CREATE INDEX IF NOT EXISTS "builder_exits_slot_number_idx" + ON public."builder_exits" ("slot_number" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_exits_public_key_idx" + ON public."builder_exits" ("public_key" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_exits_source_addr_idx" + ON public."builder_exits" ("source_address" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_exits_builder_index_idx" + ON public."builder_exits" ("builder_index" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_exits_block_number_idx" + ON public."builder_exits" ("block_number" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_exits_fork_idx" + ON public."builder_exits" ("fork_id" ASC NULLS FIRST); + +-- builder exit requests (EL view, from the builder exit system contract) +CREATE TABLE IF NOT EXISTS public."builder_exit_request_txs" ( + block_number BIGINT NOT NULL, + block_index INT NOT NULL, + block_time BIGINT NOT NULL, + block_root bytea NOT NULL, + fork_id BIGINT NOT NULL DEFAULT 0, + source_address bytea NOT NULL, + public_key bytea NOT NULL, + builder_index BIGINT NULL, + tx_hash bytea NULL, + tx_sender bytea NOT NULL, + tx_target bytea NOT NULL, + dequeue_block BIGINT NOT NULL, + CONSTRAINT builder_exit_request_txs_pkey PRIMARY KEY (block_root, block_index) +); + +CREATE INDEX IF NOT EXISTS "builder_exit_request_txs_block_number_idx" + ON public."builder_exit_request_txs" ("block_number" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_exit_request_txs_public_key_idx" + ON public."builder_exit_request_txs" ("public_key" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_exit_request_txs_source_addr_idx" + ON public."builder_exit_request_txs" ("source_address" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_exit_request_txs_builder_index_idx" + ON public."builder_exit_request_txs" ("builder_index" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_exit_request_txs_tx_hash_idx" + ON public."builder_exit_request_txs" ("tx_hash" ASC); +CREATE INDEX IF NOT EXISTS "builder_exit_request_txs_fork_idx" + ON public."builder_exit_request_txs" ("fork_id" ASC NULLS FIRST); +CREATE INDEX IF NOT EXISTS "builder_exit_request_txs_dequeue_block_idx" + ON public."builder_exit_request_txs" ("dequeue_block" ASC NULLS FIRST); + +-- +goose StatementEnd +-- +goose Down +-- +goose StatementBegin +SELECT 'NOT SUPPORTED'; +-- +goose StatementEnd diff --git a/db/schema/sqlite/20260617140000_builder-requests.sql b/db/schema/sqlite/20260617140000_builder-requests.sql new file mode 100644 index 000000000..4c7a58b65 --- /dev/null +++ b/db/schema/sqlite/20260617140000_builder-requests.sql @@ -0,0 +1,134 @@ +-- +goose Up +-- +goose StatementBegin + +-- builder deposit requests (CL view, attributed to the processing slot) +CREATE TABLE IF NOT EXISTS "builder_deposits" ( + slot_number BIGINT NOT NULL, + slot_root BLOB NOT NULL, + slot_index INT NOT NULL, + orphaned bool NOT NULL DEFAULT FALSE, + fork_id BIGINT NOT NULL DEFAULT 0, + public_key BLOB NOT NULL, + withdrawal_credentials BLOB NOT NULL, + amount BIGINT NOT NULL, + signature BLOB NULL, + builder_index BIGINT NULL, + tx_hash BLOB NULL, + block_number BIGINT NOT NULL DEFAULT 0, + result TINYINT NOT NULL DEFAULT 0, + CONSTRAINT builder_deposits_pkey PRIMARY KEY (slot_root, slot_index) +); + +CREATE INDEX IF NOT EXISTS "builder_deposits_slot_number_idx" + ON "builder_deposits" ("slot_number" ASC); +CREATE INDEX IF NOT EXISTS "builder_deposits_public_key_idx" + ON "builder_deposits" ("public_key" ASC); +CREATE INDEX IF NOT EXISTS "builder_deposits_builder_index_idx" + ON "builder_deposits" ("builder_index" ASC); +CREATE INDEX IF NOT EXISTS "builder_deposits_amount_idx" + ON "builder_deposits" ("amount" ASC); +CREATE INDEX IF NOT EXISTS "builder_deposits_block_number_idx" + ON "builder_deposits" ("block_number" ASC); +CREATE INDEX IF NOT EXISTS "builder_deposits_fork_idx" + ON "builder_deposits" ("fork_id" ASC); + +-- builder deposit requests (EL view, from the builder deposit system contract) +CREATE TABLE IF NOT EXISTS "builder_deposit_request_txs" ( + block_number BIGINT NOT NULL, + block_index INT NOT NULL, + block_time BIGINT NOT NULL, + block_root BLOB NOT NULL, + fork_id BIGINT NOT NULL DEFAULT 0, + public_key BLOB NOT NULL, + withdrawal_credentials BLOB NOT NULL, + amount BIGINT NOT NULL, + signature BLOB NULL, + builder_index BIGINT NULL, + tx_hash BLOB NULL, + tx_sender BLOB NOT NULL, + tx_target BLOB NOT NULL, + dequeue_block BIGINT NOT NULL, + CONSTRAINT builder_deposit_request_txs_pkey PRIMARY KEY (block_root, block_index) +); + +CREATE INDEX IF NOT EXISTS "builder_deposit_request_txs_block_number_idx" + ON "builder_deposit_request_txs" ("block_number" ASC); +CREATE INDEX IF NOT EXISTS "builder_deposit_request_txs_public_key_idx" + ON "builder_deposit_request_txs" ("public_key" ASC); +CREATE INDEX IF NOT EXISTS "builder_deposit_request_txs_builder_index_idx" + ON "builder_deposit_request_txs" ("builder_index" ASC); +CREATE INDEX IF NOT EXISTS "builder_deposit_request_txs_amount_idx" + ON "builder_deposit_request_txs" ("amount" ASC); +CREATE INDEX IF NOT EXISTS "builder_deposit_request_txs_tx_hash_idx" + ON "builder_deposit_request_txs" ("tx_hash" ASC); +CREATE INDEX IF NOT EXISTS "builder_deposit_request_txs_fork_idx" + ON "builder_deposit_request_txs" ("fork_id" ASC); +CREATE INDEX IF NOT EXISTS "builder_deposit_request_txs_dequeue_block_idx" + ON "builder_deposit_request_txs" ("dequeue_block" ASC); + +-- builder exit requests (CL view, attributed to the processing slot) +CREATE TABLE IF NOT EXISTS "builder_exits" ( + slot_number BIGINT NOT NULL, + slot_root BLOB NOT NULL, + slot_index INT NOT NULL, + orphaned bool NOT NULL DEFAULT FALSE, + fork_id BIGINT NOT NULL DEFAULT 0, + source_address BLOB NOT NULL, + public_key BLOB NOT NULL, + builder_index BIGINT NULL, + tx_hash BLOB NULL, + block_number BIGINT NOT NULL DEFAULT 0, + result TINYINT NOT NULL DEFAULT 0, + CONSTRAINT builder_exits_pkey PRIMARY KEY (slot_root, slot_index) +); + +CREATE INDEX IF NOT EXISTS "builder_exits_slot_number_idx" + ON "builder_exits" ("slot_number" ASC); +CREATE INDEX IF NOT EXISTS "builder_exits_public_key_idx" + ON "builder_exits" ("public_key" ASC); +CREATE INDEX IF NOT EXISTS "builder_exits_source_addr_idx" + ON "builder_exits" ("source_address" ASC); +CREATE INDEX IF NOT EXISTS "builder_exits_builder_index_idx" + ON "builder_exits" ("builder_index" ASC); +CREATE INDEX IF NOT EXISTS "builder_exits_block_number_idx" + ON "builder_exits" ("block_number" ASC); +CREATE INDEX IF NOT EXISTS "builder_exits_fork_idx" + ON "builder_exits" ("fork_id" ASC); + +-- builder exit requests (EL view, from the builder exit system contract) +CREATE TABLE IF NOT EXISTS "builder_exit_request_txs" ( + block_number BIGINT NOT NULL, + block_index INT NOT NULL, + block_time BIGINT NOT NULL, + block_root BLOB NOT NULL, + fork_id BIGINT NOT NULL DEFAULT 0, + source_address BLOB NOT NULL, + public_key BLOB NOT NULL, + builder_index BIGINT NULL, + tx_hash BLOB NULL, + tx_sender BLOB NOT NULL, + tx_target BLOB NOT NULL, + dequeue_block BIGINT NOT NULL, + CONSTRAINT builder_exit_request_txs_pkey PRIMARY KEY (block_root, block_index) +); + +CREATE INDEX IF NOT EXISTS "builder_exit_request_txs_block_number_idx" + ON "builder_exit_request_txs" ("block_number" ASC); +CREATE INDEX IF NOT EXISTS "builder_exit_request_txs_public_key_idx" + ON "builder_exit_request_txs" ("public_key" ASC); +CREATE INDEX IF NOT EXISTS "builder_exit_request_txs_source_addr_idx" + ON "builder_exit_request_txs" ("source_address" ASC); +CREATE INDEX IF NOT EXISTS "builder_exit_request_txs_builder_index_idx" + ON "builder_exit_request_txs" ("builder_index" ASC); +CREATE INDEX IF NOT EXISTS "builder_exit_request_txs_tx_hash_idx" + ON "builder_exit_request_txs" ("tx_hash" ASC); +CREATE INDEX IF NOT EXISTS "builder_exit_request_txs_fork_idx" + ON "builder_exit_request_txs" ("fork_id" ASC); +CREATE INDEX IF NOT EXISTS "builder_exit_request_txs_dequeue_block_idx" + ON "builder_exit_request_txs" ("dequeue_block" ASC); + +-- +goose StatementEnd +-- +goose Down +-- +goose StatementBegin +SELECT 'NOT SUPPORTED'; +-- +goose StatementEnd diff --git a/dbtypes/dbtypes.go b/dbtypes/dbtypes.go index 88871d8a0..5d745b3c0 100644 --- a/dbtypes/dbtypes.go +++ b/dbtypes/dbtypes.go @@ -437,6 +437,99 @@ type WithdrawalRequestTx struct { DequeueBlock uint64 `db:"dequeue_block"` } +const ( + BuilderDepositRequestResultUnknown uint8 = 0 + BuilderDepositRequestResultSuccess uint8 = 1 + + // builder registration outcomes + BuilderDepositRequestResultNewBuilder uint8 = 2 + BuilderDepositRequestResultTopUp uint8 = 3 + + // errors + BuilderDepositRequestResultInvalidSignature uint8 = 20 +) + +// BuilderDeposit is the consensus-layer view of a builder deposit request +// (Gloas/EIP-8282), attributed to the slot that processes it. +type BuilderDeposit struct { + SlotNumber uint64 `db:"slot_number"` + SlotRoot []byte `db:"slot_root"` + SlotIndex uint64 `db:"slot_index"` + Orphaned bool `db:"orphaned"` + ForkId uint64 `db:"fork_id"` + PublicKey []byte `db:"public_key"` + WithdrawalCredentials []byte `db:"withdrawal_credentials"` + Amount uint64 `db:"amount"` + Signature []byte `db:"signature"` + BuilderIndex *uint64 `db:"builder_index"` + TxHash []byte `db:"tx_hash"` + BlockNumber uint64 `db:"block_number"` + Result uint8 `db:"result"` +} + +// BuilderDepositTx is the execution-layer view of a builder deposit request, +// indexed from the builder deposit system contract. +type BuilderDepositTx struct { + BlockNumber uint64 `db:"block_number"` + BlockIndex uint64 `db:"block_index"` + BlockTime uint64 `db:"block_time"` + BlockRoot []byte `db:"block_root"` + ForkId uint64 `db:"fork_id"` + PublicKey []byte `db:"public_key"` + WithdrawalCredentials []byte `db:"withdrawal_credentials"` + Amount uint64 `db:"amount"` + Signature []byte `db:"signature"` + BuilderIndex *uint64 `db:"builder_index"` + TxHash []byte `db:"tx_hash"` + TxSender []byte `db:"tx_sender"` + TxTarget []byte `db:"tx_target"` + DequeueBlock uint64 `db:"dequeue_block"` +} + +const ( + BuilderExitRequestResultUnknown uint8 = 0 + BuilderExitRequestResultSuccess uint8 = 1 + + // errors + BuilderExitRequestResultBuilderNotFound uint8 = 20 + BuilderExitRequestResultNotActive uint8 = 21 + BuilderExitRequestResultInvalidSource uint8 = 22 + BuilderExitRequestResultHasPendingWithdrawal uint8 = 23 +) + +// BuilderExit is the consensus-layer view of a builder exit request +// (Gloas/EIP-8282), attributed to the slot that processes it. +type BuilderExit struct { + SlotNumber uint64 `db:"slot_number"` + SlotRoot []byte `db:"slot_root"` + SlotIndex uint64 `db:"slot_index"` + Orphaned bool `db:"orphaned"` + ForkId uint64 `db:"fork_id"` + SourceAddress []byte `db:"source_address"` + PublicKey []byte `db:"public_key"` + BuilderIndex *uint64 `db:"builder_index"` + TxHash []byte `db:"tx_hash"` + BlockNumber uint64 `db:"block_number"` + Result uint8 `db:"result"` +} + +// BuilderExitTx is the execution-layer view of a builder exit request, indexed +// from the builder exit system contract. +type BuilderExitTx struct { + BlockNumber uint64 `db:"block_number"` + BlockIndex uint64 `db:"block_index"` + BlockTime uint64 `db:"block_time"` + BlockRoot []byte `db:"block_root"` + ForkId uint64 `db:"fork_id"` + SourceAddress []byte `db:"source_address"` + PublicKey []byte `db:"public_key"` + BuilderIndex *uint64 `db:"builder_index"` + TxHash []byte `db:"tx_hash"` + TxSender []byte `db:"tx_sender"` + TxTarget []byte `db:"tx_target"` + DequeueBlock uint64 `db:"dequeue_block"` +} + type Validator struct { ValidatorIndex uint64 `db:"validator_index"` Pubkey []byte `db:"pubkey"` diff --git a/dbtypes/other.go b/dbtypes/other.go index 68c4eb63c..01250853b 100644 --- a/dbtypes/other.go +++ b/dbtypes/other.go @@ -204,6 +204,46 @@ type ConsolidationRequestTxFilter struct { WithOrphaned uint8 } +type BuilderDepositFilter struct { + MinSlot uint64 + MaxSlot uint64 + PublicKey []byte + MinIndex uint64 + MaxIndex uint64 + MinAmount *uint64 + MaxAmount *uint64 + WithOrphaned uint8 +} + +type BuilderDepositTxFilter struct { + MinDequeue uint64 + MaxDequeue uint64 + PublicKey []byte + MinIndex uint64 + MaxIndex uint64 + MinAmount *uint64 + MaxAmount *uint64 +} + +type BuilderExitFilter struct { + MinSlot uint64 + MaxSlot uint64 + PublicKey []byte + SourceAddress []byte + MinIndex uint64 + MaxIndex uint64 + WithOrphaned uint8 +} + +type BuilderExitTxFilter struct { + MinDequeue uint64 + MaxDequeue uint64 + PublicKey []byte + SourceAddress []byte + MinIndex uint64 + MaxIndex uint64 +} + type ValidatorOrder uint8 const ( diff --git a/go.mod b/go.mod index 3735c7148..73e8e5ec2 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/ethpandaops/eth-das-guardian v0.1.1 github.com/ethpandaops/ethcore v0.0.0-20260320045412-9cdd5d70a29c github.com/ethpandaops/ethwallclock v0.4.0 - github.com/ethpandaops/go-eth2-client v0.1.3 + github.com/ethpandaops/go-eth2-client v0.1.4 github.com/go-redis/redis/v8 v8.11.5 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/mux v1.8.1 @@ -23,7 +23,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.44 github.com/minio/minio-go/v7 v7.1.0 github.com/mitchellh/mapstructure v1.5.0 - github.com/pk910/dynamic-ssz v1.3.2-0.20260610112910-1d3a73e3fe4c + github.com/pk910/dynamic-ssz v1.3.2 github.com/pressly/goose/v3 v3.27.1 github.com/protolambda/bls12-381-util v0.1.0 github.com/protolambda/zrnt v0.34.1 @@ -143,7 +143,7 @@ require ( github.com/pion/transport/v3 v3.0.7 // indirect github.com/pion/turn/v4 v4.1.1 // indirect github.com/pion/webrtc/v4 v4.1.4 // indirect - github.com/pk910/hashtree-bindings v0.2.1 // indirect + github.com/pk910/hashtree-bindings v0.2.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prysmaticlabs/fastssz v0.0.0-20241008181541-518c4ce73516 // indirect github.com/prysmaticlabs/gohashtree v0.0.5-beta // indirect diff --git a/go.sum b/go.sum index e8b9904c8..5eba08f8e 100644 --- a/go.sum +++ b/go.sum @@ -120,8 +120,8 @@ github.com/ethpandaops/ethcore v0.0.0-20260320045412-9cdd5d70a29c h1:uBRIitwcuCJ github.com/ethpandaops/ethcore v0.0.0-20260320045412-9cdd5d70a29c/go.mod h1:QsmYTdesob+vQ6pW4KtRVvxLZUNop3cdtd/DgD30hJU= github.com/ethpandaops/ethwallclock v0.4.0 h1:+sgnhf4pk6hLPukP076VxkiLloE4L0Yk1yat+ZyHh1g= github.com/ethpandaops/ethwallclock v0.4.0/go.mod h1:y0Cu+mhGLlem19vnAV2x0hpFS5KZ7oOi2SWYayv9l24= -github.com/ethpandaops/go-eth2-client v0.1.3 h1:ZftRDaJfjT+fzgYAkOsFJmyWGnfkyKu7WYuXGLJtwQ8= -github.com/ethpandaops/go-eth2-client v0.1.3/go.mod h1:U3KdR8QSq8vqs9LWSGAF4ETHJpcB62E1DFf0gVMgWv0= +github.com/ethpandaops/go-eth2-client v0.1.4 h1:x3XC/u8sr7S44Y+3NTA5smHZLTXPdIe/zlQSK4RZXjo= +github.com/ethpandaops/go-eth2-client v0.1.4/go.mod h1:U3KdR8QSq8vqs9LWSGAF4ETHJpcB62E1DFf0gVMgWv0= github.com/ferranbt/fastssz v1.0.0 h1:9EXXYsracSqQRBQiHeaVsG/KQeYblPf40hsQPb9Dzk8= github.com/ferranbt/fastssz v1.0.0/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= github.com/filecoin-project/go-clock v0.1.0 h1:SFbYIM75M8NnFm1yMHhN9Ahy3W5bEZV9gd6MPfXbKVU= @@ -554,10 +554,10 @@ github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc= github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8= github.com/pion/webrtc/v4 v4.1.4 h1:/gK1ACGHXQmtyVVbJFQDxNoODg4eSRiFLB7t9r9pg8M= github.com/pion/webrtc/v4 v4.1.4/go.mod h1:Oab9npu1iZtQRMic3K3toYq5zFPvToe/QBw7dMI2ok4= -github.com/pk910/dynamic-ssz v1.3.2-0.20260610112910-1d3a73e3fe4c h1:F3XWSYDOjIXuISrJ7OXFiEVwW3+PYhhykQjgBEP7smo= -github.com/pk910/dynamic-ssz v1.3.2-0.20260610112910-1d3a73e3fe4c/go.mod h1:nuVJ0CMRKmzGYkV+W0vE1m+XbYlkEt0R+tO7PKnqo2U= -github.com/pk910/hashtree-bindings v0.2.1 h1:YoZ1fkXCVJNVYAUQUN2MWE1yHXQtmzy+ZiVbWKR7ojw= -github.com/pk910/hashtree-bindings v0.2.1/go.mod h1:zrWt88783JmhBfcgni6kkIMYRdXTZi/FL//OyI5T/l4= +github.com/pk910/dynamic-ssz v1.3.2 h1:65UR/O+ss+U2Dn86Rdl7LwehHo3u2ElutduS/pcuUXE= +github.com/pk910/dynamic-ssz v1.3.2/go.mod h1:lqmnou2bjr2UWQ3C/L3082TGW0SFl/SwT7ionwM0+FU= +github.com/pk910/hashtree-bindings v0.2.2 h1:gkczxxekBW2NeMK9N3OLj7Jepe7zPmJGVwr8LyofGsA= +github.com/pk910/hashtree-bindings v0.2.2/go.mod h1:zrWt88783JmhBfcgni6kkIMYRdXTZi/FL//OyI5T/l4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/handlers/block.go b/handlers/block.go index b3bfa1fd4..c8bea7f9a 100644 --- a/handlers/block.go +++ b/handlers/block.go @@ -60,17 +60,17 @@ func Block(w http.ResponseWriter, r *http.Request) { func getBlockPageRedirect(numberOrHash string) ([]byte, error) { pageData := []byte{} pageCacheKey := fmt.Sprintf("block_redirect:%s", numberOrHash) - pageRes, pageErr := services.GlobalFrontendCache.ProcessCachedPage(pageCacheKey, true, pageData, func(pageCall *services.FrontendCacheProcessingPage) interface{} { - pageData, cacheTimeout := buildBlockPageRedirect(pageCall.CallCtx, numberOrHash) + pageRes, pageErr := services.GlobalFrontendCache.ProcessCachedPage(pageCacheKey, true, &pageData, func(pageCall *services.FrontendCacheProcessingPage) interface{} { + redirect, cacheTimeout := buildBlockPageRedirect(pageCall.CallCtx, numberOrHash) pageCall.CacheTimeout = cacheTimeout - return pageData + return &redirect }) if pageErr == nil && pageRes != nil { - resData, resOk := pageRes.([]byte) + resData, resOk := pageRes.(*[]byte) if !resOk { return nil, ErrInvalidPageModel } - pageData = resData + pageData = *resData } return pageData, pageErr } diff --git a/handlers/blocks.go b/handlers/blocks.go index 30346315c..794c06706 100644 --- a/handlers/blocks.go +++ b/handlers/blocks.go @@ -300,8 +300,9 @@ func buildBlocksPageData(ctx context.Context, firstSlot uint64, pageSize uint64, } } - // Add builder info - if pageData.DisplayBuilder { + // Add builder info (needed for the Builder column and the proposer build-source icon). + // Only blocks that actually exist (proposed or orphaned) carry a build source. + if (pageData.DisplayBuilder || pageData.DisplayProposer) && dbSlot.Status > 0 { if dbSlot.BuilderIndex == -1 { slotData.HasBuilder = true slotData.BuilderIndex = math.MaxUint64 diff --git a/handlers/builder.go b/handlers/builder.go index 3c2eae1e0..0f36952ed 100644 --- a/handlers/builder.go +++ b/handlers/builder.go @@ -69,11 +69,12 @@ func BuilderDetail(w http.ResponseWriter, r *http.Request) { // search by pubkey - check cache first (more accurate), then fall back to DB var pubkey phase0.BLSPubKey copy(pubkey[:], builderPubKey) - if validatorIdx, found := services.GlobalBeaconService.GetValidatorIndexByPubkey(pubkey); found { - idx := uint64(validatorIdx) - if idx&services.BuilderIndexFlag != 0 { - builderIndex = idx &^ services.BuilderIndexFlag - builder = services.GlobalBeaconService.GetBuilderByIndex(gloas.BuilderIndex(builderIndex)) + if builderIdx, found := services.GlobalBeaconService.GetBuilderIndexByPubkey(pubkey); found { + // builder indexes can be reused (EIP-8282): only accept the cache hit if this pubkey + // still owns the index, otherwise fall through to the DB lookup by pubkey below. + if b := services.GlobalBeaconService.GetBuilderByIndex(builderIdx); b != nil && b.PublicKey == pubkey { + builderIndex = uint64(builderIdx) + builder = b } } @@ -462,33 +463,42 @@ func buildBuilderRecentBids(ctx context.Context, builderIndex uint64, chainState func buildBuilderRecentDeposits(ctx context.Context, pubkey []byte, chainState *consensus.ChainState) []*models.BuilderPageDataDeposit { result := make([]*models.BuilderPageDataDeposit, 0) - // Query deposit requests by builder pubkey - depositFilter := &services.CombinedDepositRequestFilter{ - Filter: &dbtypes.DepositTxFilter{ - PublicKey: pubkey, - WithOrphaned: 1, - }, + // Query the dedicated builder deposit requests (EIP-8282) by builder pubkey. + filter := &dbtypes.BuilderDepositFilter{ + PublicKey: pubkey, + WithOrphaned: 1, } - deposits, _ := services.GlobalBeaconService.GetDepositRequestsByFilter(ctx, depositFilter, 0, 20) + deposits, _, _ := services.GlobalBeaconService.GetBuilderDepositsByFilter(ctx, filter, 0, 20) + + // builder deposits at the exact Gloas fork boundary slot are the one-time onboarding of + // builders from the pending deposit queue (they came through the validator deposit contract). + onboardingSlot, hasOnboardingSlot := uint64(0), false + if specs := chainState.GetSpecs(); specs.GloasForkEpoch != nil { + onboardingSlot = *specs.GloasForkEpoch * specs.SlotsPerEpoch + hasOnboardingSlot = true + } + for _, deposit := range deposits { entry := &models.BuilderPageDataDeposit{ - Type: "deposit", - Amount: deposit.Amount(), - DepositorAddress: deposit.SourceAddress(), + Type: "deposit", } if deposit.Request != nil { entry.SlotNumber = deposit.Request.SlotNumber entry.SlotRoot = deposit.Request.SlotRoot entry.Time = chainState.SlotToTime(phase0.Slot(deposit.Request.SlotNumber)) entry.Orphaned = deposit.RequestOrphaned + entry.Amount = deposit.Request.Amount + entry.IsOnboarding = hasOnboardingSlot && deposit.Request.SlotNumber == onboardingSlot } else if deposit.Transaction != nil { - entry.Time = chainState.SlotToTime(phase0.Slot(deposit.Transaction.BlockTime)) + entry.Amount = deposit.Transaction.Amount + entry.Time = time.Unix(int64(deposit.Transaction.BlockTime), 0) } // Add transaction details if available if deposit.Transaction != nil { entry.HasTransaction = true entry.TransactionHash = deposit.Transaction.TxHash + entry.DepositorAddress = deposit.Transaction.TxSender entry.TransactionDetails = &models.BuilderPageDataDepositTxDetails{ BlockNumber: deposit.Transaction.BlockNumber, BlockHash: fmt.Sprintf("%#x", deposit.Transaction.BlockRoot), diff --git a/handlers/builder_deposits.go b/handlers/builder_deposits.go new file mode 100644 index 000000000..b5d606163 --- /dev/null +++ b/handlers/builder_deposits.go @@ -0,0 +1,476 @@ +package handlers + +import ( + "bytes" + "context" + "fmt" + "math" + "net/http" + "net/url" + "strconv" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethpandaops/dora/dbtypes" + "github.com/ethpandaops/dora/services" + "github.com/ethpandaops/dora/templates" + "github.com/ethpandaops/dora/types/models" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/sirupsen/logrus" +) + +// BuilderDeposits will return the filtered "builder_deposits" page using a go template +func BuilderDeposits(w http.ResponseWriter, r *http.Request) { + var templateFiles = append(layoutTemplateFiles, + "builder_deposits/builder_deposits.html", + "_shared/txDetailsModal.html", + "_svg/professor.html", + ) + + var pageTemplate = templates.GetTemplate(templateFiles...) + data := InitPageData(w, r, "builders", "/builders/deposits", "Builder Deposits", templateFiles) + + urlArgs := r.URL.Query() + var pageSize uint64 = 50 + if urlArgs.Has("c") { + pageSize, _ = strconv.ParseUint(urlArgs.Get("c"), 10, 64) + } + var pageIdx uint64 = 1 + if urlArgs.Has("p") { + pageIdx, _ = strconv.ParseUint(urlArgs.Get("p"), 10, 64) + if pageIdx < 1 { + pageIdx = 1 + } + } + + var minSlot, maxSlot, minIndex, maxIndex, minAmount, maxAmount uint64 + var pubkey string + + if urlArgs.Has("f") { + if urlArgs.Has("f.mins") { + minSlot, _ = strconv.ParseUint(urlArgs.Get("f.mins"), 10, 64) + } + if urlArgs.Has("f.maxs") { + maxSlot, _ = strconv.ParseUint(urlArgs.Get("f.maxs"), 10, 64) + } + if urlArgs.Has("f.pubkey") { + pubkey = urlArgs.Get("f.pubkey") + } + if urlArgs.Has("f.mini") { + minIndex, _ = strconv.ParseUint(urlArgs.Get("f.mini"), 10, 64) + } + if urlArgs.Has("f.maxi") { + maxIndex, _ = strconv.ParseUint(urlArgs.Get("f.maxi"), 10, 64) + } + if urlArgs.Has("f.mina") { + minAmount, _ = strconv.ParseUint(urlArgs.Get("f.mina"), 10, 64) + } + if urlArgs.Has("f.maxa") { + maxAmount, _ = strconv.ParseUint(urlArgs.Get("f.maxa"), 10, 64) + } + } + + var pageError error + pageError = services.GlobalCallRateLimiter.CheckCallLimit(r, 2) + if pageError == nil { + data.Data, pageError = getBuilderDepositsPageData(pageIdx, pageSize, minSlot, maxSlot, pubkey, minIndex, maxIndex, minAmount, maxAmount) + } + if pageError != nil { + handlePageError(w, r, pageError) + return + } + w.Header().Set("Content-Type", "text/html") + if handleTemplateError(w, r, "builder_deposits.go", "BuilderDeposits", "", pageTemplate.ExecuteTemplate(w, "layout", data)) != nil { + return + } +} + +func getBuilderDepositsPageData(pageIdx uint64, pageSize uint64, minSlot uint64, maxSlot uint64, pubkey string, minIndex uint64, maxIndex uint64, minAmount uint64, maxAmount uint64) (*models.BuilderDepositsPageData, error) { + pageData := &models.BuilderDepositsPageData{} + pageCacheKey := fmt.Sprintf("builder_deposits:%v:%v:%v:%v:%v:%v:%v:%v:%v", pageIdx, pageSize, minSlot, maxSlot, pubkey, minIndex, maxIndex, minAmount, maxAmount) + pageRes, pageErr := services.GlobalFrontendCache.ProcessCachedPage(pageCacheKey, true, pageData, func(pageCall *services.FrontendCacheProcessingPage) interface{} { + return buildBuilderDepositsPageData(pageCall.CallCtx, pageIdx, pageSize, minSlot, maxSlot, pubkey, minIndex, maxIndex, minAmount, maxAmount) + }) + if pageErr == nil && pageRes != nil { + resData, resOk := pageRes.(*models.BuilderDepositsPageData) + if !resOk { + return nil, ErrInvalidPageModel + } + pageData = resData + } + return pageData, pageErr +} + +func buildBuilderDepositsPageData(ctx context.Context, pageIdx uint64, pageSize uint64, minSlot uint64, maxSlot uint64, pubkey string, minIndex uint64, maxIndex uint64, minAmount uint64, maxAmount uint64) *models.BuilderDepositsPageData { + // Before the fork the real builder_deposits table is empty: if Gloas is scheduled but not yet + // active, show the builders projected to be onboarded from the pending deposit queue instead. + chainSpecs := services.GlobalBeaconService.GetChainState().GetSpecs() + currentEpoch := uint64(services.GlobalBeaconService.GetChainState().CurrentEpoch()) + if chainSpecs != nil && chainSpecs.GloasForkEpoch != nil && + *chainSpecs.GloasForkEpoch < math.MaxUint64 && currentEpoch < *chainSpecs.GloasForkEpoch { + return buildBuilderDepositsProjectionPageData(ctx, pageIdx, pageSize, minSlot, maxSlot, pubkey, minIndex, maxIndex, minAmount, maxAmount) + } + + filterArgs := url.Values{} + if minSlot != 0 { + filterArgs.Add("f.mins", fmt.Sprintf("%v", minSlot)) + } + if maxSlot != 0 { + filterArgs.Add("f.maxs", fmt.Sprintf("%v", maxSlot)) + } + if pubkey != "" { + filterArgs.Add("f.pubkey", pubkey) + } + if minIndex != 0 { + filterArgs.Add("f.mini", fmt.Sprintf("%v", minIndex)) + } + if maxIndex != 0 { + filterArgs.Add("f.maxi", fmt.Sprintf("%v", maxIndex)) + } + if minAmount != 0 { + filterArgs.Add("f.mina", fmt.Sprintf("%v", minAmount)) + } + if maxAmount != 0 { + filterArgs.Add("f.maxa", fmt.Sprintf("%v", maxAmount)) + } + + pageData := &models.BuilderDepositsPageData{ + FilterMinSlot: minSlot, + FilterMaxSlot: maxSlot, + FilterPubKey: pubkey, + FilterMinIndex: minIndex, + FilterMaxIndex: maxIndex, + FilterMinAmount: minAmount, + FilterMaxAmount: maxAmount, + } + logrus.Debugf("builder_deposits page called: %v:%v [%v,%v,%v]", pageIdx, pageSize, minSlot, maxSlot, pubkey) + if pageIdx == 1 { + pageData.IsDefaultPage = true + } + if pageSize > 100 { + pageSize = 100 + } + pageData.PageSize = pageSize + pageData.CurrentPageIndex = pageIdx + if pageIdx > 1 { + pageData.PrevPageIndex = pageIdx - 1 + } + + filter := &dbtypes.BuilderDepositFilter{ + MinSlot: minSlot, + MaxSlot: maxSlot, + PublicKey: common.FromHex(pubkey), + MinIndex: minIndex, + MaxIndex: maxIndex, + WithOrphaned: 1, + } + if minAmount != 0 { + filter.MinAmount = &minAmount + } + if maxAmount != 0 { + filter.MaxAmount = &maxAmount + } + + offset := (pageIdx - 1) * pageSize + combined, totalTxRows, totalReqRows := services.GlobalBeaconService.GetBuilderDepositsByFilter(ctx, filter, offset, uint32(pageSize)) + totalRows := totalTxRows + totalReqRows + + chainState := services.GlobalBeaconService.GetChainState() + + // builder deposits at the exact Gloas fork boundary slot are the one-time onboarding of + // builders from the pending deposit queue (they came through the validator deposit contract). + onboardingSlot, hasOnboardingSlot := uint64(0), false + if specs := chainState.GetSpecs(); specs.GloasForkEpoch != nil { + onboardingSlot = *specs.GloasForkEpoch * specs.SlotsPerEpoch + hasOnboardingSlot = true + } + + // builderIdxOf returns the builder index recorded for a deposit (CL request preferred, else + // the pending EL tx), if any. + builderIdxOf := func(deposit *services.CombinedBuilderDeposit) *uint64 { + if deposit.Request != nil && deposit.Request.BuilderIndex != nil { + return deposit.Request.BuilderIndex + } + if deposit.Transaction != nil && deposit.Transaction.BuilderIndex != nil { + return deposit.Transaction.BuilderIndex + } + return nil + } + + // collect the builder indexes to resolve so we can batch-load the builders and tell whether + // each pubkey still owns its (reusable) index or was superseded. + indexes := make([]gloas.BuilderIndex, 0, len(combined)) + for _, deposit := range combined { + if idx := builderIdxOf(deposit); idx != nil { + indexes = append(indexes, gloas.BuilderIndex(*idx)) + } + } + builders := services.GlobalBeaconService.GetActiveBuildersByIndexes(ctx, indexes) + + for _, deposit := range combined { + depositData := &models.BuilderDepositsPageDataDeposit{} + + if deposit.Request != nil { + depositData.IsIncluded = true + depositData.SlotNumber = deposit.Request.SlotNumber + depositData.SlotRoot = deposit.Request.SlotRoot + depositData.Time = chainState.SlotToTime(phase0.Slot(deposit.Request.SlotNumber)) + depositData.Orphaned = deposit.RequestOrphaned + depositData.PublicKey = deposit.Request.PublicKey + depositData.WithdrawalCredentials = deposit.Request.WithdrawalCredentials + depositData.Amount = deposit.Request.Amount + depositData.Result = deposit.Request.Result + depositData.BlockNumber = deposit.Request.BlockNumber + depositData.IsOnboarding = hasOnboardingSlot && deposit.Request.SlotNumber == onboardingSlot + } else if deposit.Transaction != nil { + depositData.PublicKey = deposit.Transaction.PublicKey + depositData.WithdrawalCredentials = deposit.Transaction.WithdrawalCredentials + depositData.Amount = deposit.Transaction.Amount + depositData.BlockNumber = deposit.Transaction.BlockNumber + } + + if idx := builderIdxOf(deposit); idx != nil { + if b := builders[gloas.BuilderIndex(*idx)]; b != nil && bytes.Equal(b.PublicKey[:], depositData.PublicKey) { + depositData.HasBuilderIndex = true + depositData.BuilderIndex = *idx + } else { + depositData.IsInactiveBuilder = true + } + } + + if deposit.Transaction != nil { + depositData.HasTransaction = true + depositData.TransactionHash = deposit.Transaction.TxHash + depositData.TransactionOrphaned = deposit.TransactionOrphaned + depositData.TransactionDetails = &models.BuilderPageDataDepositTxDetails{ + BlockNumber: deposit.Transaction.BlockNumber, + BlockHash: fmt.Sprintf("%#x", deposit.Transaction.BlockRoot), + BlockTime: deposit.Transaction.BlockTime, + TxOrigin: common.Address(deposit.Transaction.TxSender).Hex(), + TxTarget: common.Address(deposit.Transaction.TxTarget).Hex(), + TxHash: fmt.Sprintf("%#x", deposit.Transaction.TxHash), + } + } + + pageData.Deposits = append(pageData.Deposits, depositData) + } + pageData.DepositCount = uint64(len(pageData.Deposits)) + + if pageData.DepositCount > 0 { + pageData.FirstIndex = pageData.Deposits[0].SlotNumber + pageData.LastIndex = pageData.Deposits[pageData.DepositCount-1].SlotNumber + } + + pageData.TotalPages = totalRows / pageSize + if totalRows%pageSize > 0 { + pageData.TotalPages++ + } + pageData.LastPageIndex = pageData.TotalPages + if pageIdx < pageData.TotalPages { + pageData.NextPageIndex = pageIdx + 1 + } + + pageData.UrlParams = make([]models.UrlParam, 0) + for key, values := range filterArgs { + if len(values) > 0 { + pageData.UrlParams = append(pageData.UrlParams, models.UrlParam{Key: key, Value: values[0]}) + } + } + pageData.UrlParams = append(pageData.UrlParams, models.UrlParam{Key: "c", Value: fmt.Sprintf("%v", pageData.PageSize)}) + + pageData.FirstPageLink = fmt.Sprintf("/builders/deposits?f&%v&c=%v", filterArgs.Encode(), pageData.PageSize) + pageData.PrevPageLink = fmt.Sprintf("/builders/deposits?f&%v&c=%v&p=%v", filterArgs.Encode(), pageData.PageSize, pageData.PrevPageIndex) + pageData.NextPageLink = fmt.Sprintf("/builders/deposits?f&%v&c=%v&p=%v", filterArgs.Encode(), pageData.PageSize, pageData.NextPageIndex) + pageData.LastPageLink = fmt.Sprintf("/builders/deposits?f&%v&c=%v&p=%v", filterArgs.Encode(), pageData.PageSize, pageData.LastPageIndex) + + return pageData +} + +// buildBuilderDepositsProjectionPageData builds the pre-Gloas projection view of the builder +// deposits page: the builders projected to be onboarded from the pending deposit queue at the Gloas +// fork transition, plus the "is it safe to deposit right now" indicator. It applies the slot, pubkey +// and amount filters (the builder-index filter has no meaning before any builder exists). +func buildBuilderDepositsProjectionPageData(ctx context.Context, pageIdx uint64, pageSize uint64, minSlot uint64, maxSlot uint64, pubkey string, minIndex uint64, maxIndex uint64, minAmount uint64, maxAmount uint64) *models.BuilderDepositsPageData { + filterArgs := url.Values{} + if minSlot != 0 { + filterArgs.Add("f.mins", fmt.Sprintf("%v", minSlot)) + } + if maxSlot != 0 { + filterArgs.Add("f.maxs", fmt.Sprintf("%v", maxSlot)) + } + if pubkey != "" { + filterArgs.Add("f.pubkey", pubkey) + } + if minIndex != 0 { + filterArgs.Add("f.mini", fmt.Sprintf("%v", minIndex)) + } + if maxIndex != 0 { + filterArgs.Add("f.maxi", fmt.Sprintf("%v", maxIndex)) + } + if minAmount != 0 { + filterArgs.Add("f.mina", fmt.Sprintf("%v", minAmount)) + } + if maxAmount != 0 { + filterArgs.Add("f.maxa", fmt.Sprintf("%v", maxAmount)) + } + + pageData := &models.BuilderDepositsPageData{ + FilterMinSlot: minSlot, + FilterMaxSlot: maxSlot, + FilterPubKey: pubkey, + FilterMinIndex: minIndex, + FilterMaxIndex: maxIndex, + FilterMinAmount: minAmount, + FilterMaxAmount: maxAmount, + IsProjection: true, + } + if pageIdx == 1 { + pageData.IsDefaultPage = true + } + if pageSize > 100 { + pageSize = 100 + } + pageData.PageSize = pageSize + pageData.CurrentPageIndex = pageIdx + if pageIdx > 1 { + pageData.PrevPageIndex = pageIdx - 1 + } + + chainState := services.GlobalBeaconService.GetChainState() + projection := services.GlobalBeaconService.GetBuilderOnboardingProjection(ctx) + if projection != nil { + pageData.ProjectionTruncated = projection.Truncated + pageData.GloasForkEpoch = uint64(projection.GloasForkEpoch) + pageData.GloasForkTime = projection.GloasForkTime + pageData.OnboardedNewCount = projection.OnboardedNewCount + pageData.OnboardedTopUpCount = projection.OnboardedTopUpCount + pageData.TooEarlyCount = projection.TooEarlyCount + pageData.InvalidSignatureCount = projection.InvalidSignatureCount + pageData.KeptAsValidatorCount = projection.KeptAsValidatorCount + pageData.TotalQueueProcessedBeforeFork = projection.TotalQueueProcessedBeforeFork + pageData.HasSafetyEstimate = projection.HasSafetyEstimate + pageData.DepositSafe = projection.DepositSafe + pageData.NewDepositEstimateEpoch = uint64(projection.NewDepositEstimateEpoch) + pageData.NewDepositEstimateTime = projection.NewDepositEstimateTime + } + + // Map and filter the projected deposits (slot / pubkey / amount; builder-index filter ignored). + pubkeyFilter := common.FromHex(pubkey) + matched := make([]*models.BuilderDepositsPageDataDeposit, 0) + if projection != nil { + for _, pd := range projection.Deposits { + dep := pd.Deposit + slot := dep.SlotNumber + amount := dep.Amount + + if minSlot != 0 && slot < minSlot { + continue + } + if maxSlot != 0 && slot > maxSlot { + continue + } + if len(pubkeyFilter) > 0 && !bytes.Equal(pubkeyFilter, dep.PublicKey) { + continue + } + if minAmount != 0 && amount < minAmount { + continue + } + if maxAmount != 0 && amount > maxAmount { + continue + } + + depositData := &models.BuilderDepositsPageDataDeposit{ + IsIncluded: true, + IsProjected: true, + SlotNumber: slot, + SlotRoot: dep.SlotRoot, + Orphaned: dep.Orphaned, + Time: chainState.SlotToTime(phase0.Slot(slot)), + PublicKey: dep.PublicKey, + WithdrawalCredentials: dep.WithdrawalCredentials, + Amount: amount, + Result: pd.Result, + IsQueued: pd.IsQueued, + QueuePosition: pd.QueuePos, + ProjectedOnboarded: pd.Onboarded(), + ProjectedTooEarly: pd.TooEarly, + ProjectedAlreadyProcessed: pd.AlreadyProcessed, + ProjectedKeptAsValidator: pd.KeptAsValidator, + ProjectedInvalidSignature: pd.InvalidSignature, + } + if dep.Index != nil { + depositData.HasDepositIndex = true + depositData.DepositIndex = *dep.Index + } + switch { + case pd.AlreadyProcessed: + depositData.EstimatedTime = chainState.SlotToTime(phase0.Slot(slot)) + case pd.EstimateEpoch > 0: + depositData.EstimatedTime = chainState.EpochToTime(pd.EstimateEpoch) + default: + depositData.EstimatedTime = projection.GloasForkTime + } + + if dep.BlockNumber != nil { + depositData.HasTransaction = true + depositData.TransactionHash = dep.TxHash + depositData.BlockNumber = *dep.BlockNumber + blockTime := uint64(0) + if dep.BlockTime != nil { + blockTime = *dep.BlockTime + } + depositData.TransactionDetails = &models.BuilderPageDataDepositTxDetails{ + BlockNumber: *dep.BlockNumber, + BlockHash: fmt.Sprintf("%#x", dep.BlockRoot), + BlockTime: blockTime, + TxOrigin: common.Address(dep.TxSender).Hex(), + TxTarget: common.Address(dep.TxTarget).Hex(), + TxHash: fmt.Sprintf("%#x", dep.TxHash), + } + } + + matched = append(matched, depositData) + } + } + + totalRows := uint64(len(matched)) + start := (pageIdx - 1) * pageSize + end := start + pageSize + if start > totalRows { + start = totalRows + } + if end > totalRows { + end = totalRows + } + pageData.Deposits = matched[start:end] + pageData.DepositCount = uint64(len(pageData.Deposits)) + + if pageData.DepositCount > 0 { + pageData.FirstIndex = pageData.Deposits[0].SlotNumber + pageData.LastIndex = pageData.Deposits[pageData.DepositCount-1].SlotNumber + } + + pageData.TotalPages = totalRows / pageSize + if totalRows%pageSize > 0 { + pageData.TotalPages++ + } + pageData.LastPageIndex = pageData.TotalPages + if pageIdx < pageData.TotalPages { + pageData.NextPageIndex = pageIdx + 1 + } + + pageData.UrlParams = make([]models.UrlParam, 0) + for key, values := range filterArgs { + if len(values) > 0 { + pageData.UrlParams = append(pageData.UrlParams, models.UrlParam{Key: key, Value: values[0]}) + } + } + pageData.UrlParams = append(pageData.UrlParams, models.UrlParam{Key: "c", Value: fmt.Sprintf("%v", pageData.PageSize)}) + + pageData.FirstPageLink = fmt.Sprintf("/builders/deposits?f&%v&c=%v", filterArgs.Encode(), pageData.PageSize) + pageData.PrevPageLink = fmt.Sprintf("/builders/deposits?f&%v&c=%v&p=%v", filterArgs.Encode(), pageData.PageSize, pageData.PrevPageIndex) + pageData.NextPageLink = fmt.Sprintf("/builders/deposits?f&%v&c=%v&p=%v", filterArgs.Encode(), pageData.PageSize, pageData.NextPageIndex) + pageData.LastPageLink = fmt.Sprintf("/builders/deposits?f&%v&c=%v&p=%v", filterArgs.Encode(), pageData.PageSize, pageData.LastPageIndex) + + return pageData +} diff --git a/handlers/builder_exits.go b/handlers/builder_exits.go new file mode 100644 index 000000000..4cc777e0f --- /dev/null +++ b/handlers/builder_exits.go @@ -0,0 +1,254 @@ +package handlers + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethpandaops/dora/dbtypes" + "github.com/ethpandaops/dora/services" + "github.com/ethpandaops/dora/templates" + "github.com/ethpandaops/dora/types/models" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/sirupsen/logrus" +) + +// BuilderExits will return the filtered "builder_exits" page using a go template +func BuilderExits(w http.ResponseWriter, r *http.Request) { + var templateFiles = append(layoutTemplateFiles, + "builder_exits/builder_exits.html", + "_shared/txDetailsModal.html", + "_svg/professor.html", + ) + + var pageTemplate = templates.GetTemplate(templateFiles...) + data := InitPageData(w, r, "builders", "/builders/exits", "Builder Exits", templateFiles) + + urlArgs := r.URL.Query() + var pageSize uint64 = 50 + if urlArgs.Has("c") { + pageSize, _ = strconv.ParseUint(urlArgs.Get("c"), 10, 64) + } + var pageIdx uint64 = 1 + if urlArgs.Has("p") { + pageIdx, _ = strconv.ParseUint(urlArgs.Get("p"), 10, 64) + if pageIdx < 1 { + pageIdx = 1 + } + } + + var minSlot, maxSlot, minIndex, maxIndex uint64 + var pubkey, sourceAddr string + + if urlArgs.Has("f") { + if urlArgs.Has("f.mins") { + minSlot, _ = strconv.ParseUint(urlArgs.Get("f.mins"), 10, 64) + } + if urlArgs.Has("f.maxs") { + maxSlot, _ = strconv.ParseUint(urlArgs.Get("f.maxs"), 10, 64) + } + if urlArgs.Has("f.pubkey") { + pubkey = urlArgs.Get("f.pubkey") + } + if urlArgs.Has("f.source") { + sourceAddr = urlArgs.Get("f.source") + } + if urlArgs.Has("f.mini") { + minIndex, _ = strconv.ParseUint(urlArgs.Get("f.mini"), 10, 64) + } + if urlArgs.Has("f.maxi") { + maxIndex, _ = strconv.ParseUint(urlArgs.Get("f.maxi"), 10, 64) + } + } + + var pageError error + pageError = services.GlobalCallRateLimiter.CheckCallLimit(r, 2) + if pageError == nil { + data.Data, pageError = getBuilderExitsPageData(pageIdx, pageSize, minSlot, maxSlot, pubkey, sourceAddr, minIndex, maxIndex) + } + if pageError != nil { + handlePageError(w, r, pageError) + return + } + w.Header().Set("Content-Type", "text/html") + if handleTemplateError(w, r, "builder_exits.go", "BuilderExits", "", pageTemplate.ExecuteTemplate(w, "layout", data)) != nil { + return + } +} + +func getBuilderExitsPageData(pageIdx uint64, pageSize uint64, minSlot uint64, maxSlot uint64, pubkey string, sourceAddr string, minIndex uint64, maxIndex uint64) (*models.BuilderExitsPageData, error) { + pageData := &models.BuilderExitsPageData{} + pageCacheKey := fmt.Sprintf("builder_exits:%v:%v:%v:%v:%v:%v:%v:%v", pageIdx, pageSize, minSlot, maxSlot, pubkey, sourceAddr, minIndex, maxIndex) + pageRes, pageErr := services.GlobalFrontendCache.ProcessCachedPage(pageCacheKey, true, pageData, func(pageCall *services.FrontendCacheProcessingPage) interface{} { + return buildBuilderExitsPageData(pageCall.CallCtx, pageIdx, pageSize, minSlot, maxSlot, pubkey, sourceAddr, minIndex, maxIndex) + }) + if pageErr == nil && pageRes != nil { + resData, resOk := pageRes.(*models.BuilderExitsPageData) + if !resOk { + return nil, ErrInvalidPageModel + } + pageData = resData + } + return pageData, pageErr +} + +func buildBuilderExitsPageData(ctx context.Context, pageIdx uint64, pageSize uint64, minSlot uint64, maxSlot uint64, pubkey string, sourceAddr string, minIndex uint64, maxIndex uint64) *models.BuilderExitsPageData { + filterArgs := url.Values{} + if minSlot != 0 { + filterArgs.Add("f.mins", fmt.Sprintf("%v", minSlot)) + } + if maxSlot != 0 { + filterArgs.Add("f.maxs", fmt.Sprintf("%v", maxSlot)) + } + if pubkey != "" { + filterArgs.Add("f.pubkey", pubkey) + } + if sourceAddr != "" { + filterArgs.Add("f.source", sourceAddr) + } + if minIndex != 0 { + filterArgs.Add("f.mini", fmt.Sprintf("%v", minIndex)) + } + if maxIndex != 0 { + filterArgs.Add("f.maxi", fmt.Sprintf("%v", maxIndex)) + } + + pageData := &models.BuilderExitsPageData{ + FilterMinSlot: minSlot, + FilterMaxSlot: maxSlot, + FilterPubKey: pubkey, + FilterSourceAddr: sourceAddr, + FilterMinIndex: minIndex, + FilterMaxIndex: maxIndex, + } + logrus.Debugf("builder_exits page called: %v:%v [%v,%v,%v]", pageIdx, pageSize, minSlot, maxSlot, pubkey) + if pageIdx == 1 { + pageData.IsDefaultPage = true + } + if pageSize > 100 { + pageSize = 100 + } + pageData.PageSize = pageSize + pageData.CurrentPageIndex = pageIdx + if pageIdx > 1 { + pageData.PrevPageIndex = pageIdx - 1 + } + + filter := &dbtypes.BuilderExitFilter{ + MinSlot: minSlot, + MaxSlot: maxSlot, + PublicKey: common.FromHex(pubkey), + SourceAddress: common.FromHex(sourceAddr), + MinIndex: minIndex, + MaxIndex: maxIndex, + WithOrphaned: 1, + } + + offset := (pageIdx - 1) * pageSize + combined, totalTxRows, totalReqRows := services.GlobalBeaconService.GetBuilderExitsByFilter(ctx, filter, offset, uint32(pageSize)) + totalRows := totalTxRows + totalReqRows + + chainState := services.GlobalBeaconService.GetChainState() + + // builderIdxOf returns the builder index recorded for an exit (CL request preferred, else the + // pending EL tx), if any. + builderIdxOf := func(exit *services.CombinedBuilderExit) *uint64 { + if exit.Request != nil && exit.Request.BuilderIndex != nil { + return exit.Request.BuilderIndex + } + if exit.Transaction != nil && exit.Transaction.BuilderIndex != nil { + return exit.Transaction.BuilderIndex + } + return nil + } + + // collect the builder indexes to resolve so we can batch-load the builders and tell whether + // each pubkey still owns its (reusable) index or was superseded. + indexes := make([]gloas.BuilderIndex, 0, len(combined)) + for _, exit := range combined { + if idx := builderIdxOf(exit); idx != nil { + indexes = append(indexes, gloas.BuilderIndex(*idx)) + } + } + builders := services.GlobalBeaconService.GetActiveBuildersByIndexes(ctx, indexes) + + for _, exit := range combined { + exitData := &models.BuilderExitsPageDataExit{} + + if exit.Request != nil { + exitData.IsIncluded = true + exitData.SlotNumber = exit.Request.SlotNumber + exitData.SlotRoot = exit.Request.SlotRoot + exitData.Time = chainState.SlotToTime(phase0.Slot(exit.Request.SlotNumber)) + exitData.Orphaned = exit.RequestOrphaned + exitData.SourceAddress = exit.Request.SourceAddress + exitData.PublicKey = exit.Request.PublicKey + exitData.Result = exit.Request.Result + exitData.BlockNumber = exit.Request.BlockNumber + } else if exit.Transaction != nil { + exitData.SourceAddress = exit.Transaction.SourceAddress + exitData.PublicKey = exit.Transaction.PublicKey + exitData.BlockNumber = exit.Transaction.BlockNumber + } + + if idx := builderIdxOf(exit); idx != nil { + if b := builders[gloas.BuilderIndex(*idx)]; b != nil && bytes.Equal(b.PublicKey[:], exitData.PublicKey) { + exitData.HasBuilderIndex = true + exitData.BuilderIndex = *idx + } else { + exitData.IsInactiveBuilder = true + } + } + + if exit.Transaction != nil { + exitData.HasTransaction = true + exitData.TransactionHash = exit.Transaction.TxHash + exitData.TransactionOrphaned = exit.TransactionOrphaned + exitData.TransactionDetails = &models.BuilderPageDataExitTxDetails{ + BlockNumber: exit.Transaction.BlockNumber, + BlockHash: fmt.Sprintf("%#x", exit.Transaction.BlockRoot), + BlockTime: exit.Transaction.BlockTime, + TxOrigin: common.Address(exit.Transaction.TxSender).Hex(), + TxTarget: common.Address(exit.Transaction.TxTarget).Hex(), + TxHash: fmt.Sprintf("%#x", exit.Transaction.TxHash), + } + } + + pageData.Exits = append(pageData.Exits, exitData) + } + pageData.ExitCount = uint64(len(pageData.Exits)) + + if pageData.ExitCount > 0 { + pageData.FirstIndex = pageData.Exits[0].SlotNumber + pageData.LastIndex = pageData.Exits[pageData.ExitCount-1].SlotNumber + } + + pageData.TotalPages = totalRows / pageSize + if totalRows%pageSize > 0 { + pageData.TotalPages++ + } + pageData.LastPageIndex = pageData.TotalPages + if pageIdx < pageData.TotalPages { + pageData.NextPageIndex = pageIdx + 1 + } + + pageData.UrlParams = make([]models.UrlParam, 0) + for key, values := range filterArgs { + if len(values) > 0 { + pageData.UrlParams = append(pageData.UrlParams, models.UrlParam{Key: key, Value: values[0]}) + } + } + pageData.UrlParams = append(pageData.UrlParams, models.UrlParam{Key: "c", Value: fmt.Sprintf("%v", pageData.PageSize)}) + + pageData.FirstPageLink = fmt.Sprintf("/builders/exits?f&%v&c=%v", filterArgs.Encode(), pageData.PageSize) + pageData.PrevPageLink = fmt.Sprintf("/builders/exits?f&%v&c=%v&p=%v", filterArgs.Encode(), pageData.PageSize, pageData.PrevPageIndex) + pageData.NextPageLink = fmt.Sprintf("/builders/exits?f&%v&c=%v&p=%v", filterArgs.Encode(), pageData.PageSize, pageData.NextPageIndex) + pageData.LastPageLink = fmt.Sprintf("/builders/exits?f&%v&c=%v&p=%v", filterArgs.Encode(), pageData.PageSize, pageData.LastPageIndex) + + return pageData +} diff --git a/handlers/pageData.go b/handlers/pageData.go index 61df23800..99141c95a 100644 --- a/handlers/pageData.go +++ b/handlers/pageData.go @@ -3,6 +3,7 @@ package handlers import ( "errors" "fmt" + "math" "net/http" "strings" "syscall" @@ -212,19 +213,6 @@ func createMenuItems(active string) []types.MainMenuItem { Links: validatorMenuLinks, }) - if specs != nil && specs.GloasForkEpoch != nil && uint64(chainState.CurrentEpoch()) >= *specs.GloasForkEpoch { - builderMenu := []types.NavigationLink{ - { - Label: "Builders", - Path: "/builders", - Icon: "fa-building", - }, - } - validatorMenu = append(validatorMenu, types.NavigationGroup{ - Links: builderMenu, - }) - } - validatorActionsGroup := types.NavigationGroup{ Links: []types.NavigationLink{ { @@ -295,7 +283,7 @@ func createMenuItems(active string) []types.MainMenuItem { }) } - return []types.MainMenuItem{ + mainMenu := []types.MainMenuItem{ { Label: "Blockchain", IsActive: active == "blockchain", @@ -306,12 +294,69 @@ func createMenuItems(active string) []types.MainMenuItem { IsActive: active == "validators", Groups: validatorMenu, }, - { - Label: "Clients", - IsActive: active == "clients", - Groups: clientsMenu, - }, } + + // Builders menu group (Gloas/EIP-8282): builders are tracked separately from validators. + // Shown as soon as Gloas is scheduled (a finite fork epoch), not only once it is active, so + // the builder deposits page can surface the projected early-onboarded builders ahead of the fork. + if specs != nil && specs.GloasForkEpoch != nil && *specs.GloasForkEpoch < math.MaxUint64 { + buildersMenu := []types.NavigationGroup{ + { + Links: []types.NavigationLink{ + { + Label: "Builders", + Path: "/builders", + Icon: "fa-building", + }, + { + Label: "Deposits", + Path: "/builders/deposits", + Icon: "fa-file-signature", + }, + { + Label: "Exits", + Path: "/builders/exits", + Icon: "fa-door-open", + }, + }, + }, + } + + builderSubmitLinks := []types.NavigationLink{} + if utils.Config.Frontend.ShowSubmitDeposit { + builderSubmitLinks = append(builderSubmitLinks, types.NavigationLink{ + Label: "Submit Deposit", + Path: "/builders/submit_deposit", + Icon: "fa-file-import", + }) + } + if utils.Config.Frontend.ShowSubmitElRequests { + builderSubmitLinks = append(builderSubmitLinks, types.NavigationLink{ + Label: "Submit Exit", + Path: "/builders/submit_exit", + Icon: "fa-door-open", + }) + } + if len(builderSubmitLinks) > 0 { + buildersMenu = append(buildersMenu, types.NavigationGroup{ + Links: builderSubmitLinks, + }) + } + + mainMenu = append(mainMenu, types.MainMenuItem{ + Label: "Builders", + IsActive: active == "builders", + Groups: buildersMenu, + }) + } + + mainMenu = append(mainMenu, types.MainMenuItem{ + Label: "Clients", + IsActive: active == "clients", + Groups: clientsMenu, + }) + + return mainMenu } // used to handle errors constructed by Template.ExecuteTemplate correctly diff --git a/handlers/search.go b/handlers/search.go index f941084d1..868c260d6 100644 --- a/handlers/search.go +++ b/handlers/search.go @@ -63,16 +63,16 @@ func Search(w http.ResponseWriter, r *http.Request) { func getSearchResolverResult(pageCacheKey, searchQuery string) (searchResolverResult, error) { pageData := searchResolverResult{} - pageRes, pageErr := services.GlobalFrontendCache.ProcessCachedPage(pageCacheKey, true, pageData, func(pageCall *services.FrontendCacheProcessingPage) interface{} { + pageRes, pageErr := services.GlobalFrontendCache.ProcessCachedPage(pageCacheKey, true, &pageData, func(pageCall *services.FrontendCacheProcessingPage) interface{} { res, cacheTimeout := buildSearchResolverResult(pageCall.CallCtx, searchQuery) pageCall.CacheTimeout = cacheTimeout - return res + return &res }) if pageErr != nil { return searchResolverResult{}, pageErr } - if res, ok := pageRes.(searchResolverResult); ok { - return res, nil + if res, ok := pageRes.(*searchResolverResult); ok { + return *res, nil } return searchResolverResult{}, ErrInvalidPageModel } diff --git a/handlers/slot.go b/handlers/slot.go index d514b9826..13303ef57 100644 --- a/handlers/slot.go +++ b/handlers/slot.go @@ -1,6 +1,7 @@ package handlers import ( + "bytes" "context" "encoding/hex" "encoding/json" @@ -21,6 +22,7 @@ import ( "github.com/ethpandaops/go-eth2-client/spec/bellatrix" "github.com/ethpandaops/go-eth2-client/spec/capella" "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/gloas" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/gorilla/mux" "github.com/sirupsen/logrus" @@ -52,6 +54,8 @@ func Slot(w http.ResponseWriter, r *http.Request) { "slot/deposit_requests.html", "slot/withdrawal_requests.html", "slot/consolidation_requests.html", + "slot/builder_deposit_requests.html", + "slot/builder_exit_requests.html", "slot/bids.html", "slot/ptc_votes.html", "slot/inclusion_lists.html", @@ -959,7 +963,7 @@ func getSlotPageBlockData(ctx context.Context, blockData *services.CombinedBlock } if specs.ElectraForkEpoch != nil && uint64(epoch) >= *specs.ElectraForkEpoch { - var requests *electra.ExecutionRequests + var requests *all.ExecutionRequests if blockData.Block.Version >= spec.DataVersionGloas { // In Gloas the execution requests carried by a payload are processed in the next // block (as parent_execution_requests), so they are displayed there — consistent @@ -978,6 +982,8 @@ func getSlotPageBlockData(ctx context.Context, blockData *services.CombinedBlock getSlotPageDepositRequests(pageData, requests.Deposits) getSlotPageWithdrawalRequests(pageData, requests.Withdrawals) getSlotPageConsolidationRequests(pageData, requests.Consolidations) + getSlotPageBuilderDeposits(ctx, pageData, requests.BuilderDeposits) + getSlotPageBuilderExits(ctx, pageData, requests.BuilderExits) } } @@ -1317,6 +1323,82 @@ func getSlotPageConsolidationRequests(pageData *models.SlotPageBlockData, consol pageData.ConsolidationRequestsCount = uint64(len(pageData.ConsolidationRequests)) } +func getSlotPageBuilderDeposits(ctx context.Context, pageData *models.SlotPageBlockData, builderDeposits []*gloas.BuilderDepositRequest) { + pageData.BuilderDepositRequests = make([]*models.SlotPageBuilderDepositRequest, 0, len(builderDeposits)) + + // resolve pubkeys -> builder indexes first, then batch-load the builders for those indexes to + // tell whether each pubkey still owns its (reusable) index or was superseded. + resolvedIdx := make([]gloas.BuilderIndex, len(builderDeposits)) + resolvedOk := make([]bool, len(builderDeposits)) + indexes := make([]gloas.BuilderIndex, 0, len(builderDeposits)) + for i, builderDeposit := range builderDeposits { + if builderIdx, found := services.GlobalBeaconService.GetBuilderIndexByPubkey(builderDeposit.Pubkey); found { + resolvedIdx[i] = builderIdx + resolvedOk[i] = true + indexes = append(indexes, builderIdx) + } + } + builders := services.GlobalBeaconService.GetActiveBuildersByIndexes(ctx, indexes) + + for i, builderDeposit := range builderDeposits { + requestData := &models.SlotPageBuilderDepositRequest{ + PublicKey: builderDeposit.Pubkey[:], + WithdrawalCreds: builderDeposit.WithdrawalCredentials, + Amount: uint64(builderDeposit.Amount), + Signature: builderDeposit.Signature[:], + } + + if resolvedOk[i] { + if b := builders[resolvedIdx[i]]; b != nil && bytes.Equal(b.PublicKey[:], builderDeposit.Pubkey[:]) { + requestData.HasBuilderIndex = true + requestData.BuilderIndex = uint64(resolvedIdx[i]) + } else { + requestData.IsInactiveBuilder = true + } + } + + pageData.BuilderDepositRequests = append(pageData.BuilderDepositRequests, requestData) + } + + pageData.BuilderDepositRequestsCount = uint64(len(pageData.BuilderDepositRequests)) +} + +func getSlotPageBuilderExits(ctx context.Context, pageData *models.SlotPageBlockData, builderExits []*gloas.BuilderExitRequest) { + pageData.BuilderExitRequests = make([]*models.SlotPageBuilderExitRequest, 0, len(builderExits)) + + resolvedIdx := make([]gloas.BuilderIndex, len(builderExits)) + resolvedOk := make([]bool, len(builderExits)) + indexes := make([]gloas.BuilderIndex, 0, len(builderExits)) + for i, builderExit := range builderExits { + if builderIdx, found := services.GlobalBeaconService.GetBuilderIndexByPubkey(builderExit.Pubkey); found { + resolvedIdx[i] = builderIdx + resolvedOk[i] = true + indexes = append(indexes, builderIdx) + } + } + builders := services.GlobalBeaconService.GetActiveBuildersByIndexes(ctx, indexes) + + for i, builderExit := range builderExits { + requestData := &models.SlotPageBuilderExitRequest{ + SourceAddress: builderExit.SourceAddress[:], + PublicKey: builderExit.Pubkey[:], + } + + if resolvedOk[i] { + if b := builders[resolvedIdx[i]]; b != nil && bytes.Equal(b.PublicKey[:], builderExit.Pubkey[:]) { + requestData.HasBuilderIndex = true + requestData.BuilderIndex = uint64(resolvedIdx[i]) + } else { + requestData.IsInactiveBuilder = true + } + } + + pageData.BuilderExitRequests = append(pageData.BuilderExitRequests, requestData) + } + + pageData.BuilderExitRequestsCount = uint64(len(pageData.BuilderExitRequests)) +} + func getSlotPageExecutionProofs(pageData *models.SlotPageBlockData, blockRoot phase0.Root, slot uint64) { // Get a ready beacon client to fetch execution proofs beaconIndexer := services.GlobalBeaconService.GetBeaconIndexer() diff --git a/handlers/slots.go b/handlers/slots.go index fbd79cdde..f8e1aac1b 100644 --- a/handlers/slots.go +++ b/handlers/slots.go @@ -309,8 +309,10 @@ func buildSlotsPageData(ctx context.Context, firstSlot uint64, pageSize uint64, } } - // Add builder info - if pageData.DisplayBuilder { + // Add builder info (needed for the Builder column and the proposer build-source icon). + // Only blocks that actually exist (proposed or orphaned) carry a build source; + // scheduled/missing slots have no payload yet. + if (pageData.DisplayBuilder || pageData.DisplayProposer) && dbSlot.Status > 0 { if dbSlot.BuilderIndex == -1 { slotData.HasBuilder = true slotData.BuilderIndex = math.MaxUint64 diff --git a/handlers/submit_builder_deposit.go b/handlers/submit_builder_deposit.go new file mode 100644 index 000000000..4b990db3a --- /dev/null +++ b/handlers/submit_builder_deposit.go @@ -0,0 +1,89 @@ +package handlers + +import ( + "errors" + "net/http" + "time" + + "github.com/sirupsen/logrus" + + "github.com/ethpandaops/dora/clients/execution/rpc" + "github.com/ethpandaops/dora/services" + "github.com/ethpandaops/dora/templates" + "github.com/ethpandaops/dora/types/models" + "github.com/ethpandaops/dora/utils" +) + +// SubmitBuilderDeposit renders the submit builder deposit page. +func SubmitBuilderDeposit(w http.ResponseWriter, r *http.Request) { + var templateFiles = append(layoutTemplateFiles, + "submit_builder_deposit/submit_builder_deposit.html", + ) + var pageTemplate = templates.GetTemplate(templateFiles...) + + if !utils.Config.Frontend.ShowSubmitDeposit { + handlePageError(w, r, errors.New("submit deposit is not enabled")) + return + } + + if r.Method != http.MethodGet { + handlePageError(w, r, errors.New("invalid method")) + return + } + + pageData, pageError := getSubmitBuilderDepositPageData() + if pageError != nil { + handlePageError(w, r, pageError) + return + } + + data := InitPageData(w, r, "builders", "/builders/submit_deposit", "Submit Builder Deposit", templateFiles) + data.Data = pageData + w.Header().Set("Content-Type", "text/html") + if handleTemplateError(w, r, "submit_builder_deposit.go", "SubmitBuilderDeposit", "", pageTemplate.ExecuteTemplate(w, "layout", data)) != nil { + return + } +} + +func getSubmitBuilderDepositPageData() (*models.SubmitBuilderDepositPageData, error) { + pageData := &models.SubmitBuilderDepositPageData{} + pageCacheKey := "submit_builder_deposit" + pageRes, pageErr := services.GlobalFrontendCache.ProcessCachedPage(pageCacheKey, true, pageData, func(pageCall *services.FrontendCacheProcessingPage) interface{} { + pageData, cacheTimeout := buildSubmitBuilderDepositPageData() + pageCall.CacheTimeout = cacheTimeout + return pageData + }) + if pageErr == nil && pageRes != nil { + resData, resOk := pageRes.(*models.SubmitBuilderDepositPageData) + if !resOk { + return nil, ErrInvalidPageModel + } + pageData = resData + } + return pageData, pageErr +} + +func buildSubmitBuilderDepositPageData() (*models.SubmitBuilderDepositPageData, time.Duration) { + logrus.Debugf("submit builder deposit page called") + + chainState := services.GlobalBeaconService.GetChainState() + specs := chainState.GetSpecs() + + builderDepositContract := services.GlobalBeaconService.GetSystemContractAddress(rpc.BuilderDepositRequestContract) + + pageData := &models.SubmitBuilderDepositPageData{ + NetworkName: specs.ConfigName, + PublicRPCUrl: utils.GetFrontendRPCUrl(), + RainbowkitProjectId: utils.Config.Frontend.RainbowkitProjectId, + ChainId: specs.DepositChainId, + BuilderDepositContract: builderDepositContract.String(), + GenesisForkVersion: specs.GenesisForkVersion[:], + ExplorerUrl: utils.Config.Frontend.EthExplorerLink, + } + + if utils.Config.Chain.DisplayName != "" { + pageData.NetworkName = utils.Config.Chain.DisplayName + } + + return pageData, 1 * time.Hour +} diff --git a/handlers/submit_builder_exit.go b/handlers/submit_builder_exit.go new file mode 100644 index 000000000..1a88ced93 --- /dev/null +++ b/handlers/submit_builder_exit.go @@ -0,0 +1,154 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/sirupsen/logrus" + + "github.com/ethpandaops/dora/clients/execution/rpc" + "github.com/ethpandaops/dora/dbtypes" + "github.com/ethpandaops/dora/indexer/beacon" + "github.com/ethpandaops/dora/services" + "github.com/ethpandaops/dora/templates" + "github.com/ethpandaops/dora/types/models" + "github.com/ethpandaops/dora/utils" +) + +// SubmitBuilderExit renders the submit builder exit page. +func SubmitBuilderExit(w http.ResponseWriter, r *http.Request) { + var templateFiles = append(layoutTemplateFiles, + "submit_builder_exit/submit_builder_exit.html", + ) + var pageTemplate = templates.GetTemplate(templateFiles...) + + if !utils.Config.Frontend.ShowSubmitElRequests { + handlePageError(w, r, errors.New("submit el requests is not enabled")) + return + } + + query := r.URL.Query() + if query.Has("ajax") { + err := handleSubmitBuilderExitPageDataAjax(w, r) + if err != nil { + handlePageError(w, r, err) + } + return + } + + pageData, pageError := getSubmitBuilderExitPageData() + if pageError != nil { + handlePageError(w, r, pageError) + return + } + + data := InitPageData(w, r, "builders", "/builders/submit_exit", "Submit Builder Exit", templateFiles) + data.Data = pageData + w.Header().Set("Content-Type", "text/html") + if handleTemplateError(w, r, "submit_builder_exit.go", "SubmitBuilderExit", "", pageTemplate.ExecuteTemplate(w, "layout", data)) != nil { + return + } +} + +func getSubmitBuilderExitPageData() (*models.SubmitBuilderExitPageData, error) { + pageData := &models.SubmitBuilderExitPageData{} + pageCacheKey := "submit_builder_exit" + pageRes, pageErr := services.GlobalFrontendCache.ProcessCachedPage(pageCacheKey, true, pageData, func(pageCall *services.FrontendCacheProcessingPage) interface{} { + pageData, cacheTimeout := buildSubmitBuilderExitPageData() + pageCall.CacheTimeout = cacheTimeout + return pageData + }) + if pageErr == nil && pageRes != nil { + resData, resOk := pageRes.(*models.SubmitBuilderExitPageData) + if !resOk { + return nil, ErrInvalidPageModel + } + pageData = resData + } + return pageData, pageErr +} + +func buildSubmitBuilderExitPageData() (*models.SubmitBuilderExitPageData, time.Duration) { + logrus.Debugf("submit builder exit page called") + + chainState := services.GlobalBeaconService.GetChainState() + specs := chainState.GetSpecs() + + builderExitContract := services.GlobalBeaconService.GetSystemContractAddress(rpc.BuilderExitRequestContract) + + pageData := &models.SubmitBuilderExitPageData{ + NetworkName: specs.ConfigName, + PublicRPCUrl: utils.GetFrontendRPCUrl(), + RainbowkitProjectId: utils.Config.Frontend.RainbowkitProjectId, + ChainId: specs.DepositChainId, + BuilderExitContract: builderExitContract.String(), + ExplorerUrl: utils.Config.Frontend.EthExplorerLink, + } + + if utils.Config.Chain.DisplayName != "" { + pageData.NetworkName = utils.Config.Chain.DisplayName + } + + return pageData, 1 * time.Hour +} + +func handleSubmitBuilderExitPageDataAjax(w http.ResponseWriter, r *http.Request) error { + query := r.URL.Query() + var pageData interface{} + + switch query.Get("ajax") { + case "load_builders": + address := query.Get("address") + pageCacheKey := fmt.Sprintf("submit_builder_exit:load_builders:%s", address) + var cached []models.SubmitBuilderExitPageDataBuilder + pageRes, pageErr := services.GlobalFrontendCache.ProcessCachedPage(pageCacheKey, true, &cached, func(pageCall *services.FrontendCacheProcessingPage) interface{} { + result := buildSubmitBuilderExitLoadBuilders(pageCall.CallCtx, address) + pageCall.CacheTimeout = 1 * time.Minute + return result + }) + if pageErr != nil { + return pageErr + } + pageData = pageRes + default: + return errors.New("invalid ajax request") + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(pageData); err != nil { + logrus.WithError(err).Error("error encoding submit builder exit data") + http.Error(w, "Internal server error", http.StatusServiceUnavailable) + } + return nil +} + +func buildSubmitBuilderExitLoadBuilders(ctx context.Context, address string) []models.SubmitBuilderExitPageDataBuilder { + addressBytes := common.HexToAddress(address) + + builders, _ := services.GlobalBeaconService.GetFilteredBuilderSet(ctx, &dbtypes.BuilderFilter{ + ExecutionAddress: addressBytes[:], + }, false) + + result := make([]models.SubmitBuilderExitPageDataBuilder, 0, len(builders)) + for _, b := range builders { + if b.Superseded || b.Builder == nil { + continue + } + // Only active builders (not yet exiting) can be exited. + if b.Builder.WithdrawableEpoch != beacon.FarFutureEpoch { + continue + } + result = append(result, models.SubmitBuilderExitPageDataBuilder{ + Index: uint64(b.Index), + Pubkey: fmt.Sprintf("0x%x", b.Builder.PublicKey[:]), + ExecutionAddress: fmt.Sprintf("0x%x", b.Builder.ExecutionAddress[:]), + Status: "active", + }) + } + return result +} diff --git a/handlers/voluntary_exits.go b/handlers/voluntary_exits.go index 067249140..9d5b18c76 100644 --- a/handlers/voluntary_exits.go +++ b/handlers/voluntary_exits.go @@ -13,7 +13,6 @@ import ( "github.com/ethpandaops/dora/templates" "github.com/ethpandaops/dora/types/models" v1 "github.com/ethpandaops/go-eth2-client/api/v1" - "github.com/ethpandaops/go-eth2-client/spec/gloas" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/sirupsen/logrus" ) @@ -41,7 +40,6 @@ func VoluntaryExits(w http.ResponseWriter, r *http.Request) { } } - var entity string var minSlot uint64 var maxSlot uint64 var minIndex uint64 @@ -50,9 +48,6 @@ func VoluntaryExits(w http.ResponseWriter, r *http.Request) { var withOrphaned uint64 if urlArgs.Has("f") { - if urlArgs.Has("f.entity") { - entity = urlArgs.Get("f.entity") - } if urlArgs.Has("f.mins") { minSlot, _ = strconv.ParseUint(urlArgs.Get("f.mins"), 10, 64) } @@ -75,20 +70,10 @@ func VoluntaryExits(w http.ResponseWriter, r *http.Request) { withOrphaned = 1 } - // Apply builder flag to index filters when entity=builder - if entity == "builder" { - if minIndex > 0 { - minIndex |= services.BuilderIndexFlag - } - if maxIndex > 0 { - maxIndex |= services.BuilderIndexFlag - } - } - var pageError error pageError = services.GlobalCallRateLimiter.CheckCallLimit(r, 2) if pageError == nil { - data.Data, pageError = getFilteredVoluntaryExitsPageData(pageIdx, pageSize, entity, minSlot, maxSlot, minIndex, maxIndex, vname, uint8(withOrphaned)) + data.Data, pageError = getFilteredVoluntaryExitsPageData(pageIdx, pageSize, minSlot, maxSlot, minIndex, maxIndex, vname, uint8(withOrphaned)) } if pageError != nil { handlePageError(w, r, pageError) @@ -100,11 +85,11 @@ func VoluntaryExits(w http.ResponseWriter, r *http.Request) { } } -func getFilteredVoluntaryExitsPageData(pageIdx uint64, pageSize uint64, entity string, minSlot uint64, maxSlot uint64, minIndex uint64, maxIndex uint64, vname string, withOrphaned uint8) (*models.VoluntaryExitsPageData, error) { +func getFilteredVoluntaryExitsPageData(pageIdx uint64, pageSize uint64, minSlot uint64, maxSlot uint64, minIndex uint64, maxIndex uint64, vname string, withOrphaned uint8) (*models.VoluntaryExitsPageData, error) { pageData := &models.VoluntaryExitsPageData{} - pageCacheKey := fmt.Sprintf("voluntary_exits:%v:%v:%v:%v:%v:%v:%v:%v:%v", pageIdx, pageSize, entity, minSlot, maxSlot, minIndex, maxIndex, vname, withOrphaned) + pageCacheKey := fmt.Sprintf("voluntary_exits:%v:%v:%v:%v:%v:%v:%v:%v", pageIdx, pageSize, minSlot, maxSlot, minIndex, maxIndex, vname, withOrphaned) pageRes, pageErr := services.GlobalFrontendCache.ProcessCachedPage(pageCacheKey, true, pageData, func(pageCall *services.FrontendCacheProcessingPage) interface{} { - return buildFilteredVoluntaryExitsPageData(pageCall.CallCtx, pageIdx, pageSize, entity, minSlot, maxSlot, minIndex, maxIndex, vname, withOrphaned) + return buildFilteredVoluntaryExitsPageData(pageCall.CallCtx, pageIdx, pageSize, minSlot, maxSlot, minIndex, maxIndex, vname, withOrphaned) }) if pageErr == nil && pageRes != nil { resData, resOk := pageRes.(*models.VoluntaryExitsPageData) @@ -116,15 +101,8 @@ func getFilteredVoluntaryExitsPageData(pageIdx uint64, pageSize uint64, entity s return pageData, pageErr } -func buildFilteredVoluntaryExitsPageData(ctx context.Context, pageIdx uint64, pageSize uint64, entity string, minSlot uint64, maxSlot uint64, minIndex uint64, maxIndex uint64, vname string, withOrphaned uint8) *models.VoluntaryExitsPageData { - if entity == "" { - entity = "all" - } - +func buildFilteredVoluntaryExitsPageData(ctx context.Context, pageIdx uint64, pageSize uint64, minSlot uint64, maxSlot uint64, minIndex uint64, maxIndex uint64, vname string, withOrphaned uint8) *models.VoluntaryExitsPageData { filterArgs := url.Values{} - if entity != "all" { - filterArgs.Add("f.entity", entity) - } if minSlot != 0 { filterArgs.Add("f.mins", fmt.Sprintf("%v", minSlot)) } @@ -144,20 +122,11 @@ func buildFilteredVoluntaryExitsPageData(ctx context.Context, pageIdx uint64, pa filterArgs.Add("f.orphaned", fmt.Sprintf("%v", withOrphaned)) } - // Display indices without the builder flag for the filter UI - displayMinIndex := minIndex - displayMaxIndex := maxIndex - if entity == "builder" { - displayMinIndex = minIndex &^ services.BuilderIndexFlag - displayMaxIndex = maxIndex &^ services.BuilderIndexFlag - } - pageData := &models.VoluntaryExitsPageData{ - FilterEntity: entity, FilterMinSlot: minSlot, FilterMaxSlot: maxSlot, - FilterMinIndex: displayMinIndex, - FilterMaxIndex: displayMaxIndex, + FilterMinIndex: minIndex, + FilterMaxIndex: maxIndex, FilterValidatorName: vname, FilterWithOrphaned: withOrphaned, } @@ -199,64 +168,38 @@ func buildFilteredVoluntaryExitsPageData(ctx context.Context, pageIdx uint64, pa ValidatorStatus: "", } - // Check if this is a builder exit (validator index has BuilderIndexFlag set) - if voluntaryExit.ValidatorIndex&services.BuilderIndexFlag != 0 { - builderIndex := voluntaryExit.ValidatorIndex &^ services.BuilderIndexFlag - voluntaryExitData.IsBuilder = true - voluntaryExitData.ValidatorIndex = builderIndex - - // Resolve builder name via validatornames service (with BuilderIndexFlag) - voluntaryExitData.ValidatorName = services.GlobalBeaconService.GetValidatorName(voluntaryExit.ValidatorIndex) - - builder := services.GlobalBeaconService.GetBuilderByIndex(gloas.BuilderIndex(builderIndex)) - if builder == nil { - voluntaryExitData.ValidatorStatus = "Unknown" - } else { - voluntaryExitData.PublicKey = builder.PublicKey[:] + voluntaryExitData.ValidatorIndex = voluntaryExit.ValidatorIndex + voluntaryExitData.ValidatorName = services.GlobalBeaconService.GetValidatorName(voluntaryExit.ValidatorIndex) - // Determine builder status - currentEpoch := chainState.CurrentEpoch() - if builder.WithdrawableEpoch <= currentEpoch { - voluntaryExitData.ValidatorStatus = "Exited" - } else { - voluntaryExitData.ValidatorStatus = "Exiting" - } - } + validator := services.GlobalBeaconService.GetValidatorByIndex(phase0.ValidatorIndex(voluntaryExit.ValidatorIndex), false) + if validator == nil { + voluntaryExitData.ValidatorStatus = "Unknown" } else { - // Regular validator exit - voluntaryExitData.ValidatorIndex = voluntaryExit.ValidatorIndex - voluntaryExitData.ValidatorName = services.GlobalBeaconService.GetValidatorName(voluntaryExit.ValidatorIndex) - - validator := services.GlobalBeaconService.GetValidatorByIndex(phase0.ValidatorIndex(voluntaryExit.ValidatorIndex), false) - if validator == nil { - voluntaryExitData.ValidatorStatus = "Unknown" + voluntaryExitData.PublicKey = validator.Validator.PublicKey[:] + voluntaryExitData.WithdrawalCreds = validator.Validator.WithdrawalCredentials + + if strings.HasPrefix(validator.Status.String(), "pending") { + voluntaryExitData.ValidatorStatus = "Pending" + } else if validator.Status == v1.ValidatorStateActiveOngoing { + voluntaryExitData.ValidatorStatus = "Active" + voluntaryExitData.ShowUpcheck = true + } else if validator.Status == v1.ValidatorStateActiveExiting { + voluntaryExitData.ValidatorStatus = "Exiting" + voluntaryExitData.ShowUpcheck = true + } else if validator.Status == v1.ValidatorStateActiveSlashed { + voluntaryExitData.ValidatorStatus = "Slashed" + voluntaryExitData.ShowUpcheck = true + } else if validator.Status == v1.ValidatorStateExitedUnslashed { + voluntaryExitData.ValidatorStatus = "Exited" + } else if validator.Status == v1.ValidatorStateExitedSlashed { + voluntaryExitData.ValidatorStatus = "Slashed" } else { - voluntaryExitData.PublicKey = validator.Validator.PublicKey[:] - voluntaryExitData.WithdrawalCreds = validator.Validator.WithdrawalCredentials - - if strings.HasPrefix(validator.Status.String(), "pending") { - voluntaryExitData.ValidatorStatus = "Pending" - } else if validator.Status == v1.ValidatorStateActiveOngoing { - voluntaryExitData.ValidatorStatus = "Active" - voluntaryExitData.ShowUpcheck = true - } else if validator.Status == v1.ValidatorStateActiveExiting { - voluntaryExitData.ValidatorStatus = "Exiting" - voluntaryExitData.ShowUpcheck = true - } else if validator.Status == v1.ValidatorStateActiveSlashed { - voluntaryExitData.ValidatorStatus = "Slashed" - voluntaryExitData.ShowUpcheck = true - } else if validator.Status == v1.ValidatorStateExitedUnslashed { - voluntaryExitData.ValidatorStatus = "Exited" - } else if validator.Status == v1.ValidatorStateExitedSlashed { - voluntaryExitData.ValidatorStatus = "Slashed" - } else { - voluntaryExitData.ValidatorStatus = validator.Status.String() - } + voluntaryExitData.ValidatorStatus = validator.Status.String() + } - if voluntaryExitData.ShowUpcheck { - voluntaryExitData.UpcheckActivity = uint8(services.GlobalBeaconService.GetValidatorLiveness(validator.Index, 3)) - voluntaryExitData.UpcheckMaximum = uint8(3) - } + if voluntaryExitData.ShowUpcheck { + voluntaryExitData.UpcheckActivity = uint8(services.GlobalBeaconService.GetValidatorLiveness(validator.Index, 3)) + voluntaryExitData.UpcheckMaximum = uint8(3) } } diff --git a/indexer/beacon/block.go b/indexer/beacon/block.go index febf81bed..1f5724a88 100644 --- a/indexer/beacon/block.go +++ b/indexer/beacon/block.go @@ -748,6 +748,24 @@ func (block *Block) GetDbConsolidationRequests(indexer *Indexer, isCanonical boo return indexer.dbWriter.buildDbConsolidationRequests(block, !isCanonical, nil, nil) } +// GetDbBuilderDeposits returns the database representation of the builder deposit requests in this block. +func (block *Block) GetDbBuilderDeposits(indexer *Indexer, isCanonical bool) []*dbtypes.BuilderDeposit { + if block.isDisposed { + return nil + } + + return indexer.dbWriter.buildDbBuilderDeposits(block, !isCanonical, nil) +} + +// GetDbBuilderExits returns the database representation of the builder exit requests in this block. +func (block *Block) GetDbBuilderExits(indexer *Indexer, isCanonical bool) []*dbtypes.BuilderExit { + if block.isDisposed { + return nil + } + + return indexer.dbWriter.buildDbBuilderExits(block, !isCanonical, nil) +} + // GetForkId returns the fork ID of this block. func (block *Block) GetForkId() ForkKey { return block.forkId diff --git a/indexer/beacon/buildercache.go b/indexer/beacon/buildercache.go index 0a6bb62e9..7cca0956d 100644 --- a/indexer/beacon/buildercache.go +++ b/indexer/beacon/buildercache.go @@ -133,7 +133,7 @@ func (cache *builderCache) updateBuilderSet(slot phase0.Slot, dependentRoot phas cachedBuilder = &builderEntry{} cache.builderSetCache[i] = cachedBuilder - cache.indexer.pubkeyCache.Add(builders[i].PublicKey, phase0.ValidatorIndex(uint64(i)|BuilderIndexFlag)) + _ = cache.indexer.builderPubkeyCache.Add(builders[i].PublicKey, phase0.ValidatorIndex(uint64(i))) } else { parentBuilder = cachedBuilder.finalBuilder parentChecksum = cachedBuilder.finalChecksum @@ -557,7 +557,7 @@ func (cache *builderCache) prepopulateFromDB() (uint64, error) { cache.builderSetCache[dbBuilder.BuilderIndex] = builderEntry - cache.indexer.pubkeyCache.Add(builder.PublicKey, phase0.ValidatorIndex(dbBuilder.BuilderIndex|BuilderIndexFlag)) + _ = cache.indexer.builderPubkeyCache.Add(builder.PublicKey, phase0.ValidatorIndex(dbBuilder.BuilderIndex)) restoreCount++ } diff --git a/indexer/beacon/depositsig/depositsig.go b/indexer/beacon/depositsig/depositsig.go index fbd66c815..803813e01 100644 --- a/indexer/beacon/depositsig/depositsig.go +++ b/indexer/beacon/depositsig/depositsig.go @@ -28,6 +28,25 @@ func Domain(genesisForkVersion phase0.Version) zrnt_common.BLSDomain { ) } +// builderDepositDomainType is DOMAIN_BUILDER_DEPOSIT (Gloas/EIP-8282): a dedicated +// domain type (0x0E000000) that prevents builder-deposit signatures from being +// replayed against the regular deposit contract and vice versa. +var builderDepositDomainType = zrnt_common.BLSDomainType{0x0E, 0x00, 0x00, 0x00} + +// BuilderDomain returns the builder-deposit signature domain +// compute_domain(DOMAIN_BUILDER_DEPOSIT, genesisForkVersion, Root{}). +// +// Like regular deposits, builder-deposit proofs-of-possession are signed over a +// fork-agnostic domain (zero genesis validators root), so the domain depends only +// on the genesis fork version — just with the dedicated builder domain type. +func BuilderDomain(genesisForkVersion phase0.Version) zrnt_common.BLSDomain { + return zrnt_common.ComputeDomain( + builderDepositDomainType, + zrnt_common.Version(genesisForkVersion), + zrnt_common.Root{}, + ) +} + // signingRoot computes the signing root of DepositMessage{pubkey, wc, amount}. func signingRoot(pubkey phase0.BLSPubKey, withdrawalCredentials []byte, amount phase0.Gwei, domain zrnt_common.BLSDomain) tree.Root { msg := &zrnt_common.DepositMessage{ diff --git a/indexer/beacon/epochcache.go b/indexer/beacon/epochcache.go index 6ef48ee1e..d8bcf2c6d 100644 --- a/indexer/beacon/epochcache.go +++ b/indexer/beacon/epochcache.go @@ -548,6 +548,7 @@ func (cache *epochCache) loadEpochStats(epochStats *EpochStats) bool { continue } entry.epochState.delayedBuilderPaymentRefs = transitionInfo.DelayedBuilderPayments + entry.epochState.gloasOnboardedDeposits = transitionInfo.GloasOnboardedDeposits // Extract values from the advanced state. if err := entry.epochState.processState(state, cache); err != nil { diff --git a/indexer/beacon/epochstate.go b/indexer/beacon/epochstate.go index 6904e31bb..8fcaeee8d 100644 --- a/indexer/beacon/epochstate.go +++ b/indexer/beacon/epochstate.go @@ -40,7 +40,8 @@ type epochState struct { pendingDeposits []*electra.PendingDeposit pendingPartialWithdrawals []*electra.PendingPartialWithdrawal builderPendingWithdrawals []*gloas.BuilderPendingWithdrawal - delayedBuilderPaymentRefs []uint16 // references to the slots of the delayed builder payments + delayedBuilderPaymentRefs []uint16 // references to the slots of the delayed builder payments + gloasOnboardedDeposits []*electra.PendingDeposit // builder deposits onboarded by the upgrade_to_gloas fork transition pendingConsolidations []*electra.PendingConsolidation proposerLookahead []phase0.ValidatorIndex latestExecutionHash phase0.Hash32 @@ -172,24 +173,28 @@ func (s *epochState) loadState(ctx context.Context, client *Client, cache *epoch return nil, err } resState = loaded - apiLoadDur := time.Since(apiStart) + client.logger.Infof("loaded epoch %v state from beacon API in %v", + s.targetEpoch, time.Since(apiStart).Round(time.Millisecond)) + } + if resState != nil { // For Fulu+: apply epoch transition to advance the state from the post-block state // of the parent epoch's last block to the pre-state of the target epoch. // Skip for genesis (epoch 0) — the genesis state is already the correct pre-state. - var epochTransitionDur time.Duration if resState.Version >= spec.DataVersionFulu && s.targetEpoch > 0 { epochStart := time.Now() + var transitionInfo statetransition.TransitionInfo if err := statetransition.NewStateTransition(specs, client.indexer.dynSsz).PrepareEpochPreState(resState, s.targetEpoch, &transitionInfo); err != nil { return nil, fmt.Errorf("error applying epoch transition for epoch %v: %w", s.targetEpoch, err) } - epochTransitionDur = time.Since(epochStart) + epochTransitionDur := time.Since(epochStart) s.delayedBuilderPaymentRefs = transitionInfo.DelayedBuilderPayments - } + s.gloasOnboardedDeposits = transitionInfo.GloasOnboardedDeposits - client.logger.Infof("loaded epoch %v state from beacon API in %v + epoch transition %v", - s.targetEpoch, apiLoadDur.Round(time.Millisecond), epochTransitionDur.Round(time.Millisecond)) + client.logger.Infof("applied epoch transition for epoch %v in %v", + s.targetEpoch, epochTransitionDur.Round(time.Millisecond)) + } // Store in state cache for future use. if sc != nil { @@ -373,14 +378,12 @@ func (s *epochState) tryReplayFromParentState( return nil } - // Skip replay across fork boundaries. Fork upgrades (e.g. upgrade_to_gloas, which - // onboards builders from the pending_deposits queue) are NOT implemented by the - // state transition, so an epoch transition that crosses a fork would silently - // produce a wrong, un-migrated state — and since the returned pre-state is not - // verified against a target-epoch block, the error would go unnoticed. Detect both - // a dependent-block/parent-state version mismatch and a fork change between the - // parent epoch and the target epoch, and fall back to loading the full state from - // the beacon API for that epoch. + // Skip replay across fork boundaries. The Fulu→Gloas upgrade (upgrade_to_gloas, applied + // inside PrepareEpochPreState) is implemented, but the other fork upgrades are not, and + // crossing a boundary during this unverified parent-state reconstruction is avoided as a + // conservative measure: the source state for a fork epoch is instead obtained via API load + // + PrepareEpochPreState. Detect both a dependent-block/parent-state version mismatch and a + // fork change between the parent epoch and the target epoch, and fall back to the API. chainState := client.indexer.consensusPool.GetChainState() if depBeaconBlock.Version != parentState.Version || chainState.GetForkVersionAtEpoch(parentEpoch) != chainState.GetForkVersionAtEpoch(s.targetEpoch) { @@ -446,32 +449,12 @@ func (s *epochState) tryReplayFromParentState( } blockReplayDur := time.Since(replayStart) - // Apply epoch transition to advance the state from the post-block state of - // the parent epoch's last block to the pre-state of the target epoch. - var epochTransitionDur time.Duration - if parentState.Version >= spec.DataVersionFulu { - epochStart := time.Now() - var transitionInfo statetransition.TransitionInfo - if err := st.PrepareEpochPreState(parentState, s.targetEpoch, &transitionInfo); err != nil { - client.logger.Warnf("replay: epoch transition failed for epoch %v: %v", s.targetEpoch, err) - return nil - } - epochTransitionDur = time.Since(epochStart) - s.delayedBuilderPaymentRefs = transitionInfo.DelayedBuilderPayments - } - client.logger.Infof( - "replayed epoch %v: %d blocks in %v (apply %v) + epoch transition %v", + "replayed epoch %v: %d blocks in %v (apply %v)", parentEpoch, len(epochBlocks), blockReplayDur.Round(time.Millisecond), blockApplyTotal.Round(time.Millisecond), - epochTransitionDur.Round(time.Millisecond), ) - // Cache the post-epoch-transition state for the target epoch. - if err := sc.Store(s.slotRoot, s.targetEpoch, parentState); err != nil { - client.logger.Warnf("failed to cache replayed state for epoch %v: %v", s.targetEpoch, err) - } - return parentState } diff --git a/indexer/beacon/epochstats.go b/indexer/beacon/epochstats.go index 67747f5e1..a2fcf6dbd 100644 --- a/indexer/beacon/epochstats.go +++ b/indexer/beacon/epochstats.go @@ -523,8 +523,28 @@ func (es *EpochStats) processState(indexer *Indexer, validatorSet []*phase0.Vali DutiesSSZ: packedSsz, } + // The upgrade_to_gloas fork transition onboards builders from the pending_deposits queue at + // the Gloas fork epoch. Those deposits came through the validator deposit contract, so copy + // them into the builder deposit tables here (once, at the fork epoch) to surface them on the + // builder deposit / builder detail pages. + var onboardedDeposits []*electra.PendingDeposit + var onboardedForkId ForkKey + if gloasForkEpoch := chainState.GetSpecs().GloasForkEpoch; gloasForkEpoch != nil && + uint64(es.epoch) == *gloasForkEpoch && len(dependentState.gloasOnboardedDeposits) > 0 { + onboardedDeposits = dependentState.gloasOnboardedDeposits + if dependentBlock := indexer.blockCache.getBlockByRoot(es.dependentRoot); dependentBlock != nil { + onboardedForkId = dependentBlock.forkId + } + } + err = db.RunDBTransaction(func(tx *sqlx.Tx) error { - return db.InsertUnfinalizedDuty(indexer.ctx, tx, dbDuty) + if err := db.InsertUnfinalizedDuty(indexer.ctx, tx, dbDuty); err != nil { + return err + } + if len(onboardedDeposits) > 0 { + return indexer.dbWriter.persistGloasOnboardedBuilderDeposits(tx, es.epoch, es.dependentRoot, onboardedForkId, onboardedDeposits) + } + return nil }) if err != nil { indexer.logger.WithError(err).Errorf("failed storing epoch %v stats (%v / %v) to unfinalized duties", es.epoch, es.dependentRoot.String(), dependentState.stateRoot.String()) diff --git a/indexer/beacon/epochstats_ssz.go b/indexer/beacon/epochstats_ssz.go index a6b8f4da0..c87624c9a 100644 --- a/indexer/beacon/epochstats_ssz.go +++ b/indexer/beacon/epochstats_ssz.go @@ -1,6 +1,6 @@ // Code generated by dynamic-ssz. DO NOT EDIT. // Hash: 3d6e795001070b39fe908a83990eb5b8715459d537673f894b46cfe6147d0e06 -// Version: v1.3.1 (https://github.com/pk910/dynamic-ssz) +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) package beacon import ( @@ -14,6 +14,8 @@ import ( var _ = sszutils.ErrListTooBig +var _ = sszutils.Annotate[EpochStatsPacked](`ssz-static:"false"`) + // MarshalSSZ marshals the *EpochStatsPacked to SSZ-encoded bytes. func (t *EpochStatsPacked) MarshalSSZ() ([]byte, error) { return dynssz.GetGlobalDynSsz().MarshalSSZ(t) @@ -243,6 +245,9 @@ func (t *EpochStatsPacked) UnmarshalSSZ(buf []byte) (err error) { if buflen < 8 { return sszutils.ErrorWithPathf(sszutils.ErrFixedFieldsEOFFn(buflen, 8), "ActiveValidators[%d]", idx1) } + if buflen > 8 { + return sszutils.ErrorWithPathf(sszutils.ErrTrailingDataFn(buflen-8), "ActiveValidators[%d]", idx1) + } { // Field #0 'ValidatorIndexOffset' (static) buf := buf[0:4] val2.ValidatorIndexOffset = binary.LittleEndian.Uint32(buf) diff --git a/indexer/beacon/indexer.go b/indexer/beacon/indexer.go index 8298c4400..9be878c64 100644 --- a/indexer/beacon/indexer.go +++ b/indexer/beacon/indexer.go @@ -47,6 +47,7 @@ type Indexer struct { epochCache *epochCache forkCache *forkCache pubkeyCache *pubkeyCache + builderPubkeyCache *pubkeyCache validatorCache *validatorCache pendingValidators *pendingValidatorProjector validatorActivity *validatorActivityCache @@ -123,6 +124,10 @@ func NewIndexer(ctx context.Context, logger logrus.FieldLogger, consensusPool *c indexer.epochCache = newEpochCache(indexer) indexer.forkCache = newForkCache(indexer) indexer.pubkeyCache = newPubkeyCache(indexer, utils.Config.Indexer.PubkeyCachePath) + // builders (EIP-8282) live in a separate index space and may share pubkeys with validators, + // so they get their own pubkey cache. It reuses the validator cache's backing store (same + // file) but namespaces builder keys with a prefix to avoid collisions. + indexer.builderPubkeyCache = indexer.pubkeyCache.newPrefixedCache([]byte(pubkeyCacheBuilderPrefix)) indexer.validatorCache = newValidatorCache(indexer) indexer.pendingValidators = newPendingValidatorProjector(indexer) indexer.validatorActivity = newValidatorActivityCache(indexer) @@ -486,6 +491,7 @@ func (indexer *Indexer) StopIndexer() { } indexer.pubkeyCache.Close() + // builderPubkeyCache shares pubkeyCache's store, so it is closed by the line above. indexer.stateCache.Close() } diff --git a/indexer/beacon/indexer_getter.go b/indexer/beacon/indexer_getter.go index 8732a812a..d9cbec162 100644 --- a/indexer/beacon/indexer_getter.go +++ b/indexer/beacon/indexer_getter.go @@ -376,10 +376,19 @@ func (indexer *Indexer) GetActivationExitQueueLengths(epoch phase0.Epoch, overri } // GetValidatorIndexByPubkey returns the validator index for a given pubkey. +// Builders live in a separate index space (see GetBuilderIndexByPubkey) and are +// never returned here, even if they share a pubkey with a validator. func (indexer *Indexer) GetValidatorIndexByPubkey(pubkey phase0.BLSPubKey) (phase0.ValidatorIndex, bool) { return indexer.pubkeyCache.Get(pubkey) } +// GetBuilderIndexByPubkey returns the builder index for a given pubkey (EIP-8282). +// Builders have a dedicated pubkey cache, separate from validators. +func (indexer *Indexer) GetBuilderIndexByPubkey(pubkey phase0.BLSPubKey) (gloas.BuilderIndex, bool) { + idx, found := indexer.builderPubkeyCache.Get(pubkey) + return gloas.BuilderIndex(idx), found +} + // GetValidatorByIndex returns the validator by index for a given forkId. func (indexer *Indexer) GetValidatorByIndex(index phase0.ValidatorIndex, overrideForkId *ForkKey) *phase0.Validator { return indexer.validatorCache.getValidatorByIndex(index, overrideForkId) @@ -649,16 +658,13 @@ func (indexer *Indexer) applyBuilderBalanceChanges(block *Block, balances []phas } } - // Apply deposit requests (increase builder balances). + // Apply builder deposit requests (increase builder balances). if payload.Message.ExecutionRequests != nil { - for _, deposit := range payload.Message.ExecutionRequests.Deposits { - if validatorIdx, found := indexer.pubkeyCache.Get(deposit.Pubkey); found { - idx := uint64(validatorIdx) - if idx&BuilderIndexFlag != 0 { - builderIdx := idx &^ BuilderIndexFlag - if builderIdx < uint64(len(balances)) { - balances[builderIdx] += phase0.Gwei(deposit.Amount) - } + for _, deposit := range payload.Message.ExecutionRequests.BuilderDeposits { + if builderIdx, found := indexer.builderPubkeyCache.Get(deposit.Pubkey); found { + idx := uint64(builderIdx) + if idx < uint64(len(balances)) { + balances[idx] += phase0.Gwei(deposit.Amount) } } } @@ -688,19 +694,7 @@ func (indexer *Indexer) applyBuilderBalanceChanges(block *Block, balances []phas } } - // Apply deposit requests (increase builder balances). - if body.ExecutionRequests != nil { - for _, deposit := range body.ExecutionRequests.Deposits { - if validatorIdx, found := indexer.pubkeyCache.Get(deposit.Pubkey); found { - idx := uint64(validatorIdx) - if idx&BuilderIndexFlag != 0 { - builderIdx := idx &^ BuilderIndexFlag - if builderIdx < uint64(len(balances)) { - balances[builderIdx] += phase0.Gwei(deposit.Amount) - } - } - } - } - } + // No builder deposit requests pre-EIP-7732: builders (EIP-8282) only exist from + // Gloas onwards, where the payload envelope path above is used instead. } } diff --git a/indexer/beacon/pubkeycache.go b/indexer/beacon/pubkeycache.go index 573bed4af..1d0392b8c 100644 --- a/indexer/beacon/pubkeycache.go +++ b/indexer/beacon/pubkeycache.go @@ -8,10 +8,16 @@ import ( "github.com/syndtr/goleveldb/leveldb" ) +// pubkeyCacheBuilderPrefix namespaces builder pubkey keys in the shared leveldb store so +// they do not collide with validator keys (which are stored unprefixed for backwards compat). +const pubkeyCacheBuilderPrefix = "b:" + type pubkeyCache struct { pubkeyDb *leveldb.DB pubkeyMap map[phase0.BLSPubKey]phase0.ValidatorIndex pubkeyMutex sync.RWMutex // mutex to protect pubkeyMap for concurrent access + keyPrefix []byte // optional prefix for leveldb keys to namespace entries (e.g. builders) + ownsDb bool // whether this cache owns (and should close) the leveldb handle } // newPubkeyCache creates a new cache for validator public keys. @@ -24,6 +30,7 @@ func newPubkeyCache(indexer *Indexer, cacheFile string) *pubkeyCache { indexer.logger.WithError(err).Error("failed to open pubkey cache") } else { cache.pubkeyDb = db + cache.ownsDb = true } } @@ -34,13 +41,39 @@ func newPubkeyCache(indexer *Indexer, cacheFile string) *pubkeyCache { return cache } +// newPrefixedCache returns a pubkey cache that shares this cache's leveldb store (if any) +// but namespaces its keys with the given prefix, so a separate index space (e.g. builders, +// EIP-8282) can coexist with validators in the same file without key collisions. When there +// is no backing file it gets its own in-memory map instead. +func (c *pubkeyCache) newPrefixedCache(prefix []byte) *pubkeyCache { + sub := &pubkeyCache{ + pubkeyDb: c.pubkeyDb, + keyPrefix: prefix, + } + if sub.pubkeyDb == nil { + sub.pubkeyMap = make(map[phase0.BLSPubKey]phase0.ValidatorIndex) + } + return sub +} + +// dbKey builds the leveldb key for a pubkey, applying the optional namespace prefix. +func (c *pubkeyCache) dbKey(pubkey phase0.BLSPubKey) []byte { + if len(c.keyPrefix) == 0 { + return pubkey[:] + } + key := make([]byte, 0, len(c.keyPrefix)+len(pubkey)) + key = append(key, c.keyPrefix...) + key = append(key, pubkey[:]...) + return key +} + func (c *pubkeyCache) Add(pubkey phase0.BLSPubKey, index phase0.ValidatorIndex) error { c.pubkeyMutex.Lock() defer c.pubkeyMutex.Unlock() if c.pubkeyDb != nil { indexStr := strconv.FormatUint(uint64(index), 10) - err := c.pubkeyDb.Put(pubkey[:], []byte(indexStr), nil) + err := c.pubkeyDb.Put(c.dbKey(pubkey), []byte(indexStr), nil) if err != nil { return err } @@ -53,7 +86,7 @@ func (c *pubkeyCache) Add(pubkey phase0.BLSPubKey, index phase0.ValidatorIndex) func (c *pubkeyCache) Get(pubkey phase0.BLSPubKey) (phase0.ValidatorIndex, bool) { if c.pubkeyDb != nil { - data, err := c.pubkeyDb.Get(pubkey[:], nil) + data, err := c.pubkeyDb.Get(c.dbKey(pubkey), nil) if err != nil { return 0, false } @@ -74,7 +107,7 @@ func (c *pubkeyCache) Get(pubkey phase0.BLSPubKey) (phase0.ValidatorIndex, bool) } func (c *pubkeyCache) Close() error { - if c.pubkeyDb != nil { + if c.pubkeyDb != nil && c.ownsDb { return c.pubkeyDb.Close() } return nil diff --git a/indexer/beacon/statetransition/block.go b/indexer/beacon/statetransition/block.go index 9634938e4..e9b378ab4 100644 --- a/indexer/beacon/statetransition/block.go +++ b/indexer/beacon/statetransition/block.go @@ -69,6 +69,10 @@ func (st *StateTransition) applyBlock(state *all.BeaconState, block *all.SignedB } } s.Slot++ + + // Apply the Gloas fork upgrade when advancing into the first slot of GLOAS_FORK_EPOCH, + // so a block that is the first of the Gloas fork is processed against an upgraded state. + maybeUpgradeToGloas(s, nil) } proposerIndex := block.Message.ProposerIndex diff --git a/indexer/beacon/statetransition/fork.go b/indexer/beacon/statetransition/fork.go new file mode 100644 index 000000000..8efb556c4 --- /dev/null +++ b/indexer/beacon/statetransition/fork.go @@ -0,0 +1,246 @@ +package statetransition + +import ( + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/all" + "github.com/ethpandaops/go-eth2-client/spec/bellatrix" + "github.com/ethpandaops/go-eth2-client/spec/capella" + "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + + "github.com/ethpandaops/dora/indexer/beacon/depositsig" +) + +// builderWithdrawalPrefix is BUILDER_WITHDRAWAL_PREFIX (Gloas/EIP-8282): the 0x03 +// withdrawal-credential prefix that marks a deposit as a builder deposit. +const builderWithdrawalPrefix byte = 0x03 + +// isBuilderWithdrawalCredential reports whether the credentials use the builder prefix (0x03). +func isBuilderWithdrawalCredential(wc []byte) bool { + return len(wc) > 0 && wc[0] == builderWithdrawalPrefix +} + +// maybeUpgradeToGloas applies the upgrade_to_gloas irregular state change at the first slot of +// GLOAS_FORK_EPOCH, mirroring the spec trigger +// (state.slot % SLOTS_PER_EPOCH == 0 and compute_epoch_at_slot(state.slot) == GLOAS_FORK_EPOCH). +// It is a no-op for every other slot, for chains without a configured Gloas fork, and for states +// that are already Gloas. The onboarded builder deposits are reported via info (if provided). +func maybeUpgradeToGloas(s *stateAccessor, info *TransitionInfo) { + if s.specs.GloasForkEpoch == nil || s.Version >= spec.DataVersionGloas { + return + } + if uint64(s.Slot)%s.specs.SlotsPerEpoch != 0 { + return + } + if uint64(s.currentEpoch()) != *s.specs.GloasForkEpoch { + return + } + + onboarded := upgradeToGloas(s) + if info != nil { + info.GloasOnboardedDeposits = onboarded + } +} + +// upgradeToGloas implements upgrade_to_gloas: it mutates the embedded state in place from Fulu to +// Gloas (EIP-7732/EIP-8282), initializing the new builder-related fields and the PTC window, then +// onboards builders from the pending_deposits queue. It returns the deposits that were onboarded. +// +// https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/fork.md +func upgradeToGloas(s *stateAccessor) []*electra.PendingDeposit { + epoch := s.currentEpoch() + header := s.LatestExecutionPayloadHeader + + // [Modified in Gloas] fork versions + s.Fork = &phase0.Fork{ + PreviousVersion: s.Fork.CurrentVersion, + CurrentVersion: s.specs.GloasForkVersion, + Epoch: epoch, + } + + // From here the state is treated as Gloas for HTR and all fork-conditional logic. + s.Version = spec.DataVersionGloas + + // [New in Gloas:EIP7732] latest_block_hash carries over the (removed) execution payload header hash. + if header != nil { + s.LatestBlockHash = header.BlockHash + } + + // [New in Gloas:EIP7732] empty builder registry and withdrawal cursor. + s.Builders = make([]*gloas.Builder, 0) + s.NextWithdrawalBuilderIndex = 0 + + // [New in Gloas:EIP7732] execution_payload_availability: a Bitvector[SLOTS_PER_HISTORICAL_ROOT] + // with every bit set (all historical payloads are considered available at the fork). + availability := make([]uint8, s.specs.SlotsPerHistoricalRoot/8) + for i := range availability { + availability[i] = 0xFF + } + s.ExecutionPayloadAvailability = availability + + // [New in Gloas:EIP7732] 2 * SLOTS_PER_EPOCH empty builder pending payments, no pending withdrawals. + payments := make([]*gloas.BuilderPendingPayment, 2*s.specs.SlotsPerEpoch) + for i := range payments { + payments[i] = &gloas.BuilderPendingPayment{} + } + s.BuilderPendingPayments = payments + s.BuilderPendingWithdrawals = make([]*gloas.BuilderPendingWithdrawal, 0) + + // [New in Gloas:EIP7732] latest_execution_payload_bid seeded from the header, with the root of + // an empty ExecutionRequests. A HTR failure leaves a zero root (best effort; only affects the + // state root used to short-circuit later replays, never the extracted field values). + var execRequestsRoot phase0.Root + if root, err := s.dynSsz.HashTreeRoot(&all.ExecutionRequests{Version: spec.DataVersionGloas}); err == nil { + execRequestsRoot = phase0.Root(root) + } + bid := &all.ExecutionPayloadBid{ + Version: spec.DataVersionGloas, + ExecutionRequestsRoot: execRequestsRoot, + } + if header != nil { + bid.BlockHash = header.BlockHash + bid.GasLimit = header.GasLimit + } + s.LatestExecutionPayloadBid = bid + s.PayloadExpectedWithdrawals = make([]*capella.Withdrawal, 0) + + // [New in Gloas:EIP7732] PTC assignment window. + s.PTCWindow = initializePtcWindow(s) + + // [Removed in Gloas:EIP7732] latest_execution_payload_header (replaced by the bid above). + s.LatestExecutionPayloadHeader = nil + + // [New in Gloas:EIP7732] one-time onboarding of builders from the pending_deposits queue. + return onboardBuildersFromPendingDeposits(s) +} + +// initializePtcWindow implements initialize_ptc_window: an empty previous epoch (SLOTS_PER_EPOCH +// vectors of PTC_SIZE zero indices) followed by the computed PTCs for the current epoch and the +// next MIN_SEED_LOOKAHEAD epochs. +// +// https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/beacon-chain.md#new-initialize_ptc_window +func initializePtcWindow(s *stateAccessor) [][]phase0.ValidatorIndex { + slotsPerEpoch := s.specs.SlotsPerEpoch + window := make([][]phase0.ValidatorIndex, 0, (2+s.specs.MinSeedLookahead)*slotsPerEpoch) + + // empty_previous_epoch + for i := uint64(0); i < slotsPerEpoch; i++ { + window = append(window, make([]phase0.ValidatorIndex, s.specs.PtcSize)) + } + + currentEpoch := s.currentEpoch() + for e := uint64(0); e < 1+s.specs.MinSeedLookahead; e++ { + epoch := currentEpoch + phase0.Epoch(e) + startSlot := uint64(epoch) * slotsPerEpoch + for i := uint64(0); i < slotsPerEpoch; i++ { + window = append(window, computePtc(s, phase0.Slot(startSlot+i))) + } + } + + return window +} + +// onboardBuildersFromPendingDeposits implements onboard_builders_from_pending_deposits: it walks +// the pending_deposits queue once, onboarding builder-credential deposits (new registrations and +// top-ups) into the builder registry and dropping them from the queue, while leaving validator +// deposits in place. It returns the deposits that were onboarded as builders. +// +// The proof-of-possession signatures are verified under the regular deposit domain (these deposits +// came through the validator deposit contract) in a single batch up front — the queue can hold +// thousands of deposits, so per-deposit verification at the fork would be slow. The onboarding pass +// itself must stay sequential: each registration mutates the builder set, turning later same-pubkey +// deposits into top-ups, and the pending-validator check depends on the kept queue built so far. +// +// https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/beacon-chain.md#new-onboard_builders_from_pending_deposits +func onboardBuildersFromPendingDeposits(s *stateAccessor) []*electra.PendingDeposit { + onboarded := make([]*electra.PendingDeposit, 0) + + validatorPubkeys := make(map[phase0.BLSPubKey]bool, len(s.Validators)) + for _, v := range s.Validators { + validatorPubkeys[v.PublicKey] = true + } + + sigValid := batchVerifyOnboardingSignatures(s, validatorPubkeys) + + // The builder registry is empty pre-fork and only this loop grows it, so a pubkey→index map + // mirrors state.builders exactly (used to detect top-ups). keptPubkeys mirrors the deposits + // that stay in the queue, so a builder deposit sharing a pubkey with a pending validator + // deposit is kept rather than onboarded. + kept := make([]*electra.PendingDeposit, 0, len(s.PendingDeposits)) + keptPubkeys := make(map[phase0.BLSPubKey]bool, len(s.PendingDeposits)) + builderIndexByPubkey := make(map[phase0.BLSPubKey]uint64) + + for i, deposit := range s.PendingDeposits { + // deposits for existing validators always stay in the pending queue + if validatorPubkeys[deposit.Pubkey] { + kept = append(kept, deposit) + keptPubkeys[deposit.Pubkey] = true + continue + } + + if builderIndex, isBuilder := builderIndexByPubkey[deposit.Pubkey]; isBuilder { + // top up an already-onboarded builder + s.Builders[builderIndex].Balance += deposit.Amount + } else { + // non-builder deposits, and builder deposits for a pubkey that already has a pending + // validator deposit, stay in the queue (applied to that validator later). + if !isBuilderWithdrawalCredential(deposit.WithdrawalCredentials) { + kept = append(kept, deposit) + keptPubkeys[deposit.Pubkey] = true + continue + } + if keptPubkeys[deposit.Pubkey] { + kept = append(kept, deposit) + continue + } + // new builder: onboarded only with a valid proof-of-possession; an invalid signature is + // dropped entirely (neither kept nor onboarded). + if !sigValid[i] { + continue + } + var execAddr bellatrix.ExecutionAddress + copy(execAddr[:], deposit.WithdrawalCredentials[12:]) + addBuilderToRegistry(s, deposit.Pubkey, deposit.WithdrawalCredentials[0], execAddr, deposit.Amount, deposit.Slot) + builderIndexByPubkey[deposit.Pubkey] = uint64(len(s.Builders) - 1) + } + + // onboarded as a builder (new registration or top-up) + onboarded = append(onboarded, deposit) + } + + s.PendingDeposits = kept + return onboarded +} + +// batchVerifyOnboardingSignatures batch-verifies the proof-of-possession of every builder-credential +// pending deposit for a pubkey that is not already a validator (a superset of the deposits whose +// signature actually gates a new builder registration), returning per-deposit validity indexed by +// position in state.pending_deposits. Verification uses the regular deposit domain. +func batchVerifyOnboardingSignatures(s *stateAccessor, validatorPubkeys map[phase0.BLSPubKey]bool) []bool { + sigValid := make([]bool, len(s.PendingDeposits)) + + inputs := make([]depositsig.Input, 0, len(s.PendingDeposits)) + indexes := make([]int, 0, len(s.PendingDeposits)) + for i, deposit := range s.PendingDeposits { + if validatorPubkeys[deposit.Pubkey] || !isBuilderWithdrawalCredential(deposit.WithdrawalCredentials) { + continue + } + inputs = append(inputs, depositsig.Input{ + Pubkey: deposit.Pubkey, + WithdrawalCredentials: deposit.WithdrawalCredentials, + Amount: deposit.Amount, + Signature: deposit.Signature, + }) + indexes = append(indexes, i) + } + + if len(inputs) > 0 { + results := depositsig.VerifyBatch(inputs, depositsig.Domain(s.specs.GenesisForkVersion)) + for j, idx := range indexes { + sigValid[idx] = results[j] + } + } + + return sigValid +} diff --git a/indexer/beacon/statetransition/operations.go b/indexer/beacon/statetransition/operations.go index 14a643a83..12be3e035 100644 --- a/indexer/beacon/statetransition/operations.go +++ b/indexer/beacon/statetransition/operations.go @@ -55,9 +55,15 @@ func processOperations(s *stateAccessor, block *all.SignedBeaconBlock) { } // applyExecutionRequests processes a block/payload's execution-layer requests -// (deposits, withdrawal requests, consolidation requests) into the state. -// Shared by processOperations (pre-Gloas) and processParentExecutionPayload (Gloas+). -func applyExecutionRequests(s *stateAccessor, requests *electra.ExecutionRequests) { +// into the state. Shared by processOperations (pre-Gloas) and +// processParentExecutionPayload (Gloas+). +// +// Gloas (EIP-8282) adds two dedicated request lists — builder deposits and +// builder exits — processed after the validator-facing requests, mirroring the +// for_ops order in apply_parent_execution_payload. +// +// https://github.com/ethereum/consensus-specs/pull/5359 +func applyExecutionRequests(s *stateAccessor, requests *all.ExecutionRequests) { if requests == nil { return } @@ -70,34 +76,27 @@ func applyExecutionRequests(s *stateAccessor, requests *electra.ExecutionRequest for _, consolidation := range requests.Consolidations { processConsolidationRequest(s, consolidation) } -} -// builderWithdrawalPrefix is BUILDER_WITHDRAWAL_PREFIX (Gloas): the withdrawal -// credential prefix that designates a builder deposit. -const builderWithdrawalPrefix = 0x03 + if s.Version >= spec.DataVersionGloas { + for _, builderDeposit := range requests.BuilderDeposits { + processBuilderDepositRequest(s, builderDeposit) + } + for _, builderExit := range requests.BuilderExits { + processBuilderExitRequest(s, builderExit) + } + } +} // processDepositRequest implements process_deposit_request. // -// Pre-Gloas the request is unconditionally appended to the pending_deposits -// queue. Gloas (EIP-7732) diverts builder deposits out of the queue: a deposit -// for a pubkey that already belongs to a builder — or one carrying the 0x03 -// builder credential for a brand-new pubkey — is applied immediately to the -// builder registry and never enters the queue. +// The request is appended to the pending_deposits queue. Gloas (EIP-8282) +// removed the builder branch entirely: builder deposits now arrive via the +// dedicated builder deposit contract (processBuilderDepositRequest), so a +// 0x03-credential deposit via the regular deposit contract is queued as an +// ordinary validator deposit like any other. // -// https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/beacon-chain.md#modified-process_deposit_request +// https://github.com/ethereum/consensus-specs/pull/5359 func processDepositRequest(s *stateAccessor, deposit *electra.DepositRequest) { - if s.Version >= spec.DataVersionGloas { - _, isBuilder := findBuilderByPubkey(s, deposit.Pubkey) - isValidator := findValidatorByPubkey(s, deposit.Pubkey) != nil - if isBuilder || (isBuilderWithdrawalCredential(deposit.WithdrawalCredentials) && - !isValidator && !isPendingValidator(s, deposit.Pubkey)) { - // Apply builder deposits immediately. - applyDepositForBuilder(s, deposit.Pubkey, deposit.WithdrawalCredentials, deposit.Amount, deposit.Signature, s.Slot) - return - } - } - - // Add validator deposits to the queue. s.PendingDeposits = append(s.PendingDeposits, &electra.PendingDeposit{ Pubkey: deposit.Pubkey, WithdrawalCredentials: deposit.WithdrawalCredentials, @@ -107,11 +106,6 @@ func processDepositRequest(s *stateAccessor, deposit *electra.DepositRequest) { }) } -// isBuilderWithdrawalCredential implements is_builder_withdrawal_credential (Gloas). -func isBuilderWithdrawalCredential(withdrawalCredentials []byte) bool { - return len(withdrawalCredentials) > 0 && withdrawalCredentials[0] == builderWithdrawalPrefix -} - // findBuilderByPubkey returns the builder registry index for a pubkey, if present. func findBuilderByPubkey(s *stateAccessor, pubkey phase0.BLSPubKey) (uint64, bool) { for i, builder := range s.Builders { @@ -122,56 +116,119 @@ func findBuilderByPubkey(s *stateAccessor, pubkey phase0.BLSPubKey) (uint64, boo return 0, false } -// isPendingValidator implements is_pending_validator (Gloas): reports whether a -// pending deposit with a valid signature is already queued for the given pubkey. +// processBuilderDepositRequest implements process_builder_deposit_request +// (Gloas/EIP-8282). For an existing builder pubkey the amount tops up its +// balance. For a new pubkey a builder is registered ONLY if the deposit carries a +// valid proof-of-possession under DOMAIN_BUILDER_DEPOSIT; otherwise it is silently +// dropped. // -// https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/beacon-chain.md#new-is_pending_validator -func isPendingValidator(s *stateAccessor, pubkey phase0.BLSPubKey) bool { - depositDomain := depositsig.Domain(s.specs.GenesisForkVersion) - for _, pd := range s.PendingDeposits { - if pd.Pubkey != pubkey { - continue - } - if depositsig.Valid(pd.Pubkey, pd.WithdrawalCredentials, phase0.Gwei(pd.Amount), pd.Signature, depositDomain) { - return true - } +// https://github.com/ethereum/consensus-specs/pull/5359 +func processBuilderDepositRequest(s *stateAccessor, request *gloas.BuilderDepositRequest) { + if request == nil { + return + } + if builderIndex, isBuilder := findBuilderByPubkey(s, request.Pubkey); isBuilder { + s.Builders[builderIndex].Balance += request.Amount + return } - return false + + // New builder: register only if the builder-deposit signature is valid. + if !isValidBuilderDepositSignature(s, request) { + return + } + + var execAddr bellatrix.ExecutionAddress + var credVersion byte + if len(request.WithdrawalCredentials) >= 32 { + credVersion = request.WithdrawalCredentials[0] + copy(execAddr[:], request.WithdrawalCredentials[12:]) + } + addBuilderToRegistry(s, request.Pubkey, credVersion, execAddr, request.Amount, s.Slot) +} + +// isValidBuilderDepositSignature implements is_valid_builder_deposit_signature +// (Gloas): the proof-of-possession is verified over the DepositMessage under the +// dedicated DOMAIN_BUILDER_DEPOSIT. +// +// https://github.com/ethereum/consensus-specs/pull/5359 +func isValidBuilderDepositSignature(s *stateAccessor, request *gloas.BuilderDepositRequest) bool { + domain := depositsig.BuilderDomain(s.specs.GenesisForkVersion) + return depositsig.Valid(request.Pubkey, request.WithdrawalCredentials, request.Amount, request.Signature, domain) } -// applyDepositForBuilder implements apply_deposit_for_builder (Gloas). For an -// existing builder the amount tops up its balance. For a new pubkey a builder is -// registered ONLY if the deposit carries a valid signature (proof-of-possession); -// otherwise the deposit is silently dropped, exactly as the chain does. +// processBuilderExitRequest implements process_builder_exit_request +// (Gloas/EIP-8282). It initiates a builder exit when the pubkey maps to an active +// builder, the source address matches the builder's execution address, and the +// builder has no pending withdrawals queued. // -// https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/beacon-chain.md#new-apply_deposit_for_builder -func applyDepositForBuilder(s *stateAccessor, pubkey phase0.BLSPubKey, withdrawalCredentials []byte, amount phase0.Gwei, signature phase0.BLSSignature, slot phase0.Slot) { - if builderIndex, isBuilder := findBuilderByPubkey(s, pubkey); isBuilder { - s.Builders[builderIndex].Balance += amount +// https://github.com/ethereum/consensus-specs/pull/5359 +func processBuilderExitRequest(s *stateAccessor, request *gloas.BuilderExitRequest) { + if request == nil { return } - - // New builder: only register if the deposit signature is valid. - depositDomain := depositsig.Domain(s.specs.GenesisForkVersion) - if !depositsig.Valid(pubkey, withdrawalCredentials, amount, signature, depositDomain) { + builderIndex, isBuilder := findBuilderByPubkey(s, request.Pubkey) + if !isBuilder { + return + } + if !isActiveBuilder(s, builderIndex) { + return + } + if s.Builders[builderIndex].ExecutionAddress != request.SourceAddress { + return + } + if getPendingBalanceToWithdrawForBuilder(s, builderIndex) != 0 { return } - addBuilderToRegistry(s, pubkey, withdrawalCredentials, amount, slot) + initiateBuilderExit(s, builderIndex) +} + +// isActiveBuilder implements is_active_builder (Gloas): the builder's placement in +// the registry is finalized and it has not initiated exit. +// +// https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/beacon-chain.md#new-is_active_builder +func isActiveBuilder(s *stateAccessor, builderIndex uint64) bool { + builder := s.Builders[builderIndex] + return builder.DepositEpoch < s.FinalizedCheckpoint.Epoch && + builder.WithdrawableEpoch == FarFutureEpoch +} + +// getPendingBalanceToWithdrawForBuilder implements +// get_pending_balance_to_withdraw_for_builder (Gloas): the sum of queued builder +// pending withdrawals and pending-payment withdrawals for the builder. +// +// https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/beacon-chain.md#new-get_pending_balance_to_withdraw_for_builder +func getPendingBalanceToWithdrawForBuilder(s *stateAccessor, builderIndex uint64) phase0.Gwei { + var total phase0.Gwei + for _, withdrawal := range s.BuilderPendingWithdrawals { + if withdrawal != nil && uint64(withdrawal.BuilderIndex) == builderIndex { + total += withdrawal.Amount + } + } + for _, payment := range s.BuilderPendingPayments { + if payment != nil && payment.Withdrawal != nil && uint64(payment.Withdrawal.BuilderIndex) == builderIndex { + total += payment.Withdrawal.Amount + } + } + return total +} + +// initiateBuilderExit implements initiate_builder_exit (Gloas): the builder +// becomes withdrawable after MIN_BUILDER_WITHDRAWABILITY_DELAY epochs. +// +// https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/beacon-chain.md#new-initiate_builder_exit +func initiateBuilderExit(s *stateAccessor, builderIndex uint64) { + s.Builders[builderIndex].WithdrawableEpoch = s.currentEpoch() + phase0.Epoch(s.specs.MinBuilderWithdrawabilityDelay) } // addBuilderToRegistry implements add_builder_to_registry (Gloas). The new builder // reuses the first slot of a fully-withdrawn (withdrawable, zero-balance) builder, // or is appended when no such slot exists. // -// https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/beacon-chain.md#new-add_builder_to_registry -func addBuilderToRegistry(s *stateAccessor, pubkey phase0.BLSPubKey, withdrawalCredentials []byte, amount phase0.Gwei, slot phase0.Slot) { - var execAddr bellatrix.ExecutionAddress - if len(withdrawalCredentials) >= 32 { - copy(execAddr[:], withdrawalCredentials[12:]) - } +// https://github.com/ethereum/consensus-specs/pull/5359 +func addBuilderToRegistry(s *stateAccessor, pubkey phase0.BLSPubKey, credVersion byte, execAddr bellatrix.ExecutionAddress, amount phase0.Gwei, slot phase0.Slot) { builder := &gloas.Builder{ PublicKey: pubkey, - Version: withdrawalCredentials[0], + Version: credVersion, ExecutionAddress: execAddr, Balance: amount, DepositEpoch: phase0.Epoch(uint64(slot) / s.specs.SlotsPerEpoch), @@ -203,16 +260,51 @@ func getIndexForNewBuilder(s *stateAccessor) uint64 { // processProposerSlashing processes a proposer slashing. // https://github.com/ethereum/consensus-specs/blob/master/specs/phase0/beacon-chain.md#proposer-slashings func processProposerSlashing(s *stateAccessor, slashing *phase0.ProposerSlashing) { - if slashing == nil || slashing.SignedHeader1 == nil { + if slashing == nil || slashing.SignedHeader1 == nil || slashing.SignedHeader1.Message == nil { return } - proposerIndex := slashing.SignedHeader1.Message.ProposerIndex + header := slashing.SignedHeader1.Message + proposerIndex := header.ProposerIndex if int(proposerIndex) >= len(s.Validators) { return } + + // New in Gloas: clear the BuilderPendingPayment tied to this proposal if it is + // still in the 2-epoch window and bound to the slashed proposer (per #5365 the + // payment is only griefed when the slashed validator is its proposer). + // https://github.com/ethereum/consensus-specs/pull/5365 + if s.Version >= spec.DataVersionGloas { + clearSlashedBuilderPendingPayment(s, header.Slot, proposerIndex) + } + slashValidator(s, proposerIndex) } +// clearSlashedBuilderPendingPayment removes the builder pending payment recorded +// for the slashed proposer's slot, if that slot is still within the 2-epoch +// BuilderPendingPayments window and the payment is bound to the slashed proposer. +func clearSlashedBuilderPendingPayment(s *stateAccessor, slot phase0.Slot, proposerIndex phase0.ValidatorIndex) { + slotsPerEpoch := s.specs.SlotsPerEpoch + proposalEpoch := phase0.Epoch(uint64(slot) / slotsPerEpoch) + + var paymentIdx uint64 + switch proposalEpoch { + case s.currentEpoch(): + paymentIdx = slotsPerEpoch + uint64(slot)%slotsPerEpoch + case s.previousEpoch(): + paymentIdx = uint64(slot) % slotsPerEpoch + default: + return + } + + if paymentIdx >= uint64(len(s.BuilderPendingPayments)) { + return + } + if payment := s.BuilderPendingPayments[paymentIdx]; payment != nil && payment.ProposerIndex == proposerIndex { + s.BuilderPendingPayments[paymentIdx] = &gloas.BuilderPendingPayment{} + } +} + // processAttesterSlashing processes an attester slashing. // https://github.com/ethereum/consensus-specs/blob/master/specs/phase0/beacon-chain.md#attester-slashings func processAttesterSlashing(s *stateAccessor, slashing *all.AttesterSlashing) { @@ -650,6 +742,7 @@ func processExecutionPayloadBid(s *stateAccessor, block *all.SignedBeaconBlock) Amount: bid.Value, BuilderIndex: bid.BuilderIndex, }, + ProposerIndex: block.Message.ProposerIndex, } } } diff --git a/indexer/beacon/statetransition/statetransition.go b/indexer/beacon/statetransition/statetransition.go index 99be9af86..6057f4a33 100644 --- a/indexer/beacon/statetransition/statetransition.go +++ b/indexer/beacon/statetransition/statetransition.go @@ -19,6 +19,7 @@ import ( "github.com/ethpandaops/dora/clients/consensus" "github.com/ethpandaops/go-eth2-client/spec" "github.com/ethpandaops/go-eth2-client/spec/all" + "github.com/ethpandaops/go-eth2-client/spec/electra" "github.com/ethpandaops/go-eth2-client/spec/phase0" dynssz "github.com/pk910/dynamic-ssz" ) @@ -90,6 +91,11 @@ type TransitionInfo struct { // DelayedBuilderPayments is a list of slots that the state transition appended delayed builder payments for. // This tells the state simulator which slots to reference delayed builder payments in the BuilderPendingWithdrawals list. DelayedBuilderPayments []uint16 + + // GloasOnboardedDeposits holds the pending deposits that the one-time upgrade_to_gloas + // onboarding converted into builders (and thereby removed from the pending_deposits queue). + // Only populated when the transition crosses the Gloas fork; nil otherwise. + GloasOnboardedDeposits []*electra.PendingDeposit } // processSlots advances the state from its current slot to targetSlot, applying @@ -123,6 +129,10 @@ func (st *StateTransition) processSlots(state *all.BeaconState, targetSlot phase } s.Slot++ + + // Apply the Gloas fork upgrade at the first slot of GLOAS_FORK_EPOCH (after the + // preceding Fulu epoch transition has run, mirroring the spec's irregular state change). + maybeUpgradeToGloas(s, info) } return nil diff --git a/indexer/beacon/writedb.go b/indexer/beacon/writedb.go index 8c78ec9d1..87963b0cb 100644 --- a/indexer/beacon/writedb.go +++ b/indexer/beacon/writedb.go @@ -1,6 +1,7 @@ package beacon import ( + "bytes" "fmt" "math" @@ -169,6 +170,18 @@ func (dbw *dbWriter) persistBlockChildObjects(tx *sqlx.Tx, block *Block, deposit return err } + // insert builder deposit requests (gloas) + err = dbw.persistBlockBuilderDeposits(tx, block, orphaned, overrideForkId) + if err != nil { + return err + } + + // insert builder exit requests (gloas) + err = dbw.persistBlockBuilderExits(tx, block, orphaned, overrideForkId) + if err != nil { + return err + } + // insert withdrawals err = dbw.persistBlockWithdrawals(tx, block, orphaned, overrideForkId, sim) if err != nil { @@ -332,11 +345,16 @@ func (dbw *dbWriter) buildDbBlock(block *Block, epochStats *EpochStats, override } } - // DepositCount counts the deposits this block processes; in Gloas these come from the - // parent payload (parent_execution_requests), consistent with the deposits table and - // the slot detail page, which attribute requests to the block that processes them. + // DepositCount/ExitCount count the deposits and exits this block processes; in Gloas + // these come from the parent payload (parent_execution_requests), consistent with the + // deposits table and the slot detail page, which attribute requests to the block that + // processes them. Gloas builder deposits/exits (EIP-8282) are folded into the combined + // deposit/exit counts. + var builderDepositCount, builderExitCount int if processedRequests, _ := dbw.getProcessedExecutionRequests(block); processedRequests != nil { depositRequests = processedRequests.Deposits + builderDepositCount = len(processedRequests.BuilderDeposits) + builderExitCount = len(processedRequests.BuilderExits) } // Get builder index from block, default to -1 (self-built/MaxUint64) @@ -368,8 +386,8 @@ func (dbw *dbWriter) buildDbBlock(block *Block, epochStats *EpochStats, override Graffiti: graffiti[:], GraffitiText: utils.GraffitiToString(graffiti[:]), AttestationCount: uint64(len(attestations)), - DepositCount: uint64(len(deposits) + len(depositRequests)), - ExitCount: uint64(len(voluntaryExits)), + DepositCount: uint64(len(deposits) + len(depositRequests) + builderDepositCount), + ExitCount: uint64(len(voluntaryExits) + builderExitCount), AttesterSlashingCount: uint64(len(attesterSlashings)), ProposerSlashingCount: uint64(len(proposerSlashings)), BLSChangeCount: uint64(len(blsToExecChanges)), @@ -570,15 +588,19 @@ func (dbw *dbWriter) buildDbEpoch(epoch phase0.Epoch, blocks []*Block, epochStat } } - // Count the deposits each block processes; in Gloas these come from the parent - // payload (parent_execution_requests), consistent with the deposits table. + // Count the deposits/exits each block processes; in Gloas these come from the + // parent payload (parent_execution_requests), consistent with the deposits table. + // Gloas builder deposits/exits (EIP-8282) are folded into the combined counts. + var builderDepositCount, builderExitCount int if processedRequests, _ := dbw.getProcessedExecutionRequests(block); processedRequests != nil { depositRequests = processedRequests.Deposits + builderDepositCount = len(processedRequests.BuilderDeposits) + builderExitCount = len(processedRequests.BuilderExits) } dbEpoch.AttestationCount += uint64(len(attestations)) - dbEpoch.DepositCount += uint64(len(deposits) + len(depositRequests)) - dbEpoch.ExitCount += uint64(len(voluntaryExits)) + dbEpoch.DepositCount += uint64(len(deposits) + len(depositRequests) + builderDepositCount) + dbEpoch.ExitCount += uint64(len(voluntaryExits) + builderExitCount) dbEpoch.AttesterSlashingCount += uint64(len(attesterSlashings)) dbEpoch.ProposerSlashingCount += uint64(len(proposerSlashings)) dbEpoch.BLSChangeCount += uint64(len(blsToExecChanges)) @@ -732,7 +754,7 @@ func (dbw *dbWriter) persistBlockDepositRequests(tx *sqlx.Tx, block *Block, orph // carries an empty parent_execution_requests, so the last pre-Gloas requests are not counted // twice. In earlier forks the requests live in the block body and were included in the // block's own payload. -func (dbw *dbWriter) getProcessedExecutionRequests(block *Block) (*electra.ExecutionRequests, uint64) { +func (dbw *dbWriter) getProcessedExecutionRequests(block *Block) (*all.ExecutionRequests, uint64) { chainState := dbw.indexer.consensusPool.GetChainState() blockBody := block.GetBlock(dbw.indexer.ctx) @@ -788,6 +810,218 @@ func (dbw *dbWriter) buildDbDepositRequests(block *Block, orphaned bool, overrid return dbDeposits } +// persistBlockBuilderDeposits persists the block's builder deposit requests +// (Gloas/EIP-8282) to the builder_deposits table. Pre-Gloas blocks carry none. +func (dbw *dbWriter) persistBlockBuilderDeposits(tx *sqlx.Tx, block *Block, orphaned bool, overrideForkId *ForkKey) error { + dbDeposits := dbw.buildDbBuilderDeposits(block, orphaned, overrideForkId) + if len(dbDeposits) > 0 { + err := db.InsertBuilderDeposits(dbw.indexer.ctx, tx, dbDeposits) + if err != nil { + return fmt.Errorf("error inserting builder deposits: %v", err) + } + } + + return nil +} + +func (dbw *dbWriter) buildDbBuilderDeposits(block *Block, orphaned bool, overrideForkId *ForkKey) []*dbtypes.BuilderDeposit { + requests, blockNumber := dbw.getProcessedExecutionRequests(block) + if requests == nil || len(requests.BuilderDeposits) == 0 { + return []*dbtypes.BuilderDeposit{} + } + + dbDeposits := make([]*dbtypes.BuilderDeposit, len(requests.BuilderDeposits)) + for idx, deposit := range requests.BuilderDeposits { + dbDeposit := &dbtypes.BuilderDeposit{ + SlotNumber: uint64(block.Slot), + SlotRoot: block.Root[:], + SlotIndex: uint64(idx), + Orphaned: orphaned, + ForkId: uint64(block.forkId), + PublicKey: deposit.Pubkey[:], + WithdrawalCredentials: deposit.WithdrawalCredentials, + Amount: uint64(deposit.Amount), + Signature: deposit.Signature[:], + BlockNumber: blockNumber, + } + if builderIdx, found := dbw.indexer.builderPubkeyCache.Get(deposit.Pubkey); found { + resolvedIdx := uint64(builderIdx) + dbDeposit.BuilderIndex = &resolvedIdx + } + if overrideForkId != nil { + dbDeposit.ForkId = uint64(*overrideForkId) + } + + dbDeposits[idx] = dbDeposit + } + + return dbDeposits +} + +// persistGloasOnboardedBuilderDeposits copies the builder deposits that the one-time +// upgrade_to_gloas fork transition onboarded from the pending_deposits queue into the +// builder_deposits (+ builder_deposit_request_txs) tables. These deposits arrived through +// the validator deposit contract, so they are absent from the dedicated builder deposit +// tables; without this copy they never surface on the builder deposit / builder detail pages. +// +// The deposits are attributed to the first slot of the Gloas fork epoch (where onboarding +// happens) and keyed by the epoch's dependent root, so reloads upsert and sibling branches +// stay distinct. Execution-layer tx details are sourced from the regular deposit_txs table +// (matched by pubkey+amount+signature); the contract indexer persists those rows for recent +// unfinalized blocks too, so the lookup is reliable even shortly after the fork. +func (dbw *dbWriter) persistGloasOnboardedBuilderDeposits(tx *sqlx.Tx, epoch phase0.Epoch, dependentRoot phase0.Root, forkId ForkKey, onboarded []*electra.PendingDeposit) error { + if len(onboarded) == 0 { + return nil + } + + chainState := dbw.indexer.consensusPool.GetChainState() + slotNumber := uint64(epoch) * chainState.GetSpecs().SlotsPerEpoch + + // Index the deposit txs of every onboarded pubkey for matching. Top-ups share a pubkey, so + // candidates are consumed in order as they are paired to keep the mapping 1:1. + pubkeys := make([][]byte, 0, len(onboarded)) + seenPubkey := make(map[phase0.BLSPubKey]bool, len(onboarded)) + for _, deposit := range onboarded { + if !seenPubkey[deposit.Pubkey] { + seenPubkey[deposit.Pubkey] = true + pubkeys = append(pubkeys, deposit.Pubkey[:]) + } + } + + txsByPubkey := make(map[phase0.BLSPubKey][]*dbtypes.DepositTx, len(pubkeys)) + for _, depositTx := range db.GetDepositTxsByPublicKeys(dbw.indexer.ctx, pubkeys) { + key := phase0.BLSPubKey(depositTx.PublicKey) + txsByPubkey[key] = append(txsByPubkey[key], depositTx) + } + + dbDeposits := make([]*dbtypes.BuilderDeposit, 0, len(onboarded)) + dbDepositTxs := make([]*dbtypes.BuilderDepositTx, 0, len(onboarded)) + firstSeen := make(map[phase0.BLSPubKey]bool, len(pubkeys)) + + for idx, deposit := range onboarded { + // The first deposit of a pubkey registers a new builder, later ones top it up. + result := dbtypes.BuilderDepositRequestResultTopUp + if !firstSeen[deposit.Pubkey] { + firstSeen[deposit.Pubkey] = true + result = dbtypes.BuilderDepositRequestResultNewBuilder + } + + dbDeposit := &dbtypes.BuilderDeposit{ + SlotNumber: slotNumber, + SlotRoot: dependentRoot[:], + SlotIndex: uint64(idx), + Orphaned: false, + ForkId: uint64(forkId), + PublicKey: deposit.Pubkey[:], + WithdrawalCredentials: deposit.WithdrawalCredentials, + Amount: uint64(deposit.Amount), + Signature: deposit.Signature[:], + Result: result, + } + if builderIdx, found := dbw.indexer.builderPubkeyCache.Get(deposit.Pubkey); found { + resolvedIdx := uint64(builderIdx) + dbDeposit.BuilderIndex = &resolvedIdx + } + + if depositTx := consumeMatchingDepositTx(txsByPubkey, deposit); depositTx != nil { + dbDeposit.TxHash = depositTx.TxHash + dbDeposit.BlockNumber = depositTx.BlockNumber + + // dequeue_block is set to the originating EL block so the page treats the request as + // already included (its pending bucket is dequeue_block > highest indexed EL block). + dbDepositTxs = append(dbDepositTxs, &dbtypes.BuilderDepositTx{ + BlockNumber: depositTx.BlockNumber, + BlockIndex: uint64(idx), + BlockTime: depositTx.BlockTime, + BlockRoot: depositTx.BlockRoot, + ForkId: depositTx.ForkId, + PublicKey: deposit.Pubkey[:], + WithdrawalCredentials: deposit.WithdrawalCredentials, + Amount: uint64(deposit.Amount), + Signature: deposit.Signature[:], + BuilderIndex: dbDeposit.BuilderIndex, + TxHash: depositTx.TxHash, + TxSender: depositTx.TxSender, + TxTarget: depositTx.TxTarget, + DequeueBlock: depositTx.BlockNumber, + }) + } + + dbDeposits = append(dbDeposits, dbDeposit) + } + + if err := db.InsertBuilderDeposits(dbw.indexer.ctx, tx, dbDeposits); err != nil { + return fmt.Errorf("error inserting onboarded builder deposits: %v", err) + } + if len(dbDepositTxs) > 0 { + if err := db.InsertBuilderDepositTxs(dbw.indexer.ctx, tx, dbDepositTxs); err != nil { + return fmt.Errorf("error inserting onboarded builder deposit txs: %v", err) + } + } + + return nil +} + +// consumeMatchingDepositTx returns and removes the first deposit tx that matches the pending +// deposit by amount and signature, so repeated top-ups of the same pubkey pair to distinct txs. +func consumeMatchingDepositTx(txsByPubkey map[phase0.BLSPubKey][]*dbtypes.DepositTx, deposit *electra.PendingDeposit) *dbtypes.DepositTx { + candidates := txsByPubkey[deposit.Pubkey] + for i, candidate := range candidates { + if candidate.Amount == uint64(deposit.Amount) && bytes.Equal(candidate.Signature, deposit.Signature[:]) { + txsByPubkey[deposit.Pubkey] = append(candidates[:i], candidates[i+1:]...) + return candidate + } + } + + return nil +} + +// persistBlockBuilderExits persists the block's builder exit requests +// (Gloas/EIP-8282) to the builder_exits table. Pre-Gloas blocks carry none. +func (dbw *dbWriter) persistBlockBuilderExits(tx *sqlx.Tx, block *Block, orphaned bool, overrideForkId *ForkKey) error { + dbExits := dbw.buildDbBuilderExits(block, orphaned, overrideForkId) + if len(dbExits) > 0 { + err := db.InsertBuilderExits(dbw.indexer.ctx, tx, dbExits) + if err != nil { + return fmt.Errorf("error inserting builder exits: %v", err) + } + } + + return nil +} + +func (dbw *dbWriter) buildDbBuilderExits(block *Block, orphaned bool, overrideForkId *ForkKey) []*dbtypes.BuilderExit { + requests, blockNumber := dbw.getProcessedExecutionRequests(block) + if requests == nil || len(requests.BuilderExits) == 0 { + return []*dbtypes.BuilderExit{} + } + + dbExits := make([]*dbtypes.BuilderExit, len(requests.BuilderExits)) + for idx, exit := range requests.BuilderExits { + dbExit := &dbtypes.BuilderExit{ + SlotNumber: uint64(block.Slot), + SlotRoot: block.Root[:], + SlotIndex: uint64(idx), + Orphaned: orphaned, + ForkId: uint64(block.forkId), + SourceAddress: exit.SourceAddress[:], + PublicKey: exit.Pubkey[:], + BlockNumber: blockNumber, + } + if builderIdx, found := dbw.indexer.builderPubkeyCache.Get(exit.Pubkey); found { + resolvedIdx := uint64(builderIdx) + dbExit.BuilderIndex = &resolvedIdx + } + if overrideForkId != nil { + dbExit.ForkId = uint64(*overrideForkId) + } + + dbExits[idx] = dbExit + } + + return dbExits +} + func (dbw *dbWriter) persistBlockVoluntaryExits(tx *sqlx.Tx, block *Block, orphaned bool, overrideForkId *ForkKey) error { // insert voluntary exits dbVoluntaryExits := dbw.buildDbVoluntaryExits(block, orphaned, overrideForkId) diff --git a/indexer/execution/system_contracts/builder_deposit_indexer.go b/indexer/execution/system_contracts/builder_deposit_indexer.go new file mode 100644 index 000000000..b2714369b --- /dev/null +++ b/indexer/execution/system_contracts/builder_deposit_indexer.go @@ -0,0 +1,269 @@ +package system_contracts + +import ( + "fmt" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/jmoiron/sqlx" + "github.com/sirupsen/logrus" + + "github.com/ethpandaops/dora/clients/execution/rpc" + "github.com/ethpandaops/dora/db" + "github.com/ethpandaops/dora/dbtypes" + "github.com/ethpandaops/dora/indexer/beacon" + "github.com/ethpandaops/dora/indexer/execution" + "github.com/ethpandaops/dora/utils" +) + +// BuilderDepositIndexer indexes the EIP-8282 builder deposit system contract. +type BuilderDepositIndexer struct { + indexerCtx *execution.IndexerCtx + logger logrus.FieldLogger + indexer *contractIndexer[dbtypes.BuilderDepositTx] + matcher *transactionMatcher[builderDepositMatch] +} + +type builderDepositMatch struct { + slotRoot []byte + slotIndex uint64 + txHash []byte +} + +// NewBuilderDepositIndexer creates a new builder deposit contract indexer. +func NewBuilderDepositIndexer(indexer *execution.IndexerCtx) *BuilderDepositIndexer { + batchSize := utils.Config.ExecutionApi.LogBatchSize + if batchSize == 0 { + batchSize = 1000 + } + + bi := &BuilderDepositIndexer{ + indexerCtx: indexer, + logger: indexer.Logger.WithField("indexer", "builder_deposits"), + } + + specs := indexer.ChainState.GetSpecs() + + bi.indexer = newContractIndexer( + indexer, + indexer.Logger.WithField("contract-indexer", "builder_deposits"), + &contractIndexerOptions[dbtypes.BuilderDepositTx]{ + stateKey: "indexer.builderdepositindexer", + batchSize: batchSize, + contractAddress: bi.indexerCtx.GetSystemContractAddress(rpc.BuilderDepositRequestContract), + deployBlock: uint64(utils.Config.ExecutionApi.GloasDeployBlock), + dequeueRate: specs.MaxBuilderDepositRequestsPerPayload, + + processFinalTx: bi.processFinalTx, + processRecentTx: bi.processRecentTx, + persistTxs: bi.persistBuilderDepositTxs, + }, + ) + + bi.matcher = newTransactionMatcher( + indexer, + indexer.Logger.WithField("contract-matcher", "builder_deposits"), + &transactionMatcherOptions[builderDepositMatch]{ + stateKey: "indexer.builderdepositmatcher", + deployBlock: uint64(utils.Config.ExecutionApi.GloasDeployBlock), + timeLimit: 2 * time.Second, + + matchBlockRange: bi.matchBlockRange, + persistMatches: bi.persistMatches, + }, + ) + + go bi.runBuilderDepositIndexerLoop() + + return bi +} + +// GetMatcherHeight returns the last processed el block number from the transaction matcher. +func (bi *BuilderDepositIndexer) GetMatcherHeight() uint64 { + return bi.matcher.GetMatcherHeight() +} + +// runBuilderDepositIndexerLoop is the main loop for the builder deposit indexer. +func (bi *BuilderDepositIndexer) runBuilderDepositIndexerLoop() { + defer utils.HandleSubroutinePanic("BuilderDepositIndexer.runBuilderDepositIndexerLoop", bi.runBuilderDepositIndexerLoop) + + for { + time.Sleep(30 * time.Second) + bi.logger.Debugf("run builder deposit indexer logic") + + err := bi.indexer.runContractIndexer() + if err != nil { + bi.logger.Errorf("indexer error: %v", err) + } + + err = bi.matcher.runTransactionMatcher(bi.indexer.state.FinalBlock) + if err != nil { + bi.logger.Errorf("matcher error: %v", err) + } + } +} + +// processFinalTx parses a finalized builder deposit log into a request tx. +func (bi *BuilderDepositIndexer) processFinalTx(log *types.Log, tx *types.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, _ []*dbtypes.BuilderDepositTx) (*dbtypes.BuilderDepositTx, error) { + requestTx := bi.parseRequestLog(log) + if requestTx == nil { + return nil, fmt.Errorf("invalid builder deposit log") + } + + txTo := *tx.To() + + requestTx.BlockTime = header.Time + requestTx.TxSender = txFrom[:] + requestTx.TxTarget = txTo[:] + requestTx.DequeueBlock = dequeueBlock + + return requestTx, nil +} + +// processRecentTx parses a recent (unfinalized) builder deposit log into a request tx. +func (bi *BuilderDepositIndexer) processRecentTx(log *types.Log, tx *types.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, fork *execution.ForkWithClients, _ []*dbtypes.BuilderDepositTx) (*dbtypes.BuilderDepositTx, error) { + requestTx := bi.parseRequestLog(log) + if requestTx == nil { + return nil, fmt.Errorf("invalid builder deposit log") + } + + txTo := *tx.To() + + requestTx.BlockTime = header.Time + requestTx.TxSender = txFrom[:] + requestTx.TxTarget = txTo[:] + requestTx.DequeueBlock = dequeueBlock + + clBlock := bi.indexerCtx.BeaconIndexer.GetBlocksByExecutionBlockHash(phase0.Hash32(log.BlockHash)) + if len(clBlock) > 0 { + requestTx.ForkId = uint64(clBlock[0].GetForkId()) + } else { + requestTx.ForkId = uint64(fork.ForkId) + } + + return requestTx, nil +} + +// parseRequestLog parses a builder deposit log into a request tx. +func (bi *BuilderDepositIndexer) parseRequestLog(log *types.Log) *dbtypes.BuilderDepositTx { + // data layout (BuilderDepositRequest, no sender prefix): + // 0-48: pubkey (48 bytes) + // 48-80: withdrawal credentials (32 bytes) + // 80-88: amount (8 bytes, big-endian) + // 88-184: signature (96 bytes) + if len(log.Data) < 184 { + bi.logger.Warnf("invalid builder deposit log data length: %v", len(log.Data)) + return nil + } + + pubkey := log.Data[0:48] + withdrawalCredentials := log.Data[48:80] + amount := big.NewInt(0).SetBytes(log.Data[80:88]).Uint64() + signature := log.Data[88:184] + + requestTx := &dbtypes.BuilderDepositTx{ + BlockNumber: log.BlockNumber, + BlockIndex: uint64(log.Index), + BlockRoot: log.BlockHash[:], + PublicKey: pubkey, + WithdrawalCredentials: withdrawalCredentials, + Amount: amount, + Signature: signature, + TxHash: log.TxHash[:], + } + + return requestTx +} + +// persistBuilderDepositTxs persists builder deposit request txs to the database. +func (bi *BuilderDepositIndexer) persistBuilderDepositTxs(tx *sqlx.Tx, requests []*dbtypes.BuilderDepositTx) error { + requestCount := len(requests) + for requestIdx := 0; requestIdx < requestCount; requestIdx += 500 { + endIdx := requestIdx + 500 + if endIdx > requestCount { + endIdx = requestCount + } + + err := db.InsertBuilderDepositTxs(bi.indexerCtx.Ctx, tx, requests[requestIdx:endIdx]) + if err != nil { + return fmt.Errorf("error while inserting builder deposit txs: %v", err) + } + } + + return nil +} + +// matchBlockRange matches builder deposit requests (CL) with their EL request txs by dequeue block. +func (bi *BuilderDepositIndexer) matchBlockRange(fromBlock uint64, toBlock uint64) ([]*builderDepositMatch, error) { + requestMatches := []*builderDepositMatch{} + + dequeueDepositTxs := db.GetBuilderDepositTxsByDequeueRange(bi.indexerCtx.Ctx, fromBlock, toBlock) + if len(dequeueDepositTxs) > 0 { + firstBlock := dequeueDepositTxs[0].DequeueBlock + lastBlock := dequeueDepositTxs[len(dequeueDepositTxs)-1].DequeueBlock + + for _, builderDeposit := range db.GetBuilderDepositsByElBlockRange(bi.indexerCtx.Ctx, firstBlock, lastBlock) { + if len(builderDeposit.TxHash) > 0 { + continue + } + + parentForkIds := bi.indexerCtx.BeaconIndexer.GetParentForkIds(beacon.ForkKey(builderDeposit.ForkId)) + isParentFork := func(forkId uint64) bool { + if forkId == builderDeposit.ForkId { + return true + } + for _, parentForkId := range parentForkIds { + if uint64(parentForkId) == forkId { + return true + } + } + return false + } + + matchingTxs := []*dbtypes.BuilderDepositTx{} + for _, tx := range dequeueDepositTxs { + if tx.DequeueBlock == builderDeposit.BlockNumber && isParentFork(tx.ForkId) { + matchingTxs = append(matchingTxs, tx) + } + } + + if len(matchingTxs) == 0 { + for _, tx := range dequeueDepositTxs { + if tx.DequeueBlock == builderDeposit.BlockNumber { + matchingTxs = append(matchingTxs, tx) + } + } + } + + if len(matchingTxs) < int(builderDeposit.SlotIndex)+1 { + continue + } + + txHash := matchingTxs[builderDeposit.SlotIndex].TxHash + bi.logger.Debugf("Matched builder deposit %d:%v with tx 0x%x", builderDeposit.SlotNumber, builderDeposit.SlotIndex, txHash) + + requestMatches = append(requestMatches, &builderDepositMatch{ + slotRoot: builderDeposit.SlotRoot, + slotIndex: builderDeposit.SlotIndex, + txHash: txHash, + }) + } + } + + return requestMatches, nil +} + +// persistMatches persists builder deposit tx-hash matches to the database. +func (bi *BuilderDepositIndexer) persistMatches(tx *sqlx.Tx, matches []*builderDepositMatch) error { + for _, match := range matches { + err := db.UpdateBuilderDepositTxHash(bi.indexerCtx.Ctx, tx, match.slotRoot, match.slotIndex, match.txHash) + if err != nil { + return err + } + } + + return nil +} diff --git a/indexer/execution/system_contracts/builder_exit_indexer.go b/indexer/execution/system_contracts/builder_exit_indexer.go new file mode 100644 index 000000000..8a8b254fe --- /dev/null +++ b/indexer/execution/system_contracts/builder_exit_indexer.go @@ -0,0 +1,262 @@ +package system_contracts + +import ( + "fmt" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/jmoiron/sqlx" + "github.com/sirupsen/logrus" + + "github.com/ethpandaops/dora/clients/execution/rpc" + "github.com/ethpandaops/dora/db" + "github.com/ethpandaops/dora/dbtypes" + "github.com/ethpandaops/dora/indexer/beacon" + "github.com/ethpandaops/dora/indexer/execution" + "github.com/ethpandaops/dora/utils" +) + +// BuilderExitIndexer indexes the EIP-8282 builder exit system contract. +type BuilderExitIndexer struct { + indexerCtx *execution.IndexerCtx + logger logrus.FieldLogger + indexer *contractIndexer[dbtypes.BuilderExitTx] + matcher *transactionMatcher[builderExitMatch] +} + +type builderExitMatch struct { + slotRoot []byte + slotIndex uint64 + txHash []byte +} + +// NewBuilderExitIndexer creates a new builder exit contract indexer. +func NewBuilderExitIndexer(indexer *execution.IndexerCtx) *BuilderExitIndexer { + batchSize := utils.Config.ExecutionApi.LogBatchSize + if batchSize == 0 { + batchSize = 1000 + } + + bi := &BuilderExitIndexer{ + indexerCtx: indexer, + logger: indexer.Logger.WithField("indexer", "builder_exits"), + } + + specs := indexer.ChainState.GetSpecs() + + bi.indexer = newContractIndexer( + indexer, + indexer.Logger.WithField("contract-indexer", "builder_exits"), + &contractIndexerOptions[dbtypes.BuilderExitTx]{ + stateKey: "indexer.builderexitindexer", + batchSize: batchSize, + contractAddress: bi.indexerCtx.GetSystemContractAddress(rpc.BuilderExitRequestContract), + deployBlock: uint64(utils.Config.ExecutionApi.GloasDeployBlock), + dequeueRate: specs.MaxBuilderExitRequestsPerPayload, + + processFinalTx: bi.processFinalTx, + processRecentTx: bi.processRecentTx, + persistTxs: bi.persistBuilderExitTxs, + }, + ) + + bi.matcher = newTransactionMatcher( + indexer, + indexer.Logger.WithField("contract-matcher", "builder_exits"), + &transactionMatcherOptions[builderExitMatch]{ + stateKey: "indexer.builderexitmatcher", + deployBlock: uint64(utils.Config.ExecutionApi.GloasDeployBlock), + timeLimit: 2 * time.Second, + + matchBlockRange: bi.matchBlockRange, + persistMatches: bi.persistMatches, + }, + ) + + go bi.runBuilderExitIndexerLoop() + + return bi +} + +// GetMatcherHeight returns the last processed el block number from the transaction matcher. +func (bi *BuilderExitIndexer) GetMatcherHeight() uint64 { + return bi.matcher.GetMatcherHeight() +} + +// runBuilderExitIndexerLoop is the main loop for the builder exit indexer. +func (bi *BuilderExitIndexer) runBuilderExitIndexerLoop() { + defer utils.HandleSubroutinePanic("BuilderExitIndexer.runBuilderExitIndexerLoop", bi.runBuilderExitIndexerLoop) + + for { + time.Sleep(30 * time.Second) + bi.logger.Debugf("run builder exit indexer logic") + + err := bi.indexer.runContractIndexer() + if err != nil { + bi.logger.Errorf("indexer error: %v", err) + } + + err = bi.matcher.runTransactionMatcher(bi.indexer.state.FinalBlock) + if err != nil { + bi.logger.Errorf("matcher error: %v", err) + } + } +} + +// processFinalTx parses a finalized builder exit log into a request tx. +func (bi *BuilderExitIndexer) processFinalTx(log *types.Log, tx *types.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, _ []*dbtypes.BuilderExitTx) (*dbtypes.BuilderExitTx, error) { + requestTx := bi.parseRequestLog(log) + if requestTx == nil { + return nil, fmt.Errorf("invalid builder exit log") + } + + txTo := *tx.To() + + requestTx.BlockTime = header.Time + requestTx.TxSender = txFrom[:] + requestTx.TxTarget = txTo[:] + requestTx.DequeueBlock = dequeueBlock + + return requestTx, nil +} + +// processRecentTx parses a recent (unfinalized) builder exit log into a request tx. +func (bi *BuilderExitIndexer) processRecentTx(log *types.Log, tx *types.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, fork *execution.ForkWithClients, _ []*dbtypes.BuilderExitTx) (*dbtypes.BuilderExitTx, error) { + requestTx := bi.parseRequestLog(log) + if requestTx == nil { + return nil, fmt.Errorf("invalid builder exit log") + } + + txTo := *tx.To() + + requestTx.BlockTime = header.Time + requestTx.TxSender = txFrom[:] + requestTx.TxTarget = txTo[:] + requestTx.DequeueBlock = dequeueBlock + + clBlock := bi.indexerCtx.BeaconIndexer.GetBlocksByExecutionBlockHash(phase0.Hash32(log.BlockHash)) + if len(clBlock) > 0 { + requestTx.ForkId = uint64(clBlock[0].GetForkId()) + } else { + requestTx.ForkId = uint64(fork.ForkId) + } + + return requestTx, nil +} + +// parseRequestLog parses a builder exit log into a request tx. +func (bi *BuilderExitIndexer) parseRequestLog(log *types.Log) *dbtypes.BuilderExitTx { + // data layout (BuilderExitRequest): + // 0-20: source address (20 bytes) + // 20-68: builder pubkey (48 bytes) + if len(log.Data) < 68 { + bi.logger.Warnf("invalid builder exit log data length: %v", len(log.Data)) + return nil + } + + sourceAddr := log.Data[0:20] + pubkey := log.Data[20:68] + + requestTx := &dbtypes.BuilderExitTx{ + BlockNumber: log.BlockNumber, + BlockIndex: uint64(log.Index), + BlockRoot: log.BlockHash[:], + SourceAddress: sourceAddr, + PublicKey: pubkey, + TxHash: log.TxHash[:], + } + + return requestTx +} + +// persistBuilderExitTxs persists builder exit request txs to the database. +func (bi *BuilderExitIndexer) persistBuilderExitTxs(tx *sqlx.Tx, requests []*dbtypes.BuilderExitTx) error { + requestCount := len(requests) + for requestIdx := 0; requestIdx < requestCount; requestIdx += 500 { + endIdx := requestIdx + 500 + if endIdx > requestCount { + endIdx = requestCount + } + + err := db.InsertBuilderExitTxs(bi.indexerCtx.Ctx, tx, requests[requestIdx:endIdx]) + if err != nil { + return fmt.Errorf("error while inserting builder exit txs: %v", err) + } + } + + return nil +} + +// matchBlockRange matches builder exit requests (CL) with their EL request txs by dequeue block. +func (bi *BuilderExitIndexer) matchBlockRange(fromBlock uint64, toBlock uint64) ([]*builderExitMatch, error) { + requestMatches := []*builderExitMatch{} + + dequeueExitTxs := db.GetBuilderExitTxsByDequeueRange(bi.indexerCtx.Ctx, fromBlock, toBlock) + if len(dequeueExitTxs) > 0 { + firstBlock := dequeueExitTxs[0].DequeueBlock + lastBlock := dequeueExitTxs[len(dequeueExitTxs)-1].DequeueBlock + + for _, builderExit := range db.GetBuilderExitsByElBlockRange(bi.indexerCtx.Ctx, firstBlock, lastBlock) { + if len(builderExit.TxHash) > 0 { + continue + } + + parentForkIds := bi.indexerCtx.BeaconIndexer.GetParentForkIds(beacon.ForkKey(builderExit.ForkId)) + isParentFork := func(forkId uint64) bool { + if forkId == builderExit.ForkId { + return true + } + for _, parentForkId := range parentForkIds { + if uint64(parentForkId) == forkId { + return true + } + } + return false + } + + matchingTxs := []*dbtypes.BuilderExitTx{} + for _, tx := range dequeueExitTxs { + if tx.DequeueBlock == builderExit.BlockNumber && isParentFork(tx.ForkId) { + matchingTxs = append(matchingTxs, tx) + } + } + + if len(matchingTxs) == 0 { + for _, tx := range dequeueExitTxs { + if tx.DequeueBlock == builderExit.BlockNumber { + matchingTxs = append(matchingTxs, tx) + } + } + } + + if len(matchingTxs) < int(builderExit.SlotIndex)+1 { + continue + } + + txHash := matchingTxs[builderExit.SlotIndex].TxHash + bi.logger.Debugf("Matched builder exit %d:%v with tx 0x%x", builderExit.SlotNumber, builderExit.SlotIndex, txHash) + + requestMatches = append(requestMatches, &builderExitMatch{ + slotRoot: builderExit.SlotRoot, + slotIndex: builderExit.SlotIndex, + txHash: txHash, + }) + } + } + + return requestMatches, nil +} + +// persistMatches persists builder exit tx-hash matches to the database. +func (bi *BuilderExitIndexer) persistMatches(tx *sqlx.Tx, matches []*builderExitMatch) error { + for _, match := range matches { + err := db.UpdateBuilderExitTxHash(bi.indexerCtx.Ctx, tx, match.slotRoot, match.slotIndex, match.txHash) + if err != nil { + return err + } + } + + return nil +} diff --git a/services/chainservice.go b/services/chainservice.go index cfce56e95..e9f629ae3 100644 --- a/services/chainservice.go +++ b/services/chainservice.go @@ -33,20 +33,22 @@ import ( ) type ChainService struct { - ctx context.Context - logger logrus.FieldLogger - consensusPool *consensus.Pool - executionPool *execution.Pool - beaconIndexer *beacon.Indexer - validatorNames *ValidatorNames - buildoorInventory *BuildoorInventory - depositIndexer *syscontracts.DepositIndexer - consolidationIndexer *syscontracts.ConsolidationIndexer - withdrawalIndexer *syscontracts.WithdrawalIndexer - mevRelayIndexer *mevrelay.MevIndexer - snooperManager *snooper.SnooperManager - txIndexer *txindexer.TxIndexer - started bool + ctx context.Context + logger logrus.FieldLogger + consensusPool *consensus.Pool + executionPool *execution.Pool + beaconIndexer *beacon.Indexer + validatorNames *ValidatorNames + buildoorInventory *BuildoorInventory + depositIndexer *syscontracts.DepositIndexer + consolidationIndexer *syscontracts.ConsolidationIndexer + withdrawalIndexer *syscontracts.WithdrawalIndexer + builderDepositIndexer *syscontracts.BuilderDepositIndexer + builderExitIndexer *syscontracts.BuilderExitIndexer + mevRelayIndexer *mevrelay.MevIndexer + snooperManager *snooper.SnooperManager + txIndexer *txindexer.TxIndexer + started bool } var GlobalBeaconService *ChainService @@ -336,6 +338,8 @@ func (cs *ChainService) StartService() error { cs.depositIndexer = syscontracts.NewDepositIndexer(executionIndexerCtx) cs.consolidationIndexer = syscontracts.NewConsolidationIndexer(executionIndexerCtx) cs.withdrawalIndexer = syscontracts.NewWithdrawalIndexer(executionIndexerCtx) + cs.builderDepositIndexer = syscontracts.NewBuilderDepositIndexer(executionIndexerCtx) + cs.builderExitIndexer = syscontracts.NewBuilderExitIndexer(executionIndexerCtx) // start EL transaction indexer if enabled if utils.Config.ExecutionIndexer.Enabled { @@ -392,6 +396,14 @@ func (bs *ChainService) GetWithdrawalIndexer() *syscontracts.WithdrawalIndexer { return bs.withdrawalIndexer } +func (bs *ChainService) GetBuilderDepositIndexer() *syscontracts.BuilderDepositIndexer { + return bs.builderDepositIndexer +} + +func (bs *ChainService) GetBuilderExitIndexer() *syscontracts.BuilderExitIndexer { + return bs.builderExitIndexer +} + func (bs *ChainService) GetSnooperManager() *snooper.SnooperManager { return bs.snooperManager } diff --git a/services/chainservice_blocks.go b/services/chainservice_blocks.go index 30636780c..20cf62dfe 100644 --- a/services/chainservice_blocks.go +++ b/services/chainservice_blocks.go @@ -86,15 +86,9 @@ func (bs *ChainService) GetSlotDetailsByBlockroot(ctx context.Context, blockroot header, err = beacon.LoadBeaconHeader(ctx, client, blockroot) if header != nil { break - } else if err != nil { - log := logrus.WithError(err) - if client != nil { - log = log.WithField("client", client.GetClient().GetName()) - } - log.Warnf("Error loading block header for root 0x%x", blockroot) } } - if err != nil || header == nil { + if err != nil && header == nil { return err } return nil @@ -146,11 +140,7 @@ func (bs *ChainService) GetSlotDetailsByBlockroot(ctx context.Context, blockroot if block == nil { block, err = beacon.LoadBeaconBlock(ctx, client, blockroot) if err != nil { - log := logrus.WithError(err) - if client != nil { - log = log.WithField("client", client.GetClient().GetName()) - } - log.Warnf("Error loading block body for root 0x%x", blockroot) + logrus.WithError(err).Debugf("could not load block body for root 0x%x from client %s", blockroot, client.GetClient().GetName()) } } @@ -159,11 +149,7 @@ func (bs *ChainService) GetSlotDetailsByBlockroot(ctx context.Context, blockroot if payload != nil { break } else if err != nil { - log := logrus.WithError(err) - if client != nil { - log = log.WithField("client", client.GetClient().GetName()) - } - log.Warnf("Error loading block payload for root 0x%x", blockroot) + logrus.WithError(err).Debugf("could not load block payload for root 0x%x from client %s", blockroot, client.GetClient().GetName()) } } else if block != nil { break @@ -248,17 +234,11 @@ func (bs *ChainService) GetSlotDetailsBySlot(ctx context.Context, slot phase0.Sl for ; headRetry < 3; headRetry++ { client := clients[headRetry%len(clients)] header, blockRoot, orphaned, err = beacon.LoadBeaconHeaderBySlot(ctx, client, slot) - if err != nil { - log := logrus.WithError(err) - if client != nil { - log = log.WithField("client", client.GetClient().GetName()) - } - log.Warnf("Error loading block header for slot %v", slot) - } else { + if header != nil { break } } - if err != nil || header == nil { + if err != nil && header == nil { return err } return nil @@ -311,11 +291,7 @@ func (bs *ChainService) GetSlotDetailsBySlot(ctx context.Context, slot phase0.Sl client := clients[bodyRetry%len(clients)] block, err = beacon.LoadBeaconBlock(ctx, client, blockRoot) if err != nil { - log := logrus.WithError(err) - if client != nil { - log = log.WithField("client", client.GetClient().GetName()) - } - log.Warnf("Error loading block body for slot %v", slot) + logrus.WithError(err).Debugf("could not load block body for slot %v from client %s", slot, client.GetClient().GetName()) } if block != nil && block.Version >= spec.DataVersionGloas { @@ -323,11 +299,7 @@ func (bs *ChainService) GetSlotDetailsBySlot(ctx context.Context, slot phase0.Sl if payload != nil { break } else if err != nil { - log := logrus.WithError(err) - if client != nil { - log = log.WithField("client", client.GetClient().GetName()) - } - log.Warnf("Error loading block payload for root 0x%x", blockRoot) + logrus.WithError(err).Debugf("could not load block payload for slot %v from client %s", slot, client.GetClient().GetName()) } } else if block != nil { break @@ -505,7 +477,7 @@ func (bs *ChainService) populateBlockAccessList(ctx context.Context, result *Com bal, err := beacon.UnmarshalBlockAccessList(blockData.BalVersion, blockData.BalData) if err != nil { - logrus.WithError(err).Errorf("failed to decode BAL from blockdb for block 0x%x", result.Root[:]) + logrus.WithError(err).Debugf("failed to decode BAL from blockdb for block 0x%x", result.Root[:]) return } result.BlockAccessList = bal @@ -1429,8 +1401,11 @@ func (bs *ChainService) GetHighestElBlockNumber(ctx context.Context, overrideFor if canonicalHead == nil { break } - if canonicalHead.GetBlockIndex(ctx) != nil { - return canonicalHead.GetBlockIndex(ctx).ExecutionNumber + // In Gloas/ePBS the execution payload is decoupled from the beacon block and may not be + // revealed yet for the most recent canonical head(s), in which case ExecutionNumber is 0. + // Walk back to the latest block that actually carries an execution number. + if blockIndex := canonicalHead.GetBlockIndex(ctx); blockIndex != nil && blockIndex.ExecutionNumber > 0 { + return blockIndex.ExecutionNumber } parentRoot := canonicalHead.GetParentRoot() diff --git a/services/chainservice_builder.go b/services/chainservice_builder.go index d7c93f6af..443d79ece 100644 --- a/services/chainservice_builder.go +++ b/services/chainservice_builder.go @@ -251,6 +251,46 @@ func (bs *ChainService) GetBuilderByIndex(index gloas.BuilderIndex) *gloas.Build return bs.beaconIndexer.GetBuilderByIndex(index, nil) } +// GetBuilderIndexByPubkey resolves a builder index from its pubkey via the dedicated +// builder pubkey cache (separate from validators, see GetValidatorIndexByPubkey). +func (bs *ChainService) GetBuilderIndexByPubkey(pubkey phase0.BLSPubKey) (gloas.BuilderIndex, bool) { + return bs.beaconIndexer.GetBuilderIndexByPubkey(pubkey) +} + +// GetActiveBuildersByIndexes batch-resolves the builder currently occupying each of the given +// indexes (cache first, then a single batched DB query for misses). Builder indexes can be +// reused (EIP-8282), so callers compare the returned builder's pubkey against the pubkey they +// expect to tell whether that pubkey still owns the index or was superseded. +func (bs *ChainService) GetActiveBuildersByIndexes(ctx context.Context, indexes []gloas.BuilderIndex) map[gloas.BuilderIndex]*gloas.Builder { + result := make(map[gloas.BuilderIndex]*gloas.Builder, len(indexes)) + missing := make([]uint64, 0) + seenMissing := make(map[uint64]bool) + for _, idx := range indexes { + if _, ok := result[idx]; ok { + continue + } + if builder := bs.beaconIndexer.GetBuilderByIndex(idx, nil); builder != nil { + result[idx] = builder + } else if !seenMissing[uint64(idx)] { + seenMissing[uint64(idx)] = true + missing = append(missing, uint64(idx)) + } + } + + if len(missing) > 0 { + db.StreamBuildersByIndexes(ctx, missing, func(dbBuilder *dbtypes.Builder) bool { + // the active occupant of an index is the non-superseded row + if dbBuilder.Superseded { + return true + } + result[gloas.BuilderIndex(dbBuilder.BuilderIndex)] = beacon.UnwrapDbBuilder(dbBuilder) + return true + }) + } + + return result +} + // GetBuilderBalances returns the current builder balances (epoch-start adjusted for in-epoch withdrawals). func (bs *ChainService) GetBuilderBalances() []phase0.Gwei { return bs.beaconIndexer.GetRecentBuilderBalances(nil) diff --git a/services/chainservice_builder_onboarding.go b/services/chainservice_builder_onboarding.go new file mode 100644 index 000000000..1332da511 --- /dev/null +++ b/services/chainservice_builder_onboarding.go @@ -0,0 +1,279 @@ +package services + +import ( + "context" + "math" + "sort" + "time" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + + "github.com/ethpandaops/dora/dbtypes" +) + +// builderWithdrawalCredType is BUILDER_WITHDRAWAL_PREFIX (Gloas/EIP-8282): the 0x03 withdrawal +// credential prefix that marks a deposit as a builder deposit, onboarded as a builder at the Gloas +// fork transition (onboard_builders_from_pending_deposits). +const builderWithdrawalCredType uint8 = 0x03 + +// isBuilderCredential reports whether the withdrawal credentials use the builder prefix (0x03). +func isBuilderCredential(wc []byte) bool { + return len(wc) > 0 && wc[0] == builderWithdrawalCredType +} + +// projectionFetchCap bounds how many builder-credential deposits the projection enumerates. It is +// far above any realistic count of pre-fork builder deposits; hitting it sets Truncated. +const projectionFetchCap = 10000 + +// ProjectedBuilderDeposit is one builder-credential (0x03) deposit on chain together with its +// projected fate at the upcoming Gloas fork transition. When none of the fate flags is set the +// deposit is projected to be onboarded as a builder (Result distinguishes new vs top-up). +type ProjectedBuilderDeposit struct { + Deposit *dbtypes.DepositWithTx + + // Result is the onboarding outcome (dbtypes.BuilderDepositRequestResult*): NewBuilder, TopUp, or + // InvalidSignature. Left Unknown for deposits that never reach onboarding (too early / kept). + Result uint8 + + // EstimateEpoch is the projected epoch the deposit is processed (0 = unknown). + EstimateEpoch phase0.Epoch + + // Fate flags (at most one set; none set => onboarded as a builder): + TooEarly bool // processed before the fork -> becomes a regular validator, not a builder + AlreadyProcessed bool // refinement of TooEarly: already applied (vs projected to apply before fork) + KeptAsValidator bool // shares a pubkey with a validator -> applied as a validator deposit + InvalidSignature bool // invalid proof-of-possession -> dropped at onboarding + + // Queue cross-check (the queue lags up to an epoch, so recent deposits may be unqueued). + IsQueued bool + QueuePos uint64 +} + +// Onboarded reports whether the deposit is projected to be onboarded as a builder at the fork. +func (d *ProjectedBuilderDeposit) Onboarded() bool { + return !d.TooEarly && !d.KeptAsValidator && !d.InvalidSignature +} + +// BuilderOnboardingProjection is the projected outcome, computed pre-Gloas, of the one-time builder +// onboarding at the Gloas fork transition. Its primary data source is the on-chain builder-credential +// deposits (so deposits made in the epoch before the fork — not yet reflected in the once-per-epoch +// queue snapshot — and already-applied deposits are both covered); the pending deposit queue is used +// only to cross-check queue position and the churn-based processing estimate. It is an estimate: a +// snapshot of current deposits and churn that does not model future deposits. +type BuilderOnboardingProjection struct { + GloasForkEpoch phase0.Epoch + GloasForkTime time.Time + + // Deposits holds every (canonical) builder-credential deposit on chain, in deposit-index order, + // each annotated with its projected fate. + Deposits []*ProjectedBuilderDeposit + + OnboardedNewCount uint64 + OnboardedTopUpCount uint64 + TooEarlyCount uint64 + InvalidSignatureCount uint64 + KeptAsValidatorCount uint64 + + // QueueEstimation is the epoch the current queue fully drains (0 = unknown / empty); and a + // secondary stat: all queued deposits (any credentials) processed before the fork epoch. + QueueEstimation phase0.Epoch + TotalQueueProcessedBeforeFork uint64 + + // HasSafetyEstimate / DepositSafe answer "is it safe to deposit right now". DepositSafe is true + // when a deposit submitted now would still be queued at the fork (and so onboarded as a builder); + // when false the queue is too short and it would be processed as a validator before the fork. + HasSafetyEstimate bool + DepositSafe bool + NewDepositEstimateEpoch phase0.Epoch + NewDepositEstimateTime time.Time + + // Truncated reports that the deposit enumeration hit projectionFetchCap (some deposits omitted). + Truncated bool +} + +// GetBuilderOnboardingProjection projects the builders that would be onboarded at the Gloas fork +// transition from the builder-credential deposits made before the fork. It returns nil when Gloas is +// not scheduled (no finite fork epoch) or the chain head/queue cannot be resolved. It is meant for +// the builder deposits page before the fork, where the real builder_deposits table is still empty. +// +// It enumerates every 0x03-credential deposit and classifies each: deposits already applied or that +// the churn queue would process before the fork become regular validators ("too early"); the rest +// register builders (or top up earlier ones), drop on an invalid proof-of-possession, or stay as +// validator deposits when they share a pubkey with a validator — mirroring +// onboard_builders_from_pending_deposits over the deposits that survive to the fork. +func (bs *ChainService) GetBuilderOnboardingProjection(ctx context.Context) *BuilderOnboardingProjection { + chainState := bs.consensusPool.GetChainState() + specs := chainState.GetSpecs() + if specs == nil || specs.GloasForkEpoch == nil || *specs.GloasForkEpoch == math.MaxUint64 { + return nil + } + gloasForkEpoch := phase0.Epoch(*specs.GloasForkEpoch) + + canonicalHead := bs.beaconIndexer.GetCanonicalHead(nil) + if canonicalHead == nil { + return nil + } + indexedQueue := bs.GetIndexedDepositQueue(ctx, canonicalHead) + if indexedQueue == nil { + return nil + } + + proj := &BuilderOnboardingProjection{ + GloasForkEpoch: gloasForkEpoch, + GloasForkTime: chainState.EpochToTime(gloasForkEpoch), + QueueEstimation: indexedQueue.QueueEstimation, + } + + // remainsAtFork reports whether a queue entry survives process_pending_deposits up to the fork + // epoch (EpochEstimate == 0 means unknown — postponed without estimate or no active balance — + // and is treated as remaining, sitting at the back of the queue). + remainsAtFork := func(epoch phase0.Epoch) bool { + return epoch == 0 || epoch >= gloasForkEpoch + } + + // Queue cross-check map + secondary stats. queueNonBuilderPubkeys collects pubkeys with a pending + // non-builder deposit remaining at the fork, so a same-pubkey builder deposit is kept as a + // validator deposit rather than onboarded. + pendingByIndex := make(map[uint64]*IndexedDepositQueueEntry, len(indexedQueue.Queue)) + queueNonBuilderPubkeys := make(map[phase0.BLSPubKey]bool) + for _, entry := range indexedQueue.Queue { + if entry.DepositIndex != nil { + pendingByIndex[*entry.DepositIndex] = entry + } + remains := remainsAtFork(entry.EpochEstimate) + if !remains { + proj.TotalQueueProcessedBeforeFork++ + } + if remains && !isBuilderCredential(entry.PendingDeposit.WithdrawalCredentials) { + queueNonBuilderPubkeys[entry.PendingDeposit.Pubkey] = true + } + } + + anchorIndex, hasAnchor := uint64(0), indexedQueue.LastIncludedDepositIndex != nil + if hasAnchor { + anchorIndex = *indexedQueue.LastIncludedDepositIndex + } + + // tailEstimate is the projected processing epoch of a deposit appended to the tail right now — + // used both for deposits made after the queue snapshot and for the deposit-safety indicator. + depositAmount := phase0.Gwei(specs.MinActivationBalance) + if depositAmount == 0 { + depositAmount = phase0.Gwei(specs.MaxEffectiveBalance) + } + tailEstimate := indexedQueue.EstimateAppendedDepositEpoch(depositAmount) + + // Primary source: every builder-credential (0x03) deposit on chain (cache + DB merge). The + // credential-type filter lives on the tx filter (both the cache and DB paths apply it there). + depositFilter := &dbtypes.DepositFilter{ + WithOrphaned: 1, + } + txFilter := &dbtypes.DepositTxFilter{ + WithValid: 1, // no signature-validity filter — invalid sigs are a classified outcome + WithdrawalCredTypes: []uint8{builderWithdrawalCredType}, + } + deposits, _ := bs.GetDepositOperationsByFilter(ctx, depositFilter, txFilter, 0, projectionFetchCap) + if len(deposits) >= projectionFetchCap { + proj.Truncated = true + } + + // Process in deposit-index order (= queue / processing order); unresolved-index deposits (very + // recent) sort last. + sort.SliceStable(deposits, func(i, j int) bool { + return depositIndexOrMax(deposits[i]) < depositIndexOrMax(deposits[j]) + }) + + isExistingValidator := func(pubkey phase0.BLSPubKey) bool { + idx, found := bs.beaconIndexer.GetValidatorIndexByPubkey(pubkey) + return found && !bs.IsProjectedValidatorIndex(idx) + } + + acceptedBuilderPubkeys := make(map[phase0.BLSPubKey]bool) // pubkeys onboarded as new builders so far + validatorFromDeposit := make(map[phase0.BLSPubKey]bool) // pubkeys turned into validators by an earlier too-early deposit + for _, dep := range deposits { + if dep.Orphaned || !isBuilderCredential(dep.WithdrawalCredentials) { + continue + } + + var pubkey phase0.BLSPubKey + copy(pubkey[:], dep.PublicKey) + + pd := &ProjectedBuilderDeposit{Deposit: dep} + + var queueEntry *IndexedDepositQueueEntry + if dep.Index != nil { + if qe, ok := pendingByIndex[*dep.Index]; ok { + queueEntry = qe + pd.IsQueued = true + pd.QueuePos = qe.QueuePos + } + } + + // Determine the processing fate: still pending at the fork, already applied, or processed + // before the fork by the churn queue. + var remains bool + switch { + case queueEntry != nil: + pd.EstimateEpoch = queueEntry.EpochEstimate + remains = remainsAtFork(queueEntry.EpochEstimate) + case dep.Index == nil || !hasAnchor || *dep.Index > anchorIndex: + // included after the queue snapshot (recent) — still pending; estimate via the tail. + pd.EstimateEpoch = tailEstimate + remains = remainsAtFork(tailEstimate) + default: + // included by the snapshot but absent from the queue — already applied as a validator. + pd.AlreadyProcessed = true + remains = false + } + + if !remains { + pd.TooEarly = true + proj.TooEarlyCount++ + validatorFromDeposit[pubkey] = true + proj.Deposits = append(proj.Deposits, pd) + continue + } + + // Remains at the fork — onboarding selection (sequential, mirrors onboarding). + validSig := true + if dep.ValidSignature != nil { + validSig = *dep.ValidSignature == 1 || *dep.ValidSignature == 2 + } + + switch { + case acceptedBuilderPubkeys[pubkey]: + pd.Result = dbtypes.BuilderDepositRequestResultTopUp + proj.OnboardedTopUpCount++ + case isExistingValidator(pubkey) || validatorFromDeposit[pubkey] || queueNonBuilderPubkeys[pubkey]: + pd.KeptAsValidator = true + proj.KeptAsValidatorCount++ + case !validSig: + pd.InvalidSignature = true + pd.Result = dbtypes.BuilderDepositRequestResultInvalidSignature + proj.InvalidSignatureCount++ + default: + pd.Result = dbtypes.BuilderDepositRequestResultNewBuilder + acceptedBuilderPubkeys[pubkey] = true + proj.OnboardedNewCount++ + } + + proj.Deposits = append(proj.Deposits, pd) + } + + if tailEstimate > 0 { + proj.HasSafetyEstimate = true + proj.NewDepositEstimateEpoch = tailEstimate + proj.NewDepositEstimateTime = chainState.EpochToTime(tailEstimate) + proj.DepositSafe = tailEstimate >= gloasForkEpoch + } + + return proj +} + +// depositIndexOrMax returns the deposit's EL index, or MaxUint64 when it is unresolved (so such +// deposits sort to the end). +func depositIndexOrMax(dep *dbtypes.DepositWithTx) uint64 { + if dep.Index != nil { + return *dep.Index + } + return math.MaxUint64 +} diff --git a/services/chainservice_builder_requests.go b/services/chainservice_builder_requests.go new file mode 100644 index 000000000..58cc2d141 --- /dev/null +++ b/services/chainservice_builder_requests.go @@ -0,0 +1,453 @@ +package services + +import ( + "bytes" + "context" + + "github.com/ethpandaops/dora/db" + "github.com/ethpandaops/dora/dbtypes" + "github.com/ethpandaops/dora/indexer/beacon" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/prysmaticlabs/prysm/v5/container/slice" + "github.com/sirupsen/logrus" +) + +// CombinedBuilderDeposit pairs a consensus-layer builder deposit request with its +// matching execution-layer request tx (either may be nil when only one side is known). +type CombinedBuilderDeposit struct { + Request *dbtypes.BuilderDeposit + RequestOrphaned bool + Transaction *dbtypes.BuilderDepositTx + TransactionOrphaned bool +} + +// CombinedBuilderExit pairs a consensus-layer builder exit request with its +// matching execution-layer request tx (either may be nil when only one side is known). +type CombinedBuilderExit struct { + Request *dbtypes.BuilderExit + RequestOrphaned bool + Transaction *dbtypes.BuilderExitTx + TransactionOrphaned bool +} + +// GetBuilderDepositsByFilter returns builder deposit requests merged from the +// pending EL request txs (not yet dequeued) and the included CL requests +// (cache + DB), each paired with its matching request tx. +func (bs *ChainService) GetBuilderDepositsByFilter(ctx context.Context, filter *dbtypes.BuilderDepositFilter, pageOffset uint64, pageSize uint32) ([]*CombinedBuilderDeposit, uint64, uint64) { + totalPendingTxResults := uint64(0) + totalReqResults := uint64(0) + + combinedResults := make([]*CombinedBuilderDeposit, 0) + canonicalForkIds := bs.GetCanonicalForkIds() + + // pending EL request txs that have not been dequeued (and therefore not included) yet + initiatedFilter := &dbtypes.BuilderDepositTxFilter{ + MinDequeue: bs.GetHighestElBlockNumber(ctx, nil) + 1, + PublicKey: filter.PublicKey, + MinIndex: filter.MinIndex, + MaxIndex: filter.MaxIndex, + MinAmount: filter.MinAmount, + MaxAmount: filter.MaxAmount, + } + + dbTransactions, totalDbTransactions, _ := db.GetBuilderDepositTxsFiltered(ctx, pageOffset, pageSize, initiatedFilter) + totalPendingTxResults = totalDbTransactions + + for _, deposit := range dbTransactions { + combinedResults = append(combinedResults, &CombinedBuilderDeposit{ + Transaction: deposit, + TransactionOrphaned: !bs.isCanonicalForkId(deposit.ForkId, canonicalForkIds), + }) + } + + requestTxDetailsFor := [][]byte{} + page2Offset := uint64(0) + if pageOffset > totalPendingTxResults { + page2Offset = pageOffset - totalPendingTxResults + } + + dbOperations, totalReqResults := bs.getBuilderDepositOperationsByFilter(ctx, filter, page2Offset, pageSize) + + for _, dbOperation := range dbOperations { + if len(combinedResults) >= int(pageSize) { + break + } + + combinedResult := &CombinedBuilderDeposit{ + Request: dbOperation, + RequestOrphaned: !bs.isCanonicalForkId(dbOperation.ForkId, canonicalForkIds), + } + + if len(dbOperation.TxHash) > 0 { + requestTxDetailsFor = append(requestTxDetailsFor, dbOperation.TxHash) + } else if matcherHeight := bs.GetBuilderDepositIndexer().GetMatcherHeight(); dbOperation.BlockNumber > matcherHeight { + combinedResult.Transaction, combinedResult.TransactionOrphaned = bs.matchBuilderDepositTxOnTheFly(ctx, dbOperation, canonicalForkIds) + } + + combinedResults = append(combinedResults, combinedResult) + } + + if len(requestTxDetailsFor) > 0 { + for _, txDetails := range db.GetBuilderDepositTxsByTxHashes(ctx, requestTxDetailsFor) { + for _, combinedResult := range combinedResults { + if combinedResult.Request != nil && bytes.Equal(combinedResult.Request.TxHash, txDetails.TxHash) { + combinedResult.Transaction = txDetails + combinedResult.TransactionOrphaned = !bs.isCanonicalForkId(txDetails.ForkId, canonicalForkIds) + } + } + } + } + + return combinedResults, totalPendingTxResults, totalReqResults +} + +func (bs *ChainService) matchBuilderDepositTxOnTheFly(ctx context.Context, dbOperation *dbtypes.BuilderDeposit, canonicalForkIds []uint64) (*dbtypes.BuilderDepositTx, bool) { + requestTxs := db.GetBuilderDepositTxsByDequeueRange(ctx, dbOperation.BlockNumber, dbOperation.BlockNumber) + if len(requestTxs) == 1 { + return requestTxs[0], !bs.isCanonicalForkId(requestTxs[0].ForkId, canonicalForkIds) + } + if len(requestTxs) > 1 { + forkIds := bs.GetParentForkIds(beacon.ForkKey(dbOperation.ForkId)) + isParentFork := func(forkId uint64) bool { + for _, parentForkId := range forkIds { + if uint64(parentForkId) == forkId { + return true + } + } + return false + } + + matchingTxs := []*dbtypes.BuilderDepositTx{} + for _, tx := range requestTxs { + if isParentFork(tx.ForkId) { + matchingTxs = append(matchingTxs, tx) + } + } + + if len(matchingTxs) >= int(dbOperation.SlotIndex)+1 { + tx := matchingTxs[dbOperation.SlotIndex] + return tx, !bs.isCanonicalForkId(tx.ForkId, canonicalForkIds) + } + } + + return nil, false +} + +func (bs *ChainService) getBuilderDepositOperationsByFilter(ctx context.Context, filter *dbtypes.BuilderDepositFilter, pageOffset uint64, pageSize uint32) ([]*dbtypes.BuilderDeposit, uint64) { + chainState := bs.consensusPool.GetChainState() + _, prunedEpoch := bs.beaconIndexer.GetBlockCacheState() + idxMinSlot := chainState.EpochToSlot(prunedEpoch) + currentSlot := chainState.CurrentSlot() + + canonicalForkIds := bs.GetCanonicalForkIds() + + // load most recent objects from indexer cache + cachedMatches := make([]*dbtypes.BuilderDeposit, 0) + for slotIdx := int64(currentSlot); slotIdx >= int64(idxMinSlot); slotIdx-- { + slot := uint64(slotIdx) + blocks := bs.beaconIndexer.GetBlocksBySlot(phase0.Slot(slot)) + for _, block := range blocks { + isCanonical := bs.isCanonicalForkId(uint64(block.GetForkId()), canonicalForkIds) + if !includeByOrphanedFilter(filter.WithOrphaned, isCanonical) { + continue + } + if filter.MinSlot > 0 && slot < filter.MinSlot { + continue + } + if filter.MaxSlot > 0 && slot > filter.MaxSlot { + continue + } + + deposits := block.GetDbBuilderDeposits(bs.beaconIndexer, isCanonical) + slice.Reverse(deposits) // reverse as other datasources are ordered by descending block index too + for idx, deposit := range deposits { + if !builderDepositMatchesFilter(deposit, filter) { + continue + } + cachedMatches = append(cachedMatches, deposits[idx]) + } + } + } + + resObjs, cachedMatchesLen, resIdx := paginateCachedBuilderDeposits(cachedMatches, pageOffset, pageSize) + + var dbObjects []*dbtypes.BuilderDeposit + var dbCount uint64 + var err error + + cachedEnd := pageOffset + uint64(pageSize) + if cachedEnd <= cachedMatchesLen { + _, dbCount, err = db.GetBuilderDepositsFiltered(ctx, 0, 1, canonicalForkIds, filter) + } else { + dbSliceStart := uint64(0) + if pageOffset > cachedMatchesLen { + dbSliceStart = pageOffset - cachedMatchesLen + } + dbSliceLimit := pageSize - uint32(resIdx) + dbObjects, dbCount, err = db.GetBuilderDepositsFiltered(ctx, dbSliceStart, dbSliceLimit, canonicalForkIds, filter) + } + + if err != nil { + logrus.Warnf("ChainService.getBuilderDepositOperationsByFilter error: %v", err) + } else { + for idx, dbObject := range dbObjects { + dbObjects[idx].Orphaned = !bs.isCanonicalForkId(dbObject.ForkId, canonicalForkIds) + if !includeByOrphanedFilter(filter.WithOrphaned, !dbObjects[idx].Orphaned) { + continue + } + resObjs = append(resObjs, dbObjects[idx]) + } + } + + return resObjs, cachedMatchesLen + dbCount +} + +// GetBuilderExitsByFilter returns builder exit requests merged from the pending EL +// request txs (not yet dequeued) and the included CL requests (cache + DB), each +// paired with its matching request tx. +func (bs *ChainService) GetBuilderExitsByFilter(ctx context.Context, filter *dbtypes.BuilderExitFilter, pageOffset uint64, pageSize uint32) ([]*CombinedBuilderExit, uint64, uint64) { + totalPendingTxResults := uint64(0) + + combinedResults := make([]*CombinedBuilderExit, 0) + canonicalForkIds := bs.GetCanonicalForkIds() + + initiatedFilter := &dbtypes.BuilderExitTxFilter{ + MinDequeue: bs.GetHighestElBlockNumber(ctx, nil) + 1, + PublicKey: filter.PublicKey, + SourceAddress: filter.SourceAddress, + MinIndex: filter.MinIndex, + MaxIndex: filter.MaxIndex, + } + + dbTransactions, totalDbTransactions, _ := db.GetBuilderExitTxsFiltered(ctx, pageOffset, pageSize, initiatedFilter) + totalPendingTxResults = totalDbTransactions + + for _, exit := range dbTransactions { + combinedResults = append(combinedResults, &CombinedBuilderExit{ + Transaction: exit, + TransactionOrphaned: !bs.isCanonicalForkId(exit.ForkId, canonicalForkIds), + }) + } + + requestTxDetailsFor := [][]byte{} + page2Offset := uint64(0) + if pageOffset > totalPendingTxResults { + page2Offset = pageOffset - totalPendingTxResults + } + + dbOperations, totalReqResults := bs.getBuilderExitOperationsByFilter(ctx, filter, page2Offset, pageSize) + + for _, dbOperation := range dbOperations { + if len(combinedResults) >= int(pageSize) { + break + } + + combinedResult := &CombinedBuilderExit{ + Request: dbOperation, + RequestOrphaned: !bs.isCanonicalForkId(dbOperation.ForkId, canonicalForkIds), + } + + if len(dbOperation.TxHash) > 0 { + requestTxDetailsFor = append(requestTxDetailsFor, dbOperation.TxHash) + } else if matcherHeight := bs.GetBuilderExitIndexer().GetMatcherHeight(); dbOperation.BlockNumber > matcherHeight { + combinedResult.Transaction, combinedResult.TransactionOrphaned = bs.matchBuilderExitTxOnTheFly(ctx, dbOperation, canonicalForkIds) + } + + combinedResults = append(combinedResults, combinedResult) + } + + if len(requestTxDetailsFor) > 0 { + for _, txDetails := range db.GetBuilderExitTxsByTxHashes(ctx, requestTxDetailsFor) { + for _, combinedResult := range combinedResults { + if combinedResult.Request != nil && bytes.Equal(combinedResult.Request.TxHash, txDetails.TxHash) { + combinedResult.Transaction = txDetails + combinedResult.TransactionOrphaned = !bs.isCanonicalForkId(txDetails.ForkId, canonicalForkIds) + } + } + } + } + + return combinedResults, totalPendingTxResults, totalReqResults +} + +func (bs *ChainService) matchBuilderExitTxOnTheFly(ctx context.Context, dbOperation *dbtypes.BuilderExit, canonicalForkIds []uint64) (*dbtypes.BuilderExitTx, bool) { + requestTxs := db.GetBuilderExitTxsByDequeueRange(ctx, dbOperation.BlockNumber, dbOperation.BlockNumber) + if len(requestTxs) == 1 { + return requestTxs[0], !bs.isCanonicalForkId(requestTxs[0].ForkId, canonicalForkIds) + } + if len(requestTxs) > 1 { + forkIds := bs.GetParentForkIds(beacon.ForkKey(dbOperation.ForkId)) + isParentFork := func(forkId uint64) bool { + for _, parentForkId := range forkIds { + if uint64(parentForkId) == forkId { + return true + } + } + return false + } + + matchingTxs := []*dbtypes.BuilderExitTx{} + for _, tx := range requestTxs { + if isParentFork(tx.ForkId) { + matchingTxs = append(matchingTxs, tx) + } + } + + if len(matchingTxs) >= int(dbOperation.SlotIndex)+1 { + tx := matchingTxs[dbOperation.SlotIndex] + return tx, !bs.isCanonicalForkId(tx.ForkId, canonicalForkIds) + } + } + + return nil, false +} + +func (bs *ChainService) getBuilderExitOperationsByFilter(ctx context.Context, filter *dbtypes.BuilderExitFilter, pageOffset uint64, pageSize uint32) ([]*dbtypes.BuilderExit, uint64) { + chainState := bs.consensusPool.GetChainState() + _, prunedEpoch := bs.beaconIndexer.GetBlockCacheState() + idxMinSlot := chainState.EpochToSlot(prunedEpoch) + currentSlot := chainState.CurrentSlot() + + canonicalForkIds := bs.GetCanonicalForkIds() + + cachedMatches := make([]*dbtypes.BuilderExit, 0) + for slotIdx := int64(currentSlot); slotIdx >= int64(idxMinSlot); slotIdx-- { + slot := uint64(slotIdx) + blocks := bs.beaconIndexer.GetBlocksBySlot(phase0.Slot(slot)) + for _, block := range blocks { + isCanonical := bs.isCanonicalForkId(uint64(block.GetForkId()), canonicalForkIds) + if !includeByOrphanedFilter(filter.WithOrphaned, isCanonical) { + continue + } + if filter.MinSlot > 0 && slot < filter.MinSlot { + continue + } + if filter.MaxSlot > 0 && slot > filter.MaxSlot { + continue + } + + exits := block.GetDbBuilderExits(bs.beaconIndexer, isCanonical) + slice.Reverse(exits) + for idx, exit := range exits { + if !builderExitMatchesFilter(exit, filter) { + continue + } + cachedMatches = append(cachedMatches, exits[idx]) + } + } + } + + resObjs, cachedMatchesLen, resIdx := paginateCachedBuilderExits(cachedMatches, pageOffset, pageSize) + + var dbObjects []*dbtypes.BuilderExit + var dbCount uint64 + var err error + + cachedEnd := pageOffset + uint64(pageSize) + if cachedEnd <= cachedMatchesLen { + _, dbCount, err = db.GetBuilderExitsFiltered(ctx, 0, 1, canonicalForkIds, filter) + } else { + dbSliceStart := uint64(0) + if pageOffset > cachedMatchesLen { + dbSliceStart = pageOffset - cachedMatchesLen + } + dbSliceLimit := pageSize - uint32(resIdx) + dbObjects, dbCount, err = db.GetBuilderExitsFiltered(ctx, dbSliceStart, dbSliceLimit, canonicalForkIds, filter) + } + + if err != nil { + logrus.Warnf("ChainService.getBuilderExitOperationsByFilter error: %v", err) + } else { + for idx, dbObject := range dbObjects { + dbObjects[idx].Orphaned = !bs.isCanonicalForkId(dbObject.ForkId, canonicalForkIds) + if !includeByOrphanedFilter(filter.WithOrphaned, !dbObjects[idx].Orphaned) { + continue + } + resObjs = append(resObjs, dbObjects[idx]) + } + } + + return resObjs, cachedMatchesLen + dbCount +} + +// includeByOrphanedFilter reports whether an object should be included given the +// WithOrphaned filter mode (0: canonical only, 1: all, 2: orphaned only). +func includeByOrphanedFilter(withOrphaned uint8, isCanonical bool) bool { + switch withOrphaned { + case 0: + return isCanonical + case 2: + return !isCanonical + default: + return true + } +} + +func builderDepositMatchesFilter(deposit *dbtypes.BuilderDeposit, filter *dbtypes.BuilderDepositFilter) bool { + if len(filter.PublicKey) > 0 && !bytes.Equal(deposit.PublicKey, filter.PublicKey) { + return false + } + if filter.MinIndex > 0 && (deposit.BuilderIndex == nil || *deposit.BuilderIndex < filter.MinIndex) { + return false + } + if filter.MaxIndex > 0 && (deposit.BuilderIndex == nil || *deposit.BuilderIndex > filter.MaxIndex) { + return false + } + if filter.MinAmount != nil && deposit.Amount < *filter.MinAmount { + return false + } + if filter.MaxAmount != nil && deposit.Amount > *filter.MaxAmount { + return false + } + return true +} + +func builderExitMatchesFilter(exit *dbtypes.BuilderExit, filter *dbtypes.BuilderExitFilter) bool { + if len(filter.PublicKey) > 0 && !bytes.Equal(exit.PublicKey, filter.PublicKey) { + return false + } + if len(filter.SourceAddress) > 0 && !bytes.Equal(exit.SourceAddress, filter.SourceAddress) { + return false + } + if filter.MinIndex > 0 && (exit.BuilderIndex == nil || *exit.BuilderIndex < filter.MinIndex) { + return false + } + if filter.MaxIndex > 0 && (exit.BuilderIndex == nil || *exit.BuilderIndex > filter.MaxIndex) { + return false + } + return true +} + +func paginateCachedBuilderDeposits(cachedMatches []*dbtypes.BuilderDeposit, pageOffset uint64, pageSize uint32) ([]*dbtypes.BuilderDeposit, uint64, int) { + resObjs := make([]*dbtypes.BuilderDeposit, 0) + resIdx := 0 + cachedMatchesLen := uint64(len(cachedMatches)) + cachedEnd := pageOffset + uint64(pageSize) + + if cachedEnd <= cachedMatchesLen { + resObjs = append(resObjs, cachedMatches[pageOffset:cachedEnd]...) + resIdx += int(cachedEnd - pageOffset) + } else if pageOffset < cachedMatchesLen { + resObjs = append(resObjs, cachedMatches[pageOffset:]...) + resIdx += len(cachedMatches) - int(pageOffset) + } + + return resObjs, cachedMatchesLen, resIdx +} + +func paginateCachedBuilderExits(cachedMatches []*dbtypes.BuilderExit, pageOffset uint64, pageSize uint32) ([]*dbtypes.BuilderExit, uint64, int) { + resObjs := make([]*dbtypes.BuilderExit, 0) + resIdx := 0 + cachedMatchesLen := uint64(len(cachedMatches)) + cachedEnd := pageOffset + uint64(pageSize) + + if cachedEnd <= cachedMatchesLen { + resObjs = append(resObjs, cachedMatches[pageOffset:cachedEnd]...) + resIdx += int(cachedEnd - pageOffset) + } else if pageOffset < cachedMatchesLen { + resObjs = append(resObjs, cachedMatches[pageOffset:]...) + resIdx += len(cachedMatches) - int(pageOffset) + } + + return resObjs, cachedMatchesLen, resIdx +} diff --git a/services/chainservice_deposits.go b/services/chainservice_deposits.go index 596921697..3e7ec4a44 100644 --- a/services/chainservice_deposits.go +++ b/services/chainservice_deposits.go @@ -405,6 +405,45 @@ type IndexedDepositQueue struct { TotalNew uint64 TotalGwei phase0.Gwei QueueEstimation phase0.Epoch + + // LastIncludedDepositIndex is the EL index of the most recent deposit included on chain as of + // the queue snapshot (nil if unknown). Deposits with a higher index were included after the + // snapshot (e.g. in the current epoch) and are not yet reflected in the queue; deposits with a + // lower index that are absent from the queue have already been applied. + LastIncludedDepositIndex *uint64 + + // churn-simulation residual, captured after estimating every queued deposit, so a + // hypothetical deposit appended to the tail can be projected (see EstimateAppendedDepositEpoch). + churnEpoch phase0.Epoch + churnBalance phase0.Gwei + churnEpochCount uint64 + activationExitChurnLimit phase0.Gwei + maxPendingDepositsPerEpoch uint64 + totalActiveBalance phase0.Gwei +} + +// EstimateAppendedDepositEpoch projects the epoch in which a deposit of the given amount would be +// processed by process_pending_deposits if it were appended to the tail of the queue right now. It +// continues the same churn simulation used for the existing entries from its residual state. It +// returns 0 (unknown) when the churn parameters are unavailable (e.g. no active balance yet). +func (q *IndexedDepositQueue) EstimateAppendedDepositEpoch(amount phase0.Gwei) phase0.Epoch { + if q.totalActiveBalance == 0 || q.activationExitChurnLimit == 0 { + return 0 + } + + queueEpoch := q.churnEpoch + queueBalance := q.churnBalance + + if q.maxPendingDepositsPerEpoch > 0 && q.churnEpochCount >= q.maxPendingDepositsPerEpoch { + queueEpoch++ + queueBalance = q.activationExitChurnLimit + } + for queueBalance < amount { + queueEpoch++ + queueBalance += q.activationExitChurnLimit + } + + return queueEpoch } func (bs *ChainService) GetIndexedDepositQueue(ctx context.Context, headBlock *beacon.Block) *IndexedDepositQueue { @@ -423,23 +462,30 @@ func (bs *ChainService) GetIndexedDepositQueue(ctx context.Context, headBlock *b PendingDeposit: queue[idx], } } - if len(queue) == 0 { - return indexedQueue + // The anchor (most recent included deposit) is resolved unconditionally — even for an empty + // queue — so callers can tell already-applied deposits from deposits included after the snapshot. + lastIncludedDeposit := bs.getRecentIncludedDeposits(ctx, queueBlockRoot) + if lastIncludedDeposit != nil && lastIncludedDeposit.Index != nil { + anchorIndex := *lastIncludedDeposit.Index + indexedQueue.LastIncludedDepositIndex = &anchorIndex } - // Assign EL deposit indexes by position, flagging postponed (reordered) entries - // that must instead be resolved by slot. - lastIncludedDeposit, includedPubkeyByIndex := bs.getRecentIncludedDeposits(ctx, queueBlockRoot) - indexes, postponed := resolveQueueDepositIndexes(queue, lastIncludedDeposit, includedPubkeyByIndex) - for idx := range indexedQueue.Queue { - indexedQueue.Queue[idx].DepositIndex = indexes[idx] - } + // The churn-simulation residual below is still populated for an empty queue (so the + // safety estimate for a newly appended deposit works), hence no early return here. + if len(queue) > 0 { + // Assign EL deposit indexes by position, flagging postponed (reordered) entries + // that must instead be resolved by slot. + indexes, postponed := resolveQueueDepositIndexes(queue, lastIncludedDeposit) + for idx := range indexedQueue.Queue { + indexedQueue.Queue[idx].DepositIndex = indexes[idx] + } - // Resolve postponed entries by slot (one batched query) and flag them for rendering. - bs.resolvePostponedDepositIndexes(ctx, queue, indexedQueue.Queue, postponed) - for idx := range indexedQueue.Queue { - if postponed[idx] { - indexedQueue.Queue[idx].Postponed = true + // Resolve postponed entries by slot (one batched query) and flag them for rendering. + bs.resolvePostponedDepositIndexes(ctx, queue, indexedQueue.Queue, postponed) + for idx := range indexedQueue.Queue { + if postponed[idx] { + indexedQueue.Queue[idx].Postponed = true + } } } @@ -558,6 +604,15 @@ func (bs *ChainService) GetIndexedDepositQueue(ctx context.Context, headBlock *b indexedQueue.QueueEstimation = queueEpoch } + // Retain the churn-simulation residual so a hypothetical deposit appended to the tail can be + // projected (EstimateAppendedDepositEpoch) — used by the pre-Gloas builder onboarding safety check. + indexedQueue.churnEpoch = queueEpoch + indexedQueue.churnBalance = queueBalance + indexedQueue.churnEpochCount = currentEpochCount + indexedQueue.activationExitChurnLimit = activationExitChurnLimit + indexedQueue.maxPendingDepositsPerEpoch = maxPendingDepositsPerEpoch + indexedQueue.totalActiveBalance = totalActiveBalance + return indexedQueue } @@ -566,7 +621,7 @@ func (bs *ChainService) GetIndexedDepositQueue(ctx context.Context, headBlock *b // has no concrete withdrawable epoch yet. func (bs *ChainService) postponedDepositEpoch(pubkey phase0.BLSPubKey) phase0.Epoch { validatorIdx, found := bs.beaconIndexer.GetValidatorIndexByPubkey(pubkey) - if !found || uint64(validatorIdx)&BuilderIndexFlag != 0 { + if !found { return 0 } validator := bs.GetValidatorByIndex(validatorIdx, false) @@ -593,12 +648,12 @@ func (bs *ChainService) postponedDepositEpoch(pubkey phase0.BLSPubKey) phase0.Ep // individually by slot afterwards. Detection is purely slot-based (independent of the // possibly-delayed validator set) so the assigned index is always correct. // -// Matching in the backward count is by deposit index AND pubkey: post-Gloas builder -// (0x03) deposits get an EL deposit index but never enter the queue, leaving gaps; and -// 0x01->0x02 compounding-switch deposits are synthesized with no EL deposit at all. -// Synthetic ones are skipped entirely, and for real ones index gaps are skipped until the -// pubkey at the candidate index matches the queue entry. -func resolveQueueDepositIndexes(queue []*electra.PendingDeposit, anchor *dbtypes.Deposit, includedPubkeyByIndex map[uint64]phase0.BLSPubKey) (indexes []*uint64, postponed []bool) { +// 0x01->0x02 compounding-switch deposits are synthesized in the queue with no EL deposit +// at all; they are skipped entirely. Every other queue entry maps to a real EL deposit in +// contiguous index order (post-Gloas builder deposits arrive via the separate builder +// deposit contract and never appear in the regular deposit stream), so each non-postponed +// entry simply takes the next index below the anchor. +func resolveQueueDepositIndexes(queue []*electra.PendingDeposit, anchor *dbtypes.Deposit) (indexes []*uint64, postponed []bool) { indexes = make([]*uint64, len(queue)) postponed = make([]bool, len(queue)) @@ -627,19 +682,6 @@ func resolveQueueDepositIndexes(queue []*electra.PendingDeposit, anchor *dbtypes if candidate < 0 { break // ran out of indexes; leave earlier entries unindexed } - // Skip indexes that belong to deposits not present in the queue (builder - // gaps). A known pubkey that does not match means this index is a gap. - for candidate >= 0 { - pubkey, known := includedPubkeyByIndex[uint64(candidate)] - if known && pubkey != deposit.Pubkey { - candidate-- - continue - } - break // matches, or unknown (beyond cache) -> accept and fall back to contiguous - } - if candidate < 0 { - break - } if tailRegularIdx < 0 { tailRegularIdx = idx } @@ -745,21 +787,17 @@ func isSyntheticPendingDeposit(deposit *electra.PendingDeposit) bool { return true } -// getRecentIncludedDeposits returns the most recent queue-eligible included deposit — -// the anchor used to align EL deposit indexes to the pending_deposits queue — together -// with a map of EL deposit index -> pubkey for the recent included deposits. +// getRecentIncludedDeposits returns the most recent included deposit — the anchor used to +// align EL deposit indexes to the pending_deposits queue. // -// The map intentionally includes ALL included deposits, even post-Gloas builder (0x03) -// deposits that are onboarded as builders and never enter the queue, so callers can -// detect the index gaps those deposits leave and match queue entries across them by -// pubkey. The anchor, by contrast, is the last deposit that actually enters the queue -// (builder deposits are excluded only once Gloas is active). -func (bs *ChainService) getRecentIncludedDeposits(ctx context.Context, headRoot phase0.Root) (*dbtypes.Deposit, map[uint64]phase0.BLSPubKey) { - indexPubkeys := make(map[uint64]phase0.BLSPubKey) - +// Post-Gloas (EIP-8282) builder deposits arrive via the dedicated builder deposit contract +// and never appear in the regular deposit stream, so every included regular deposit (any +// credential type, including 0x03) enters the pending_deposits queue and is a valid anchor; +// the EL deposit index sequence stays contiguous with the queue. +func (bs *ChainService) getRecentIncludedDeposits(ctx context.Context, headRoot phase0.Root) *dbtypes.Deposit { headBlock := bs.beaconIndexer.GetBlockByRoot(headRoot) if headBlock == nil { - return nil, indexPubkeys + return nil } canonicalForkIds := bs.beaconIndexer.GetParentForkIds(headBlock.GetForkId()) @@ -813,17 +851,9 @@ func (bs *ChainService) getRecentIncludedDeposits(ctx context.Context, headRoot deposits = block.GetDbDeposits(bs.beaconIndexer, nil, isCanonical) } - // Builder deposits (0x03) enter the queue before Gloas (they become - // validators) but are onboarded as builders once Gloas is active and then - // skip the queue, leaving gaps in the index sequence. - skipBuilderDeposits := chainState.IsEip7732Enabled(chainState.EpochOfSlot(block.Slot)) + // Every included regular deposit enters the queue and is a valid anchor; the + // most recent one wins. for _, deposit := range deposits { - if deposit.Index != nil { - indexPubkeys[*deposit.Index] = phase0.BLSPubKey(deposit.PublicKey) - } - if skipBuilderDeposits && len(deposit.WithdrawalCredentials) > 0 && deposit.WithdrawalCredentials[0] == 0x03 { - continue // onboarded as a builder; not in the queue, not a valid anchor - } lastQueued = deposit } } @@ -841,7 +871,7 @@ func (bs *ChainService) getRecentIncludedDeposits(ctx context.Context, headRoot } } - return lastQueued, indexPubkeys + return lastQueued } type QueuedDepositFilter struct { diff --git a/services/chainservice_deposits_test.go b/services/chainservice_deposits_test.go new file mode 100644 index 000000000..89288e8a8 --- /dev/null +++ b/services/chainservice_deposits_test.go @@ -0,0 +1,309 @@ +package services + +import ( + "testing" + + "github.com/ethpandaops/dora/dbtypes" + "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/phase0" +) + +// regularDeposit builds a non-synthetic pending deposit at the given slot. +func regularDeposit(slot phase0.Slot, pubkeyByte byte) *electra.PendingDeposit { + d := &electra.PendingDeposit{Slot: slot} + d.Pubkey[0] = pubkeyByte + // zero signature -> signature[0] == 0x00 != 0xc0, so not synthetic + return d +} + +// syntheticDeposit builds a synthesized (0x01->0x02 compounding switch) pending deposit +// carrying the G2 point-at-infinity signature. +func syntheticDeposit(slot phase0.Slot, pubkeyByte byte) *electra.PendingDeposit { + d := &electra.PendingDeposit{Slot: slot} + d.Pubkey[0] = pubkeyByte + d.Signature[0] = 0xc0 + return d +} + +func anchorAt(index, slot uint64) *dbtypes.Deposit { + idx := index + return &dbtypes.Deposit{Index: &idx, SlotNumber: slot} +} + +func u64p(v uint64) *uint64 { return &v } + +// queueRole describes the intended role of a queue entry when building realistic cases. +type queueRole int + +const ( + roleRegular queueRole = iota // a normal in-order deposit backed by an EL deposit index + roleSynthetic // a 0x01->0x02 compounding switch (no EL deposit) + rolePostponed // a deposit reordered to the tail (slot dips below the running max) +) + +// buildQueue assembles a pending-deposit queue from a role list and returns it together with +// the per-entry roles. Regular entries get strictly increasing slots; postponed entries get a +// low slot so the resolver's forward pass detects the dip; synthetic entries carry the +// point-at-infinity signature and are slot-agnostic. +func buildQueue(roles []queueRole) ([]*electra.PendingDeposit, []queueRole) { + queue := make([]*electra.PendingDeposit, len(roles)) + var nextRegularSlot phase0.Slot = 1000 + for i, role := range roles { + pubkey := byte(i % 251) + switch role { + case roleSynthetic: + queue[i] = syntheticDeposit(nextRegularSlot, pubkey) + case rolePostponed: + // far below any regular slot so it always dips below the running max + queue[i] = regularDeposit(10+phase0.Slot(i), pubkey) + default: + queue[i] = regularDeposit(nextRegularSlot, pubkey) + nextRegularSlot++ + } + } + return queue, roles +} + +// expectedFor computes the expected (indexes, postponed) for a role list given the anchor +// index, assigning contiguous EL deposit indexes to the in-order regular entries (the last +// regular aligns with the anchor) and leaving synthetic/postponed entries unindexed. It is +// derived directly from the roles, independent of the resolver's internals. +func expectedFor(roles []queueRole, anchorIndex uint64) (indexes []*uint64, postponed []bool) { + indexes = make([]*uint64, len(roles)) + postponed = make([]bool, len(roles)) + + regularPositions := make([]int, 0, len(roles)) + for i, role := range roles { + switch role { + case roleRegular: + regularPositions = append(regularPositions, i) + case rolePostponed: + postponed[i] = true + } + } + + cand := int64(anchorIndex) + for k := len(regularPositions) - 1; k >= 0; k-- { + v := uint64(cand) + indexes[regularPositions[k]] = &v + cand-- + } + return indexes, postponed +} + +func TestResolveQueueDepositIndexes(t *testing.T) { + tests := []struct { + name string + queue []*electra.PendingDeposit + anchor *dbtypes.Deposit + wantIndexes []*uint64 + wantPostponed []bool + }{ + { + name: "contiguous regular deposits", + queue: []*electra.PendingDeposit{regularDeposit(10, 1), regularDeposit(11, 2), regularDeposit(12, 3)}, + anchor: anchorAt(5, 12), + wantIndexes: []*uint64{u64p(3), u64p(4), u64p(5)}, + wantPostponed: []bool{false, false, false}, + }, + { + name: "postponed entry dips below running max slot", + queue: []*electra.PendingDeposit{regularDeposit(10, 1), regularDeposit(11, 2), regularDeposit(9, 3)}, + anchor: anchorAt(5, 11), + wantIndexes: []*uint64{u64p(4), u64p(5), nil}, + wantPostponed: []bool{false, false, true}, + }, + { + name: "synthetic compounding-switch deposit is skipped, not postponed", + queue: []*electra.PendingDeposit{regularDeposit(10, 1), syntheticDeposit(10, 2), regularDeposit(11, 3)}, + anchor: anchorAt(5, 11), + wantIndexes: []*uint64{u64p(4), nil, u64p(5)}, + wantPostponed: []bool{false, false, false}, + }, + { + name: "0x03 (builder-cred) regular deposit is indexed contiguously like any validator deposit", + queue: []*electra.PendingDeposit{regularDeposit(10, 1), regularDeposit(11, 2)}, + anchor: anchorAt(8, 11), + wantIndexes: []*uint64{u64p(7), u64p(8)}, + wantPostponed: []bool{false, false}, + }, + { + name: "anchor slot mismatch falls back to slot resolution for all entries", + queue: []*electra.PendingDeposit{regularDeposit(10, 1)}, + anchor: anchorAt(5, 20), + wantIndexes: []*uint64{nil}, + wantPostponed: []bool{true}, + }, + { + name: "nil anchor leaves every entry to slot resolution", + queue: []*electra.PendingDeposit{regularDeposit(10, 1), regularDeposit(11, 2)}, + anchor: nil, + wantIndexes: []*uint64{nil, nil}, + wantPostponed: []bool{true, true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + indexes, postponed := resolveQueueDepositIndexes(tt.queue, tt.anchor) + + if len(postponed) != len(tt.wantPostponed) { + t.Fatalf("postponed length = %d, want %d", len(postponed), len(tt.wantPostponed)) + } + for i := range tt.wantPostponed { + if postponed[i] != tt.wantPostponed[i] { + t.Errorf("postponed[%d] = %v, want %v", i, postponed[i], tt.wantPostponed[i]) + } + } + + if len(indexes) != len(tt.wantIndexes) { + t.Fatalf("indexes length = %d, want %d", len(indexes), len(tt.wantIndexes)) + } + for i := range tt.wantIndexes { + switch { + case tt.wantIndexes[i] == nil && indexes[i] != nil: + t.Errorf("index[%d] = %d, want nil", i, *indexes[i]) + case tt.wantIndexes[i] != nil && indexes[i] == nil: + t.Errorf("index[%d] = nil, want %d", i, *tt.wantIndexes[i]) + case tt.wantIndexes[i] != nil && indexes[i] != nil && *indexes[i] != *tt.wantIndexes[i]: + t.Errorf("index[%d] = %d, want %d", i, *indexes[i], *tt.wantIndexes[i]) + } + } + }) + } +} + +func countRole(roles []queueRole, want queueRole) int { + n := 0 + for _, r := range roles { + if r == want { + n++ + } + } + return n +} + +// TestResolveQueueDepositIndexesRealistic exercises large, mainnet-shaped queues: long +// contiguous runs, clusters of postponed top-ups to exiting validators appended at the tail +// (process_pending_deposits behaviour), 0x01->0x02 compounding-switch synthetics interspersed +// throughout, and the "nothing but old stuck deposits" case that falls back to slot +// resolution. These mirror the patterns seen on the live mainnet deposit queue. +func TestResolveQueueDepositIndexesRealistic(t *testing.T) { + buildRoles := func(spec func() []queueRole) []queueRole { return spec() } + + cases := []struct { + name string + roles []queueRole + anchorIndex uint64 + }{ + { + name: "256 contiguous deposits", + roles: buildRoles(func() []queueRole { + roles := make([]queueRole, 256) + for i := range roles { + roles[i] = roleRegular + } + return roles + }), + anchorIndex: 5000, + }, + { + name: "200 in-order deposits with 8 postponed top-ups appended at the tail", + roles: buildRoles(func() []queueRole { + roles := make([]queueRole, 0, 208) + for i := 0; i < 200; i++ { + roles = append(roles, roleRegular) + } + for i := 0; i < 8; i++ { + roles = append(roles, rolePostponed) + } + return roles + }), + anchorIndex: 800000, + }, + { + name: "150 deposits with a compounding-switch synthetic after every 25", + roles: buildRoles(func() []queueRole { + roles := make([]queueRole, 0, 156) + for i := 0; i < 150; i++ { + roles = append(roles, roleRegular) + if (i+1)%25 == 0 { + roles = append(roles, roleSynthetic) + } + } + return roles + }), + anchorIndex: 1_234_567, + }, + { + name: "kitchen sink: 100 deposits, interspersed synthetics, postponed top-ups at the tail", + roles: buildRoles(func() []queueRole { + roles := make([]queueRole, 0, 120) + for i := 0; i < 100; i++ { + roles = append(roles, roleRegular) + if (i+1)%30 == 0 { + roles = append(roles, roleSynthetic) + } + } + for i := 0; i < 5; i++ { + roles = append(roles, rolePostponed) + } + return roles + }), + anchorIndex: 42_000, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + queue, roles := buildQueue(tc.roles) + // The tail-most in-order regular carries the highest assigned slot; the anchor must + // align with it for the positional assignment to be trusted. + regCount := countRole(roles, roleRegular) + anchor := anchorAt(tc.anchorIndex, uint64(1000+regCount-1)) + + wantIndexes, wantPostponed := expectedFor(roles, tc.anchorIndex) + gotIndexes, gotPostponed := resolveQueueDepositIndexes(queue, anchor) + + if len(gotIndexes) != len(queue) || len(gotPostponed) != len(queue) { + t.Fatalf("result lengths = (%d,%d), want %d", len(gotIndexes), len(gotPostponed), len(queue)) + } + for i := range queue { + if gotPostponed[i] != wantPostponed[i] { + t.Errorf("postponed[%d] = %v, want %v", i, gotPostponed[i], wantPostponed[i]) + } + switch { + case wantIndexes[i] == nil && gotIndexes[i] != nil: + t.Errorf("index[%d] = %d, want nil", i, *gotIndexes[i]) + case wantIndexes[i] != nil && gotIndexes[i] == nil: + t.Errorf("index[%d] = nil, want %d", i, *wantIndexes[i]) + case wantIndexes[i] != nil && gotIndexes[i] != nil && *gotIndexes[i] != *wantIndexes[i]: + t.Errorf("index[%d] = %d, want %d", i, *gotIndexes[i], *wantIndexes[i]) + } + } + }) + } +} + +// TestResolveQueueDepositIndexesStuckDepositsFallback models a queue made up entirely of old +// stuck top-up deposits to already-exited validators: their slots are in order (no dip), but +// the anchor — the most recent included deposit — is unrelated and far newer, so positional +// assignment cannot be trusted and every entry must fall back to slot resolution. +func TestResolveQueueDepositIndexesStuckDepositsFallback(t *testing.T) { + queue := []*electra.PendingDeposit{ + regularDeposit(1000, 1), + regularDeposit(1001, 2), + regularDeposit(1002, 3), + } + anchor := anchorAt(9_000_000, 5_000_000) // recent, unrelated deposit + + indexes, postponed := resolveQueueDepositIndexes(queue, anchor) + for i := range queue { + if !postponed[i] { + t.Errorf("postponed[%d] = false, want true (slot fallback)", i) + } + if indexes[i] != nil { + t.Errorf("index[%d] = %d, want nil (slot fallback)", i, *indexes[i]) + } + } +} diff --git a/templates/blocks/blocks.html b/templates/blocks/blocks.html index fb67075f0..15cbc6224 100644 --- a/templates/blocks/blocks.html +++ b/templates/blocks/blocks.html @@ -150,7 +150,7 @@

Blocks

{{ end }} {{ if $g.DisplayTime }}{{ formatRecentTimeShort $slot.Ts }}{{ end }} {{ if $slot.Synchronized }} - {{ if $g.DisplayProposer }}{{ if gt $slot.Slot 0 }}{{ formatValidator $slot.Proposer $slot.ProposerName }}{{ end }}{{ end }} + {{ if $g.DisplayProposer }}{{ if gt $slot.Slot 0 }}{{ formatProposerWithBuildSource $slot.Status $slot.Proposer $slot.ProposerName $slot.HasBuilder $slot.BuilderIndex $slot.BuilderURL }}{{ end }}{{ end }} {{ if $g.DisplayAttestations }}{{ if not (eq $slot.Status 0) }}{{ $slot.AttestationCount }}{{ end }}{{ end }} {{ if $g.DisplayDeposits }}{{ if not (eq $slot.Status 0) }}{{ $slot.DepositCount }} / {{ $slot.ExitCount }}{{ end }}{{ end }} {{ if $g.DisplaySlashings }}{{ if not (eq $slot.Status 0) }}{{ $slot.ProposerSlashingCount }} / {{ $slot.AttesterSlashingCount }}{{ end }}{{ end }} diff --git a/templates/builder/recentDeposits.html b/templates/builder/recentDeposits.html index 2dbfd4d6a..aade4b3f2 100644 --- a/templates/builder/recentDeposits.html +++ b/templates/builder/recentDeposits.html @@ -28,9 +28,9 @@ {{ end }} {{ if $deposit.Orphaned }} - {{ formatAddCommas $deposit.SlotNumber }} + {{ formatAddCommas $deposit.SlotNumber }}{{ if $deposit.IsOnboarding }} {{ end }} {{ else }} - {{ formatAddCommas $deposit.SlotNumber }} + {{ formatAddCommas $deposit.SlotNumber }}{{ if $deposit.IsOnboarding }} {{ end }} {{ end }} {{ formatRecentTimeShort $deposit.Time }} {{ formatFullEthFromGwei $deposit.Amount }} diff --git a/templates/builder_deposits/builder_deposits.html b/templates/builder_deposits/builder_deposits.html new file mode 100644 index 000000000..ba3d463cc --- /dev/null +++ b/templates/builder_deposits/builder_deposits.html @@ -0,0 +1,325 @@ +{{ define "page" }} +
+
+

+ Builder Deposits +

+ +
+ +
+ + {{ if .IsProjection }} +
+
+ Projected Builder Onboarding (pre-Gloas) +
+
+

+ Gloas activates at epoch {{ .GloasForkEpoch }} + ({{ formatRecentTimeShort .GloasForkTime }}). + Builder deposits are not recorded on-chain yet — the entries below are the actual 0x03-credential deposits, + each annotated with its projected fate at the fork (based on the deposit churn limit and pending queue), and may change as deposits are submitted or processed. + {{ if .ProjectionTruncated }}(showing the first {{ len .Deposits }}; older deposits omitted){{ end }} +

+
+
+
    +
  • {{ .OnboardedNewCount }} new builder{{ if ne .OnboardedNewCount 1 }}s{{ end }} projected to be onboarded at the fork{{ if gt .OnboardedTopUpCount 0 }}, plus {{ .OnboardedTopUpCount }} top-up deposit{{ if ne .OnboardedTopUpCount 1 }}s{{ end }}{{ end }}
  • + {{ if gt .TooEarlyCount 0 }}
  • {{ .TooEarlyCount }} deposit{{ if ne .TooEarlyCount 1 }}s{{ end }} too early (processed before the fork → become validators)
  • {{ end }} + {{ if gt .KeptAsValidatorCount 0 }}
  • {{ .KeptAsValidatorCount }} kept as validator deposit{{ if ne .KeptAsValidatorCount 1 }}s{{ end }} (pubkey already a validator)
  • {{ end }} + {{ if gt .InvalidSignatureCount 0 }}
  • {{ .InvalidSignatureCount }} with an invalid signature (dropped at onboarding)
  • {{ end }} +
+
+
+ {{ if .HasSafetyEstimate }} + {{ if .DepositSafe }} +
+ + Safe to deposit now. A builder deposit submitted now would remain queued until the fork + (est. processing epoch {{ .NewDepositEstimateEpoch }}, at or after the fork) and be onboarded as a builder. +
+ {{ else }} +
+ + Queue too short to deposit yet. A deposit submitted now would be processed + (~epoch {{ .NewDepositEstimateEpoch }}) before the fork and become a regular validator, not a builder. +
+ {{ end }} + {{ else }} +
+ Not enough data to estimate whether it is safe to deposit now. +
+ {{ end }} +
+
+
+
+ {{ end }} + +
+ +
+
Builder Deposit Filters
+
+
+
+
+
+
Slot Number
+
+
+ +
+
-
+
+ +
+
+
+
+
Builder Index
+
+
+ +
+
-
+
+ +
+
+
+
+
Public Key
+
+ +
+
+
+
+
+
+
+
Amount (Gwei)
+
+
+ +
+
-
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + {{ if gt .DepositCount 0 }} + + {{ range $i, $deposit := .Deposits }} + + + + + + + + + + + {{ end }} + + {{ else }} + + + + + + + + {{ end }} +
SlotTimeBuilderPublic KeyWithdrawal CredAmountIncl. StatusTransaction
+ {{ if $deposit.IsIncluded }} + {{ if $deposit.Orphaned }} + {{ formatAddCommas $deposit.SlotNumber }} + {{ else }} + {{ formatAddCommas $deposit.SlotNumber }} + {{ end }} + {{ if $deposit.IsOnboarding }} + + {{ end }} + {{ else }} + pending + {{ end }} + + {{ if $deposit.IsIncluded }} + {{ formatRecentTimeShort $deposit.Time }} + {{ else }} + - + {{ end }} + + {{ if $deposit.IsProjected }} + {{ if $deposit.IsQueued }} + #{{ $deposit.QueuePosition }} + {{ else }} + - + {{ end }} + {{ else if $deposit.HasBuilderIndex }} + {{ formatBuilderWithIndex $deposit.BuilderIndex "" }} + {{ else if $deposit.IsInactiveBuilder }} + {{ formatInactiveBuilder $deposit.PublicKey }} + {{ else }} + - + {{ end }} + +
+ 0x{{ printf "%x" $deposit.PublicKey }} +
+
+
+ {{ formatWithdawalCredentials $deposit.WithdrawalCredentials }} + + {{ formatEthFromGwei $deposit.Amount }} + {{ if $deposit.IsProjected }} + {{ if $deposit.ProjectedAlreadyProcessed }} + Too early + {{ else if $deposit.ProjectedTooEarly }} + Too early + {{ else if $deposit.ProjectedKeptAsValidator }} + Validator + {{ else if $deposit.ProjectedInvalidSignature }} + Invalid sig + {{ else if eq $deposit.Result 3 }} + Top-up + {{ else }} + New builder + {{ end }} + {{ else if not $deposit.IsIncluded }} + Pending + {{ else if $deposit.Orphaned }} + Orphaned + {{ else }} + Included + {{ end }} + + {{ if $deposit.HasTransaction }} +
+ {{ ethTransactionLink $deposit.TransactionHash 0 }} +
+ +
+ {{ if $deposit.TransactionDetails }} +
+ +
+ {{ end }} +
+ {{ else }} + - + {{ end }} +
+
+ {{ template "professor_svg" }} +
+
+
+ {{ if gt .TotalPages 1 }} +
+
+
+
Showing builder deposits from slot {{ .FirstIndex }} to {{ .LastIndex }}
+
+
+
+
+ +
+
+
+ {{ end }} +
+ +
+ {{ template "txDetailsModal" . }} +
+{{ end }} +{{ define "js" }} +{{ template "txDetailsModal-js" . }} +{{ end }} +{{ define "css" }} + +{{ template "txDetailsModal-css" . }} +{{ end }} diff --git a/templates/builder_exits/builder_exits.html b/templates/builder_exits/builder_exits.html new file mode 100644 index 000000000..1a39adb0e --- /dev/null +++ b/templates/builder_exits/builder_exits.html @@ -0,0 +1,242 @@ +{{ define "page" }} +
+
+

+ Builder Exits +

+ +
+ +
+
+ +
+
Builder Exit Filters
+
+
+
+
+
+
Slot Number
+
+
+ +
+
-
+
+ +
+
+
+
+
Builder Index
+
+
+ +
+
-
+
+ +
+
+
+
+
+
+
+
+
Public Key
+
+ +
+
+
+
Source Address
+
+ +
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + {{ if gt .ExitCount 0 }} + + {{ range $i, $exit := .Exits }} + + + + + + + + + + {{ end }} + + {{ else }} + + + + + + + + {{ end }} +
SlotTimeBuilderSource AddressPublic KeyIncl. StatusTransaction
+ {{ if $exit.IsIncluded }} + {{ if $exit.Orphaned }} + {{ formatAddCommas $exit.SlotNumber }} + {{ else }} + {{ formatAddCommas $exit.SlotNumber }} + {{ end }} + {{ else }} + pending + {{ end }} + + {{ if $exit.IsIncluded }} + {{ formatRecentTimeShort $exit.Time }} + {{ else }} + - + {{ end }} + + {{ if $exit.HasBuilderIndex }} + {{ formatBuilderWithIndex $exit.BuilderIndex "" }} + {{ else if $exit.IsInactiveBuilder }} + {{ formatInactiveBuilder $exit.PublicKey }} + {{ else }} + - + {{ end }} + {{ ethAddressLink $exit.SourceAddress }} +
+ 0x{{ printf "%x" $exit.PublicKey }} +
+
+
+ {{ if not $exit.IsIncluded }} + Pending + {{ else if $exit.Orphaned }} + Orphaned + {{ else }} + Included + {{ end }} + + {{ if $exit.HasTransaction }} +
+ {{ ethTransactionLink $exit.TransactionHash 0 }} +
+ +
+ {{ if $exit.TransactionDetails }} +
+ +
+ {{ end }} +
+ {{ else }} + - + {{ end }} +
+
+ {{ template "professor_svg" }} +
+
+
+ {{ if gt .TotalPages 1 }} +
+
+
+
Showing builder exits from slot {{ .FirstIndex }} to {{ .LastIndex }}
+
+
+
+
+ +
+
+
+ {{ end }} +
+ +
+ {{ template "txDetailsModal" . }} +
+{{ end }} +{{ define "js" }} +{{ template "txDetailsModal-js" . }} +{{ end }} +{{ define "css" }} + +{{ template "txDetailsModal-css" . }} +{{ end }} diff --git a/templates/slot/builder_deposit_requests.html b/templates/slot/builder_deposit_requests.html new file mode 100644 index 000000000..1594f3861 --- /dev/null +++ b/templates/slot/builder_deposit_requests.html @@ -0,0 +1,42 @@ +{{ define "block_builder_deposit_requests" }} +
+ + + + + + + + + + + {{ range $i, $req := .Block.BuilderDepositRequests }} + + + + + + + {{ end }} + +
BuilderPublic KeyWithdrawal CredentialsAmount
+ {{- if $req.HasBuilderIndex }} + {{ formatBuilderWithIndex $req.BuilderIndex "" }} + {{- else if $req.IsInactiveBuilder }} + {{ formatInactiveBuilder $req.PublicKey }} + {{- else }} + - + {{- end }} + +
+ +
+ 0x{{ printf "%x" $req.PublicKey }} +
+
+ +
+ {{ formatWithdawalCredentials $req.WithdrawalCreds }} +
{{ formatEthFromGwei $req.Amount }}
+
+{{ end }} diff --git a/templates/slot/builder_exit_requests.html b/templates/slot/builder_exit_requests.html new file mode 100644 index 000000000..0ae37e50e --- /dev/null +++ b/templates/slot/builder_exit_requests.html @@ -0,0 +1,40 @@ +{{ define "block_builder_exit_requests" }} +
+ + + + + + + + + + {{ range $i, $req := .Block.BuilderExitRequests }} + + + + + + {{ end }} + +
Source AddressBuilderPublic Key
+
+ +
+ {{ ethAddressLink $req.SourceAddress }} +
+ {{- if $req.HasBuilderIndex }} + {{ formatBuilderWithIndex $req.BuilderIndex "" }} + {{- else if $req.IsInactiveBuilder }} + {{ formatInactiveBuilder $req.PublicKey }} + {{- else }} + - + {{- end }} + +
+ +
+ 0x{{ printf "%x" $req.PublicKey }} +
+
+{{ end }} diff --git a/templates/slot/slot.html b/templates/slot/slot.html index 2edc6459c..93148ecba 100644 --- a/templates/slot/slot.html +++ b/templates/slot/slot.html @@ -91,19 +91,10 @@

Blob Sidecars {{ .Block.BlobsCount }} {{ end }} - {{ if gt .Block.DepositRequestsCount 0 }} + {{ $executionRequestsCount := addUI64 (addUI64 (addUI64 (addUI64 .Block.DepositRequestsCount .Block.WithdrawalRequestsCount) .Block.ConsolidationRequestsCount) .Block.BuilderDepositRequestsCount) .Block.BuilderExitRequestsCount }} + {{ if gt $executionRequestsCount 0 }} - {{ end }} - {{ if gt .Block.WithdrawalRequestsCount 0 }} - - {{ end }} - {{ if gt .Block.ConsolidationRequestsCount 0 }} - {{ end }} {{ if and .Block.ExecutionData .Block.ExecutionData.BlockAccessList }} @@ -247,43 +238,47 @@

Showing {{ .Block.BlobsCount }} Blob sid {{ template "block_blobSidecar" . }} {{ end }} - {{ if gt .Block.DepositRequestsCount 0 }} -
+ {{ $executionRequestsCount := addUI64 (addUI64 (addUI64 (addUI64 .Block.DepositRequestsCount .Block.WithdrawalRequestsCount) .Block.ConsolidationRequestsCount) .Block.BuilderDepositRequestsCount) .Block.BuilderExitRequestsCount }} + {{ if gt $executionRequestsCount 0 }} +
-
+
-

Showing {{ .Block.DepositRequestsCount }} Deposit Requests

- {{ if .Block.RequestsFromParentPayload }}

These requests were included in the parent block's payload and are processed in this block.

{{ end }} +

Showing {{ $executionRequestsCount }} Execution Requests{{ if .Block.RequestsFromParentPayload }} from the Parent Payload{{ end }}

+ {{ if .Block.RequestsFromParentPayload }}

These requests were included in the parent block's payload (EIP-7732) and are processed in this block.

{{ end }}
-
- {{ template "block_deposit_requests" . }} -
- {{ end }} - {{ if gt .Block.WithdrawalRequestsCount 0 }} -
-
-
-
-

Showing {{ .Block.WithdrawalRequestsCount }} Withdrawal Requests

- {{ if .Block.RequestsFromParentPayload }}

These requests were included in the parent block's payload and are processed in this block.

{{ end }} + {{ if gt .Block.DepositRequestsCount 0 }} +
+

Deposit Requests {{ .Block.DepositRequestsCount }}

+ {{ template "block_deposit_requests" . }}
-
-
- {{ template "block_withdrawal_requests" . }} -
- {{ end }} - {{ if gt .Block.ConsolidationRequestsCount 0 }} -
-
-
-
-

Showing {{ .Block.ConsolidationRequestsCount }} Consolidation Requests

- {{ if .Block.RequestsFromParentPayload }}

These requests were included in the parent block's payload and are processed in this block (EIP-7732).

{{ end }} + {{ end }} + {{ if gt .Block.WithdrawalRequestsCount 0 }} +
+

Withdrawal Requests {{ .Block.WithdrawalRequestsCount }}

+ {{ template "block_withdrawal_requests" . }}
-
+ {{ end }} + {{ if gt .Block.ConsolidationRequestsCount 0 }} +
+

Consolidation Requests {{ .Block.ConsolidationRequestsCount }}

+ {{ template "block_consolidation_requests" . }} +
+ {{ end }} + {{ if gt .Block.BuilderDepositRequestsCount 0 }} +
+

Builder Deposits {{ .Block.BuilderDepositRequestsCount }}

+ {{ template "block_builder_deposit_requests" . }} +
+ {{ end }} + {{ if gt .Block.BuilderExitRequestsCount 0 }} +
+

Builder Exits {{ .Block.BuilderExitRequestsCount }}

+ {{ template "block_builder_exit_requests" . }} +
+ {{ end }}
- {{ template "block_consolidation_requests" . }}
{{ end }} {{ if and .Block.ExecutionData .Block.ExecutionData.BlockAccessList }} @@ -622,5 +617,15 @@
Tracoor Traces
.eip7918-info-btn:hover { opacity: 0.8; } + .exec-request-section { + padding-bottom: 0.25rem; + } + .exec-request-section > h4 { + color: var(--bs-secondary-color, #6c757d); + font-weight: 600; + } + .exec-request-section .table { + margin-bottom: 0; + } {{ end }} \ No newline at end of file diff --git a/templates/slots/slots.html b/templates/slots/slots.html index 36aac590e..00a6a2fa8 100644 --- a/templates/slots/slots.html +++ b/templates/slots/slots.html @@ -150,7 +150,7 @@

Slots

{{ end }} {{ if $g.DisplayTime }}{{ formatRecentTimeShort $slot.Ts }}{{ end }} {{ if $slot.Synchronized }} - {{ if $g.DisplayProposer }}{{ if gt $slot.Slot 0 }}{{ formatValidator $slot.Proposer $slot.ProposerName }}{{ end }}{{ end }} + {{ if $g.DisplayProposer }}{{ if gt $slot.Slot 0 }}{{ formatProposerWithBuildSource $slot.Status $slot.Proposer $slot.ProposerName $slot.HasBuilder $slot.BuilderIndex $slot.BuilderURL }}{{ end }}{{ end }} {{ if $g.DisplayAttestations }}{{ if not (eq $slot.Status 0) }}{{ $slot.AttestationCount }}{{ end }}{{ end }} {{ if $g.DisplayDeposits }}{{ if not (eq $slot.Status 0) }}{{ $slot.DepositCount }} / {{ $slot.ExitCount }}{{ end }}{{ end }} {{ if $g.DisplaySlashings }}{{ if not (eq $slot.Status 0) }}{{ $slot.ProposerSlashingCount }} / {{ $slot.AttesterSlashingCount }}{{ end }}{{ end }} diff --git a/templates/submit_builder_deposit/submit_builder_deposit.html b/templates/submit_builder_deposit/submit_builder_deposit.html new file mode 100644 index 000000000..840de044f --- /dev/null +++ b/templates/submit_builder_deposit/submit_builder_deposit.html @@ -0,0 +1,62 @@ +{{ define "page" }} +
+
+

+ Submit Builder Deposit +

+ +
+ +
+ +
+
+ +
+
+
+
+{{ end }} +{{ define "js" }} + + +{{ end }} +{{ define "css" }} +{{ end }} diff --git a/templates/submit_builder_exit/submit_builder_exit.html b/templates/submit_builder_exit/submit_builder_exit.html new file mode 100644 index 000000000..99e39746a --- /dev/null +++ b/templates/submit_builder_exit/submit_builder_exit.html @@ -0,0 +1,65 @@ +{{ define "page" }} +
+
+

+ Submit Builder Exit +

+ +
+ +
+ +
+
+ +
+
+
+
+{{ end }} +{{ define "js" }} + + +{{ end }} +{{ define "css" }} +{{ end }} diff --git a/templates/voluntary_exits/voluntary_exits.html b/templates/voluntary_exits/voluntary_exits.html index e159d187e..45293b1fe 100644 --- a/templates/voluntary_exits/voluntary_exits.html +++ b/templates/voluntary_exits/voluntary_exits.html @@ -43,36 +43,24 @@

- Entity Type -
-
- -
-
-
-
- {{ if eq .FilterEntity "builder" }}Builder Index{{ else }}Validator Index{{ end }} + Validator Index
- +
-
- +
- {{ if eq .FilterEntity "builder" }}Builder Name{{ else }}Validator Name{{ end }} + Validator Name
- +
@@ -150,11 +138,7 @@

{{ end }} {{ formatRecentTimeShort $voluntaryExit.Time }} - {{ if $voluntaryExit.IsBuilder }} - {{ formatBuilderWithIndex $voluntaryExit.ValidatorIndex $voluntaryExit.ValidatorName }} - {{ else }} - {{ formatValidatorWithIndex $voluntaryExit.ValidatorIndex $voluntaryExit.ValidatorName }} - {{ end }} + {{ formatValidatorWithIndex $voluntaryExit.ValidatorIndex $voluntaryExit.ValidatorName }}
@@ -287,24 +271,6 @@

$('#pageJumpForm').submit(); } }); - - function updateEntityFields(entity) { - var isAll = (entity === 'all'); - $('.entity-field').each(function() { - $(this).prop('readonly', isAll).toggleClass('entity-field-disabled', isAll); - if (isAll) { $(this).val(''); } - }); - if (!isAll) { - $('.entity-label').each(function() { $(this).text($(this).data(entity)); }); - $('.entity-placeholder').each(function() { $(this).attr('placeholder', $(this).data(entity)); }); - } - } - $('.entity-select').on('change', function() { updateEntityFields($(this).val()); }); - $('.entity-field').on('mousedown', function() { - if ($(this).prop('readonly')) { - $('.entity-select').val('validator').trigger('change'); - } - }); }); {{ end }} @@ -317,11 +283,5 @@

padding-right: 10px; } -.entity-field-disabled { - background-color: var(--bs-secondary-bg); - opacity: 0.65; - cursor: pointer; -} - {{ end }} \ No newline at end of file diff --git a/types/config.go b/types/config.go index 59468bc4b..a43dd0852 100644 --- a/types/config.go +++ b/types/config.go @@ -118,6 +118,7 @@ type Config struct { LogBatchSize int `yaml:"logBatchSize" envconfig:"EXECUTIONAPI_LOG_BATCH_SIZE"` DepositDeployBlock int `yaml:"depositDeployBlock" envconfig:"EXECUTIONAPI_DEPOSIT_DEPLOY_BLOCK"` // el block number from where to crawl the deposit system contract (should be <=, but close to deposit contract deployment) ElectraDeployBlock int `yaml:"electraDeployBlock" envconfig:"EXECUTIONAPI_ELECTRA_DEPLOY_BLOCK"` // el block number from where to crawl the electra system contracts (should be <=, but close to electra fork activation block) + GloasDeployBlock int `yaml:"gloasDeployBlock" envconfig:"EXECUTIONAPI_GLOAS_DEPLOY_BLOCK"` // el block number from where to crawl the gloas builder system contracts (should be <=, but close to gloas fork activation block) GenesisConfig string `yaml:"genesisConfig" envconfig:"EXECUTIONAPI_GENESIS_CONFIG"` // path or URL to genesis.json file in geth format } `yaml:"executionapi"` diff --git a/types/models/builder_deposits.go b/types/models/builder_deposits.go new file mode 100644 index 000000000..0cb648f87 --- /dev/null +++ b/types/models/builder_deposits.go @@ -0,0 +1,89 @@ +package models + +import ( + "time" +) + +// BuilderDepositsPageData is a struct to hold info for the builder deposits page +type BuilderDepositsPageData struct { + FilterMinSlot uint64 `json:"filter_mins"` + FilterMaxSlot uint64 `json:"filter_maxs"` + FilterPubKey string `json:"filter_pubkey"` + FilterMinIndex uint64 `json:"filter_mini"` + FilterMaxIndex uint64 `json:"filter_maxi"` + FilterMinAmount uint64 `json:"filter_mina"` + FilterMaxAmount uint64 `json:"filter_maxa"` + + Deposits []*BuilderDepositsPageDataDeposit `json:"deposits"` + DepositCount uint64 `json:"deposit_count"` + FirstIndex uint64 `json:"first_index"` + LastIndex uint64 `json:"last_index"` + + // Pre-Gloas projection: before the fork the real builder_deposits table is empty, so the page + // instead shows the builders projected to be onboarded from the pending deposit queue at the fork. + IsProjection bool `json:"is_projection"` + ProjectionTruncated bool `json:"projection_truncated"` + GloasForkEpoch uint64 `json:"gloas_fork_epoch"` + GloasForkTime time.Time `json:"gloas_fork_time"` + OnboardedNewCount uint64 `json:"onboarded_new_count"` + OnboardedTopUpCount uint64 `json:"onboarded_topup_count"` + TooEarlyCount uint64 `json:"too_early_count"` + InvalidSignatureCount uint64 `json:"invalid_signature_count"` + KeptAsValidatorCount uint64 `json:"kept_as_validator_count"` + TotalQueueProcessedBeforeFork uint64 `json:"total_queue_processed_before_fork"` + + // "Is it safe to deposit right now" indicator (projection mode only). + HasSafetyEstimate bool `json:"has_safety_estimate"` + DepositSafe bool `json:"deposit_safe"` + NewDepositEstimateEpoch uint64 `json:"new_deposit_estimate_epoch"` + NewDepositEstimateTime time.Time `json:"new_deposit_estimate_time"` + + IsDefaultPage bool `json:"default_page"` + TotalPages uint64 `json:"total_pages"` + PageSize uint64 `json:"page_size"` + CurrentPageIndex uint64 `json:"page_index"` + PrevPageIndex uint64 `json:"prev_page_index"` + NextPageIndex uint64 `json:"next_page_index"` + LastPageIndex uint64 `json:"last_page_index"` + + FirstPageLink string `json:"first_page_link"` + PrevPageLink string `json:"prev_page_link"` + NextPageLink string `json:"next_page_link"` + LastPageLink string `json:"last_page_link"` + + UrlParams []UrlParam `json:"url_params"` +} + +type BuilderDepositsPageDataDeposit struct { + IsIncluded bool `json:"is_included"` // included in a block (CL request) vs pending tx only + SlotNumber uint64 `json:"slot"` + SlotRoot []byte `json:"slot_root" ssz-size:"32"` + Time time.Time `json:"time"` + Orphaned bool `json:"orphaned"` + PublicKey []byte `json:"pubkey" ssz-size:"48"` + WithdrawalCredentials []byte `json:"wdcreds" ssz-size:"32"` + Amount uint64 `json:"amount"` + HasBuilderIndex bool `json:"has_builder_index"` + BuilderIndex uint64 `json:"builder_index"` + IsInactiveBuilder bool `json:"is_inactive_builder"` // pubkey is a known builder but its index was reused + IsOnboarding bool `json:"is_onboarding"` // onboarded from the pending deposit queue at the Gloas fork transition + Result uint8 `json:"result"` + HasTransaction bool `json:"has_transaction"` + TransactionHash []byte `json:"tx_hash" ssz-size:"32"` + TransactionDetails *BuilderPageDataDepositTxDetails `json:"tx_details"` + TransactionOrphaned bool `json:"tx_orphaned"` + BlockNumber uint64 `json:"block_number"` + + // Pre-Gloas projection fields (set when the parent page is in projection mode). + IsProjected bool `json:"is_projected"` + HasDepositIndex bool `json:"has_deposit_index"` // EL deposit index of the deposit + DepositIndex uint64 `json:"deposit_index"` + EstimatedTime time.Time `json:"estimated_time"` // when the deposit is projected to be processed + IsQueued bool `json:"is_queued"` // found in the current pending deposit queue snapshot + QueuePosition uint64 `json:"queue_position"` // position in the queue (when IsQueued) + ProjectedOnboarded bool `json:"projected_onboarded"` // onboarded as a builder at the fork + ProjectedTooEarly bool `json:"projected_too_early"` // processed before the fork -> becomes a validator + ProjectedAlreadyProcessed bool `json:"projected_already_processed"` // already applied (refinement of too-early) + ProjectedKeptAsValidator bool `json:"projected_kept_as_validator"` // shares a pubkey with a validator deposit + ProjectedInvalidSignature bool `json:"projected_invalid_signature"` // dropped at onboarding (bad proof-of-possession) +} diff --git a/types/models/builder_exits.go b/types/models/builder_exits.go new file mode 100644 index 000000000..4077d21c6 --- /dev/null +++ b/types/models/builder_exits.go @@ -0,0 +1,54 @@ +package models + +import ( + "time" +) + +// BuilderExitsPageData is a struct to hold info for the builder exits page +type BuilderExitsPageData struct { + FilterMinSlot uint64 `json:"filter_mins"` + FilterMaxSlot uint64 `json:"filter_maxs"` + FilterPubKey string `json:"filter_pubkey"` + FilterSourceAddr string `json:"filter_source"` + FilterMinIndex uint64 `json:"filter_mini"` + FilterMaxIndex uint64 `json:"filter_maxi"` + + Exits []*BuilderExitsPageDataExit `json:"exits"` + ExitCount uint64 `json:"exit_count"` + FirstIndex uint64 `json:"first_index"` + LastIndex uint64 `json:"last_index"` + + IsDefaultPage bool `json:"default_page"` + TotalPages uint64 `json:"total_pages"` + PageSize uint64 `json:"page_size"` + CurrentPageIndex uint64 `json:"page_index"` + PrevPageIndex uint64 `json:"prev_page_index"` + NextPageIndex uint64 `json:"next_page_index"` + LastPageIndex uint64 `json:"last_page_index"` + + FirstPageLink string `json:"first_page_link"` + PrevPageLink string `json:"prev_page_link"` + NextPageLink string `json:"next_page_link"` + LastPageLink string `json:"last_page_link"` + + UrlParams []UrlParam `json:"url_params"` +} + +type BuilderExitsPageDataExit struct { + IsIncluded bool `json:"is_included"` + SlotNumber uint64 `json:"slot"` + SlotRoot []byte `json:"slot_root" ssz-size:"32"` + Time time.Time `json:"time"` + Orphaned bool `json:"orphaned"` + SourceAddress []byte `json:"source_address" ssz-size:"20"` + PublicKey []byte `json:"pubkey" ssz-size:"48"` + HasBuilderIndex bool `json:"has_builder_index"` + BuilderIndex uint64 `json:"builder_index"` + IsInactiveBuilder bool `json:"is_inactive_builder"` // pubkey is a known builder but its index was reused + Result uint8 `json:"result"` + HasTransaction bool `json:"has_transaction"` + TransactionHash []byte `json:"tx_hash" ssz-size:"32"` + TransactionDetails *BuilderPageDataExitTxDetails `json:"tx_details"` + TransactionOrphaned bool `json:"tx_orphaned"` + BlockNumber uint64 `json:"block_number"` +} diff --git a/types/models/builders.go b/types/models/builders.go index 1da983a37..4088ee9ac 100644 --- a/types/models/builders.go +++ b/types/models/builders.go @@ -130,6 +130,7 @@ type BuilderPageDataDeposit struct { SlotRoot []byte `json:"slot_root"` Time time.Time `json:"time"` Orphaned bool `json:"orphaned"` + IsOnboarding bool `json:"is_onboarding"` // onboarded from the pending deposit queue at the Gloas fork transition Amount uint64 `json:"amount"` DepositorAddress []byte `json:"depositor_address" ssz-size:"20"` HasTransaction bool `json:"has_transaction"` diff --git a/types/models/slot.go b/types/models/slot.go index b48f1a207..e820f573e 100644 --- a/types/models/slot.go +++ b/types/models/slot.go @@ -51,38 +51,40 @@ const ( ) type SlotPageBlockData struct { - BlockRoot []byte `json:"blockroot" ssz-size:"32"` - ParentRoot []byte `json:"parentroot" ssz-size:"32"` - StateRoot []byte `json:"stateroot" ssz-size:"32"` - BodyRoot []byte `json:"bodyroot" ssz-size:"32"` - Signature []byte `json:"signature" ssz-size:"96"` - RandaoReveal []byte `json:"randaoreveal" ssz-size:"96"` - Graffiti []byte `json:"graffiti"` - Eth1dataDepositroot []byte `json:"eth1data_depositroot" ssz-size:"32"` - Eth1dataDepositcount uint64 `json:"eth1data_depositcount"` - Eth1dataBlockhash []byte `json:"eth1data_blockhash" ssz-size:"32"` - SyncAggregateBits []byte `json:"syncaggregate_bits"` - SyncAggregateSignature []byte `json:"syncaggregate_signature" ssz-size:"96"` - SyncAggParticipation float64 `json:"syncaggregate_participation"` - SyncAggCommittee []types.NamedValidator `json:"syncaggregate_committee"` - ValidatorNames []SlotPageValidatorName `json:"validator_names"` - ProposerSlashingsCount uint64 `json:"proposer_slashings_count"` - AttesterSlashingsCount uint64 `json:"attester_slashings_count"` - AttestationsCount uint64 `json:"attestations_count"` - DepositsCount uint64 `json:"deposits_count"` - WithdrawalsCount uint64 `json:"withdrawals_count"` - BLSChangesCount uint64 `json:"bls_changes_count"` - VoluntaryExitsCount uint64 `json:"voluntaryexits_count"` - SlashingsCount uint64 `json:"slashings_count"` - BlobsCount uint64 `json:"blobs_count"` - ExecutionProofsCount uint64 `json:"execution_proofs_count"` - TransactionsCount uint64 `json:"transactions_count"` - DepositRequestsCount uint64 `json:"deposit_receipts_count"` - WithdrawalRequestsCount uint64 `json:"withdrawal_requests_count"` - ConsolidationRequestsCount uint64 `json:"consolidation_requests_count"` - RequestsFromParentPayload bool `json:"requests_from_parent_payload"` - BidsCount uint64 `json:"bids_count"` - PtcVotesCount uint64 `json:"ptc_votes_count"` + BlockRoot []byte `json:"blockroot" ssz-size:"32"` + ParentRoot []byte `json:"parentroot" ssz-size:"32"` + StateRoot []byte `json:"stateroot" ssz-size:"32"` + BodyRoot []byte `json:"bodyroot" ssz-size:"32"` + Signature []byte `json:"signature" ssz-size:"96"` + RandaoReveal []byte `json:"randaoreveal" ssz-size:"96"` + Graffiti []byte `json:"graffiti"` + Eth1dataDepositroot []byte `json:"eth1data_depositroot" ssz-size:"32"` + Eth1dataDepositcount uint64 `json:"eth1data_depositcount"` + Eth1dataBlockhash []byte `json:"eth1data_blockhash" ssz-size:"32"` + SyncAggregateBits []byte `json:"syncaggregate_bits"` + SyncAggregateSignature []byte `json:"syncaggregate_signature" ssz-size:"96"` + SyncAggParticipation float64 `json:"syncaggregate_participation"` + SyncAggCommittee []types.NamedValidator `json:"syncaggregate_committee"` + ValidatorNames []SlotPageValidatorName `json:"validator_names"` + ProposerSlashingsCount uint64 `json:"proposer_slashings_count"` + AttesterSlashingsCount uint64 `json:"attester_slashings_count"` + AttestationsCount uint64 `json:"attestations_count"` + DepositsCount uint64 `json:"deposits_count"` + WithdrawalsCount uint64 `json:"withdrawals_count"` + BLSChangesCount uint64 `json:"bls_changes_count"` + VoluntaryExitsCount uint64 `json:"voluntaryexits_count"` + SlashingsCount uint64 `json:"slashings_count"` + BlobsCount uint64 `json:"blobs_count"` + ExecutionProofsCount uint64 `json:"execution_proofs_count"` + TransactionsCount uint64 `json:"transactions_count"` + DepositRequestsCount uint64 `json:"deposit_receipts_count"` + WithdrawalRequestsCount uint64 `json:"withdrawal_requests_count"` + ConsolidationRequestsCount uint64 `json:"consolidation_requests_count"` + BuilderDepositRequestsCount uint64 `json:"builder_deposit_requests_count"` + BuilderExitRequestsCount uint64 `json:"builder_exit_requests_count"` + RequestsFromParentPayload bool `json:"requests_from_parent_payload"` + BidsCount uint64 `json:"bids_count"` + PtcVotesCount uint64 `json:"ptc_votes_count"` SlotsPerEpoch uint64 `json:"slots_per_epoch"` TargetCommitteeSize uint64 `json:"target_committee_size"` @@ -92,23 +94,25 @@ type SlotPageBlockData struct { ExecutionData *SlotPageExecutionData `json:"execution_data"` PayloadDataUnavailable bool `json:"payload_data_unavailable"` - Attestations []*SlotPageAttestation `json:"attestations"` // Attestations included in this block - Deposits []*SlotPageDeposit `json:"deposits"` // Deposits included in this block - VoluntaryExits []*SlotPageVoluntaryExit `json:"voluntary_exits"` // Voluntary Exits included in this block - AttesterSlashings []*SlotPageAttesterSlashing `json:"attester_slashings"` // Attester Slashings included in this block - ProposerSlashings []*SlotPageProposerSlashing `json:"proposer_slashings"` // Proposer Slashings included in this block - BLSChanges []*SlotPageBLSChange `json:"bls_changes"` // BLSChanges included in this block - Withdrawals []*SlotPageWithdrawal `json:"withdrawals"` // Withdrawals included in this block - Blobs []*SlotPageBlob `json:"blobs"` // Blob sidecars included in this block - ExecutionProofs []*SlotPageExecutionProof `json:"execution_proofs"` // Execution proofs included in this block - Transactions []*SlotPageTransaction `json:"transactions"` // Transactions included in this block - DepositRequests []*SlotPageDepositRequest `json:"deposit_receipts"` // DepositRequests included in this block - WithdrawalRequests []*SlotPageWithdrawalRequest `json:"withdrawal_requests"` // WithdrawalRequests included in this block - ConsolidationRequests []*SlotPageConsolidationRequest `json:"consolidation_requests"` // ConsolidationRequests included in this block - Bids []*SlotPageBid `json:"bids"` // Execution payload bids for this block (ePBS) - PtcVotes *SlotPagePtcVotes `json:"ptc_votes"` // PTC votes included in this block (for previous slot) - InclusionLists []*SlotPageInclusionList `json:"inclusion_lists"` // Inclusion lists for this slot (EIP-7805) - InclusionListsCount uint64 `json:"inclusion_lists_count"` + Attestations []*SlotPageAttestation `json:"attestations"` // Attestations included in this block + Deposits []*SlotPageDeposit `json:"deposits"` // Deposits included in this block + VoluntaryExits []*SlotPageVoluntaryExit `json:"voluntary_exits"` // Voluntary Exits included in this block + AttesterSlashings []*SlotPageAttesterSlashing `json:"attester_slashings"` // Attester Slashings included in this block + ProposerSlashings []*SlotPageProposerSlashing `json:"proposer_slashings"` // Proposer Slashings included in this block + BLSChanges []*SlotPageBLSChange `json:"bls_changes"` // BLSChanges included in this block + Withdrawals []*SlotPageWithdrawal `json:"withdrawals"` // Withdrawals included in this block + Blobs []*SlotPageBlob `json:"blobs"` // Blob sidecars included in this block + ExecutionProofs []*SlotPageExecutionProof `json:"execution_proofs"` // Execution proofs included in this block + Transactions []*SlotPageTransaction `json:"transactions"` // Transactions included in this block + DepositRequests []*SlotPageDepositRequest `json:"deposit_receipts"` // DepositRequests included in this block + WithdrawalRequests []*SlotPageWithdrawalRequest `json:"withdrawal_requests"` // WithdrawalRequests included in this block + ConsolidationRequests []*SlotPageConsolidationRequest `json:"consolidation_requests"` // ConsolidationRequests included in this block + BuilderDepositRequests []*SlotPageBuilderDepositRequest `json:"builder_deposit_requests"` // Builder deposit requests processed by this block (Gloas) + BuilderExitRequests []*SlotPageBuilderExitRequest `json:"builder_exit_requests"` // Builder exit requests processed by this block (Gloas) + Bids []*SlotPageBid `json:"bids"` // Execution payload bids for this block (ePBS) + PtcVotes *SlotPagePtcVotes `json:"ptc_votes"` // PTC votes included in this block (for previous slot) + InclusionLists []*SlotPageInclusionList `json:"inclusion_lists"` // Inclusion lists for this slot (EIP-7805) + InclusionListsCount uint64 `json:"inclusion_lists_count"` } type SlotPageExecutionData struct { @@ -333,6 +337,24 @@ type SlotPageConsolidationRequest struct { Epoch uint64 `db:"epoch"` } +type SlotPageBuilderDepositRequest struct { + PublicKey []byte `json:"pubkey" ssz-size:"48"` + WithdrawalCreds []byte `json:"withdrawal_creds" ssz-size:"32"` + Amount uint64 `json:"amount"` + Signature []byte `json:"signature" ssz-size:"96"` + HasBuilderIndex bool `json:"has_builder_index"` + BuilderIndex uint64 `json:"builder_index"` + IsInactiveBuilder bool `json:"is_inactive_builder"` // pubkey is a known builder but its index was reused +} + +type SlotPageBuilderExitRequest struct { + SourceAddress []byte `json:"source_address" ssz-size:"20"` + PublicKey []byte `json:"pubkey" ssz-size:"48"` + HasBuilderIndex bool `json:"has_builder_index"` + BuilderIndex uint64 `json:"builder_index"` + IsInactiveBuilder bool `json:"is_inactive_builder"` // pubkey is a known builder but its index was reused +} + type SlotPageBid struct { ParentRoot []byte `json:"parent_root"` ParentHash []byte `json:"parent_hash"` diff --git a/types/models/submit_builder_deposit.go b/types/models/submit_builder_deposit.go new file mode 100644 index 000000000..75b0fa9bf --- /dev/null +++ b/types/models/submit_builder_deposit.go @@ -0,0 +1,12 @@ +package models + +// SubmitBuilderDepositPageData is the page data for the submit builder deposit page. +type SubmitBuilderDepositPageData struct { + NetworkName string `json:"netname"` + PublicRPCUrl string `json:"pubrpc"` + RainbowkitProjectId string `json:"rainbowkit"` + ChainId uint64 `json:"chainid"` + BuilderDepositContract string `json:"builderdepositcontract"` + GenesisForkVersion []byte `json:"genesisforkversion"` + ExplorerUrl string `json:"explorerurl"` +} diff --git a/types/models/submit_builder_exit.go b/types/models/submit_builder_exit.go new file mode 100644 index 000000000..ff6069dec --- /dev/null +++ b/types/models/submit_builder_exit.go @@ -0,0 +1,19 @@ +package models + +// SubmitBuilderExitPageData is the page data for the submit builder exit page. +type SubmitBuilderExitPageData struct { + NetworkName string `json:"netname"` + PublicRPCUrl string `json:"pubrpc"` + RainbowkitProjectId string `json:"rainbowkit"` + ChainId uint64 `json:"chainid"` + BuilderExitContract string `json:"builderexitcontract"` + ExplorerUrl string `json:"explorerurl"` +} + +// SubmitBuilderExitPageDataBuilder is a builder owned by the connected wallet, offered for exit. +type SubmitBuilderExitPageDataBuilder struct { + Index uint64 `json:"index"` + Pubkey string `json:"pubkey"` + ExecutionAddress string `json:"executionAddress"` + Status string `json:"status"` +} diff --git a/types/models/voluntary_exits.go b/types/models/voluntary_exits.go index 685dbf825..2d900a182 100644 --- a/types/models/voluntary_exits.go +++ b/types/models/voluntary_exits.go @@ -6,7 +6,6 @@ import ( // VoluntaryExitsPageData is a struct to hold info for the voluntary_exits page type VoluntaryExitsPageData struct { - FilterEntity string `json:"filter_entity"` // "all", "validator", or "builder" FilterMinSlot uint64 `json:"filter_mins"` FilterMaxSlot uint64 `json:"filter_maxs"` FilterMinIndex uint64 `json:"filter_mini"` @@ -42,7 +41,6 @@ type VoluntaryExitsPageDataExit struct { Orphaned bool `json:"orphaned"` ValidatorIndex uint64 `json:"vindex"` ValidatorName string `json:"vname"` - IsBuilder bool `json:"is_builder"` PublicKey []byte `json:"pubkey" ssz-size:"48"` WithdrawalCreds []byte `json:"wdcreds" ssz-size:"32"` ValidatorStatus string `json:"vstatus"` diff --git a/ui-package/src/components/SubmitBuilderDepositsForm/BuilderDepositEntry.tsx b/ui-package/src/components/SubmitBuilderDepositsForm/BuilderDepositEntry.tsx new file mode 100644 index 000000000..d0cb8b681 --- /dev/null +++ b/ui-package/src/components/SubmitBuilderDepositsForm/BuilderDepositEntry.tsx @@ -0,0 +1,96 @@ +import React, { useState } from 'react'; +import { useAccount, useSendTransaction } from 'wagmi'; +import { Modal } from 'react-bootstrap'; + +import { IDeposit } from '../SubmitDepositsForm/DepositsTable'; +import { toReadableAmount } from '../../utils/ReadableAmount'; + +interface IBuilderDepositEntryProps { + deposit: IDeposit; + builderDepositContract: string; + requestFee: bigint; + explorerUrl?: string; +} + +const GWEI = 1000000000n; + +const BuilderDepositEntry = (props: IBuilderDepositEntryProps): React.ReactElement => { + const { address, chain } = useAccount(); + const [errorModal, setErrorModal] = useState(null); + const submitRequest = useSendTransaction(); + + const { deposit } = props; + const pubkey = deposit.pubkey.replace(/^0x/, ""); + const wdCreds = deposit.withdrawal_credentials.replace(/^0x/, ""); + const signature = deposit.signature.replace(/^0x/, ""); + const amountValue = BigInt(deposit.amount) * GWEI; // stake (wei) + const totalValue = amountValue + props.requestFee; + + return ( + + 0x{pubkey} + 0x{wdCreds} + {toReadableAmount(deposit.amount, 9, "ETH", 9)} + + {deposit.validity ? + Valid + : Invalid} + + + + {submitRequest.isSuccess && submitRequest.data ? +
+ {props.explorerUrl ? + tx + : {submitRequest.data}} +
+ : null} + {errorModal && ( + setErrorModal(null)} size="lg"> + + Builder Deposit Transaction Failed + +
{errorModal}
+ + + +
+ )} + + + ); + + function submitDeposit() { + // calldata (184 bytes): pubkey(48) ++ withdrawal_credentials(32) ++ amount(8, big-endian) ++ signature(96) + // msg.value = stake (amount in wei) + predeploy queue fee. source is implicit (the deposit signature). + const amountHex = deposit.amount.toString(16).padStart(16, "0"); + const data = ("0x" + pubkey + wdCreds + amountHex + signature) as `0x${string}`; + + submitRequest.sendTransactionAsync({ + to: props.builderDepositContract as `0x${string}`, + account: address, + chainId: chain?.id, + value: totalValue, + data, + gas: 300000n, + }).then(tx => { + console.log(tx); + }).catch(error => { + setErrorModal(error.message); + }); + } +}; + +export default BuilderDepositEntry; diff --git a/ui-package/src/components/SubmitBuilderDepositsForm/BuilderDepositsTable.tsx b/ui-package/src/components/SubmitBuilderDepositsForm/BuilderDepositsTable.tsx new file mode 100644 index 000000000..ab60292c6 --- /dev/null +++ b/ui-package/src/components/SubmitBuilderDepositsForm/BuilderDepositsTable.tsx @@ -0,0 +1,218 @@ +import React, { useEffect, useState } from 'react'; +import { ContainerType, ByteVectorType, UintNumberType, ValueOf } from "@chainsafe/ssz"; +import bls from "@chainsafe/bls/herumi"; +import { useAccount } from 'wagmi'; + +import { IDeposit } from '../SubmitDepositsForm/DepositsTable'; +import { useQueueDataCache } from '../../hooks/useQueueDataCache'; +import { toReadableAmount } from '../../utils/ReadableAmount'; +import BuilderDepositEntry from './BuilderDepositEntry'; + +interface IBuilderDepositsTableProps { + file?: File | null; + deposits?: IDeposit[] | null; + genesisForkVersion: string; + builderDepositContract: string; + explorerUrl?: string; +} + +const DepositMessage = new ContainerType({ + pubkey: new ByteVectorType(48), + withdrawal_credentials: new ByteVectorType(32), + amount: new UintNumberType(8), +}); +type DepositMessage = ValueOf; + +const ForkData = new ContainerType({ + current_version: new ByteVectorType(4), + genesis_validators_root: new ByteVectorType(32), +}); + +const SigningData = new ContainerType({ + object_root: new ByteVectorType(32), + domain: new ByteVectorType(32), +}); +type SigningData = ValueOf; + +const BuilderDepositsTable = (props: IBuilderDepositsTableProps): React.ReactElement => { + const { chain } = useAccount(); + const [deposits, setDeposits] = useState(null); + const [parseError, setParseError] = useState(null); + const [blsReady, setBlsReady] = useState(false); + const [addExtraFee, setAddExtraFee] = useState(true); + + const { queueData, logData: cachedLogData } = useQueueDataCache(props.builderDepositContract, chain?.id); + + useEffect(() => { + import('@chainsafe/bls/herumi').then((m) => m.init().then(() => setBlsReady(true))); + }, []); + + const dataSource = props.deposits ? 'generated' : props.file ? 'file' : null; + const dataSourceKey = props.deposits ? 'generated' : props.file?.name; + + useEffect(() => { + if (!dataSource || !blsReady) return; + parseDeposits().then((res) => { + setDeposits(res); + setParseError(null); + }).catch((error) => { + setParseError(error.message); + setDeposits(null); + }); + }, [dataSourceKey, blsReady]); + + // Compute the predeploy queue fee (shared across all rows). + let queueLength = 0n; + let isPreFork = false; + let requiredFee = 0n; + let requestFee = 0n; + let avgRequestPerBlock = 0; + const logLookbackRange = 10; + if (queueData && !queueData.error && !queueData.isLoading) { + queueLength = queueData.queueLength; + if (queueLength === 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffn) { + isPreFork = true; + } else { + requiredFee = getRequiredFee(queueLength); + if (addExtraFee && cachedLogData) { + for (let block in cachedLogData.logCount) avgRequestPerBlock += cachedLogData.logCount[block]; + avgRequestPerBlock /= logLookbackRange; + let extra = avgRequestPerBlock < 2 ? 3 : avgRequestPerBlock + 1; + requestFee = getRequiredFee(queueLength + BigInt(Math.ceil(extra))); + } else { + requestFee = requiredFee; + } + } + } + + let feeFactor = 0; + let feeUnit = "Wei"; + if (requestFee > 100000000000000n) { feeFactor = 18; feeUnit = "ETH"; } + else if (requestFee > 100000n) { feeFactor = 9; feeUnit = "Gwei"; } + + return ( +
+
+
Step 3: Review and submit builder deposits
+
+
+
Deposit Source:
+
+ {props.file ? props.file.name : Generated (devnet only)} +
+
+ + {parseError ? +
The provided file is not a valid builder deposit data file! ({parseError})
+ : isPreFork ? +
The network is not on Gloas yet, so builder deposit requests can not be submitted.
+ : <> +
+
Builder Deposit Contract:
+
{props.builderDepositContract}
+
+
+
Deposit Queue:
+
{queueLength.toString()} Deposits
+
+
+
Queue fee per deposit:
+
+ {toReadableAmount(requestFee, feeFactor, feeUnit, 6)} + + setAddExtraFee(e.target.checked)} /> + + +
+
+ + {!deposits ?

Loading...

: deposits.length === 0 ?

No deposits found

: ( +
+ + + + + + + + + + + + {deposits.map((deposit: IDeposit) => ( + + ))} + +
PubkeyWithdrawal CredentialsAmountValidityActions
+
+ )} + + } +
+ ); + + async function parseDeposits(): Promise { + let json: IDeposit[]; + if (props.deposits) { + json = props.deposits; + } else if (props.file) { + json = JSON.parse(await props.file.text()); + } else { + throw new Error("No deposit data provided"); + } + + // builder-deposit signing domain: DOMAIN_BUILDER_DEPOSIT = 0x0E000000 + const forkData = ForkData.fromJson({ + current_version: props.genesisForkVersion, + genesis_validators_root: "0x0000000000000000000000000000000000000000000000000000000000000000", + }); + const forkDataRoot = ForkData.hashTreeRoot(forkData); + const signingDomain = new Uint8Array(32); + signingDomain.set([0x0e, 0x00, 0x00, 0x00]); + signingDomain.set(forkDataRoot.slice(0, 28), 4); + + return json.map((deposit: IDeposit) => { + const credsOk = deposit.withdrawal_credentials.replace(/^0x/, "").substring(0, 2).toLowerCase() === "03"; + deposit.validity = props.deposits ? credsOk : (credsOk && verifyDeposit(deposit, signingDomain)); + return deposit; + }); + } + + function verifyDeposit(deposit: IDeposit, signingDomain: Uint8Array): boolean { + try { + const depositMessage: DepositMessage = DepositMessage.fromJson({ + pubkey: deposit.pubkey, + withdrawal_credentials: deposit.withdrawal_credentials, + amount: deposit.amount, + }); + const depositRoot = DepositMessage.hashTreeRoot(depositMessage); + const signature = bls.Signature.fromHex(deposit.signature); + const pubkey = bls.PublicKey.fromHex(deposit.pubkey); + const signingData: SigningData = { object_root: depositRoot, domain: signingDomain }; + const signingDataRoot = SigningData.hashTreeRoot(signingData); + return signature.verify(pubkey, signingDataRoot); + } catch { + return false; + } + } + + function getRequiredFee(numerator: bigint): bigint { + let i = 1n; + let output = 0n; + let numeratorAccum = 1n * 17n; + while (numeratorAccum > 0n) { + output += numeratorAccum; + numeratorAccum = (numeratorAccum * numerator) / (17n * i); + i += 1n; + } + return output / 17n; + } +}; + +export default BuilderDepositsTable; diff --git a/ui-package/src/components/SubmitBuilderDepositsForm/SubmitBuilderDepositsForm.tsx b/ui-package/src/components/SubmitBuilderDepositsForm/SubmitBuilderDepositsForm.tsx new file mode 100644 index 000000000..7cfe8da62 --- /dev/null +++ b/ui-package/src/components/SubmitBuilderDepositsForm/SubmitBuilderDepositsForm.tsx @@ -0,0 +1,102 @@ +import React, { useState } from 'react'; +import { ConnectButton } from '@rainbow-me/rainbowkit'; +import { useAccount } from 'wagmi'; + +import { ISubmitBuilderDepositsFormProps } from './SubmitBuilderDepositsFormProps'; +import { IDeposit } from '../SubmitDepositsForm/DepositsTable'; +import DepositGeneratorModal from '../SubmitDepositsForm/DepositGeneratorModal'; +import BuilderDepositsTable from './BuilderDepositsTable'; +import '../SubmitDepositsForm/SubmitDepositsForm.scss'; + +const SubmitBuilderDepositsForm = (props: ISubmitBuilderDepositsFormProps): React.ReactElement => { + const { address: walletAddress, isConnected } = useAccount(); + + const [file, setFile] = useState(null); + const [generatedDeposits, setGeneratedDeposits] = useState(null); + const [refreshIdx, setRefreshIdx] = useState(0); + const [showGeneratorModal, setShowGeneratorModal] = useState(false); + + return ( +
+
+
+

Submit builder deposits

+

This tool submits builder deposits to the builder deposit contract. Builder deposits carry a 0x03 withdrawal credential and a proof-of-possession signed under the dedicated builder-deposit domain.

+
+ Don't provide your keystore or mnemonic to us or any other website. The generator below is for devnet testing only. +
+
+
+ +
+
Step 1: Connect your wallet
+
+
+
+ +
+
+ +
+
+ +
+ ) => { + if (e.target.files) { + setFile(e.target.files[0]); + setGeneratedDeposits(null); + setRefreshIdx(refreshIdx + 1); + } + }} + /> + or + +
+

The deposit data file is a JSON array of builder deposits (pubkey, 0x03 withdrawal_credentials, amount, signature).

+
+ + {(file || generatedDeposits) && isConnected && ( + + )} + {(file || generatedDeposits) && !isConnected && ( +
Connect your wallet to review and submit the deposits.
+ )} +
+ + {showGeneratorModal && ( + setShowGeneratorModal(false)} + onGenerate={(deposits) => { + setGeneratedDeposits(deposits); + setFile(null); + setShowGeneratorModal(false); + setRefreshIdx(refreshIdx + 1); + }} + /> + )} +
+ ); +}; + +export default SubmitBuilderDepositsForm; diff --git a/ui-package/src/components/SubmitBuilderDepositsForm/SubmitBuilderDepositsFormProps.ts b/ui-package/src/components/SubmitBuilderDepositsForm/SubmitBuilderDepositsFormProps.ts new file mode 100644 index 000000000..8f9f769ff --- /dev/null +++ b/ui-package/src/components/SubmitBuilderDepositsForm/SubmitBuilderDepositsFormProps.ts @@ -0,0 +1,5 @@ +export interface ISubmitBuilderDepositsFormProps { + builderDepositContract: string; + genesisForkVersion: string; + explorerUrl?: string; +} diff --git a/ui-package/src/components/SubmitBuilderExitsForm/BuilderExitReview.tsx b/ui-package/src/components/SubmitBuilderExitsForm/BuilderExitReview.tsx new file mode 100644 index 000000000..8f16aa2f8 --- /dev/null +++ b/ui-package/src/components/SubmitBuilderExitsForm/BuilderExitReview.tsx @@ -0,0 +1,204 @@ +import React, { useRef, useState } from 'react'; +import { useAccount, useSendTransaction } from 'wagmi'; +import { Modal } from 'react-bootstrap'; + +import { toReadableAmount } from '../../utils/ReadableAmount'; +import { useQueueDataCache } from '../../hooks/useQueueDataCache'; + +interface IBuilderExitReviewProps { + pubkey: string; + builderExitContract: string; + explorerUrl: string; +} + +const BuilderExitReview = (props: IBuilderExitReviewProps) => { + const { address, chain } = useAccount(); + const [addExtraFee, setAddExtraFee] = useState(true); + const [errorModal, setErrorModal] = useState(null); + const componentRef = useRef(null); + + const logLookbackRange = 10; + + const submitRequest = useSendTransaction(); + const { queueData, logData: cachedLogData, refetch: refetchQueueData, isLoading: cacheLoading } = useQueueDataCache(props.builderExitContract, chain?.id); + + let queueLength = 0n; + let avgRequestPerBlock = 0; + let isPreFork = false; + let requiredFee = 0n; + let requestFee = 0n; + let failedQueueLength = false; + let isLoading = cacheLoading; + let error: Error | null = null; + + if (queueData) { + isLoading = queueData.isLoading; + error = queueData.error; + + if (!queueData.error && !queueData.isLoading) { + queueLength = queueData.queueLength; + + if (queueLength === 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffn) { + isPreFork = true; + } else { + requiredFee = getRequiredFee(queueLength); + + if (addExtraFee && cachedLogData) { + for (let block in cachedLogData.logCount) { + avgRequestPerBlock += cachedLogData.logCount[block]; + } + avgRequestPerBlock /= logLookbackRange; + + let extraFeeForRequest = avgRequestPerBlock; + if (extraFeeForRequest < 2) { + extraFeeForRequest = 3; + } else { + extraFeeForRequest++; + } + + requestFee = getRequiredFee(queueLength + BigInt(Math.ceil(extraFeeForRequest))); + } else { + requestFee = requiredFee; + } + } + } else if (error) { + failedQueueLength = true; + } + } + + let feeFactor = 0; + let feeUnit = "Wei"; + if (requestFee > 100000000000000n) { + feeFactor = 18; + feeUnit = "ETH"; + } else if (requestFee > 100000n) { + feeFactor = 9; + feeUnit = "Gwei"; + } + + return ( +
+ {error ? +
+ Error loading queue length from builder exit contract.
+ {error?.message}
+ +
+ : isLoading ? +

Loading...

+ : failedQueueLength ? +
+ Error loading queue length from builder exit contract. (check contract address: {props.builderExitContract}) +
+ : isPreFork ? +
+ The network is not on Gloas yet, so builder exit requests can not be submitted. +
+ :
+
+
Builder Exit Contract:
+
{props.builderExitContract}
+
+
+
Exit Queue:
+
{queueLength.toString()} Exits
+
+
+
Required queue fee:
+
{toReadableAmount(requiredFee, feeFactor, feeUnit, 4)}
+
+
+
Add extra fee:
+
+ setAddExtraFee(e.target.checked)} /> + +
+
+ {addExtraFee && +
+
Avg. requests per block:
+
{avgRequestPerBlock.toFixed(2)} (last {logLookbackRange} blocks)
+
} + {addExtraFee && +
+
Extra fee:
+
{toReadableAmount(requestFee - requiredFee, feeFactor, feeUnit, 4)}
+
} +
+
Total fee:
+
{toReadableAmount(requestFee, feeFactor, feeUnit, 4)}
+
+
+
+ +
+
+ {submitRequest.isSuccess ? +
+
+
+ Exit TX: + {props.explorerUrl ? + {submitRequest.data} + : {submitRequest.data}} +
+
+
+ : null} +
+ } + {errorModal && ( + setErrorModal(null)} size="lg"> + + Builder Exit Transaction Failed + +
{errorModal}
+ + + +
+ )} +
+ ); + + function getRequiredFee(numerator: bigint): bigint { + // https://eips.ethereum.org/EIPS/eip-7002#fee-calculation (shared predeploy fee mechanism) + let i = 1n; + let output = 0n; + let numeratorAccum = 1n * 17n; // factor * denominator + + while (numeratorAccum > 0n) { + output += numeratorAccum; + numeratorAccum = (numeratorAccum * numerator) / (17n * i); + i += 1n; + } + + return output / 17n; + } + + function submitExit() { + submitRequest.sendTransactionAsync({ + to: props.builderExitContract as `0x${string}`, + account: address, + chainId: chain?.id, + value: requestFee, + // calldata (48 bytes): builder pubkey. source_address = msg.sender. + data: ("0x" + props.pubkey.replace(/^0x/, "")) as `0x${string}`, + gas: 200000n, + }).then(tx => { + console.log(tx); + }).catch(error => { + setErrorModal(error.message); + }); + } +}; + +export default BuilderExitReview; diff --git a/ui-package/src/components/SubmitBuilderExitsForm/SubmitBuilderExitsForm.tsx b/ui-package/src/components/SubmitBuilderExitsForm/SubmitBuilderExitsForm.tsx new file mode 100644 index 000000000..331077f6e --- /dev/null +++ b/ui-package/src/components/SubmitBuilderExitsForm/SubmitBuilderExitsForm.tsx @@ -0,0 +1,144 @@ +import React, { useEffect, useState } from 'react'; +import { ConnectButton } from '@rainbow-me/rainbowkit'; +import { useAccount } from 'wagmi'; + +import { ISubmitBuilderExitsFormProps, IBuilder } from './SubmitBuilderExitsFormProps'; +import BuilderExitReview from './BuilderExitReview'; + +const PUBKEY_RE = /^0x[0-9a-fA-F]{96}$/; + +const SubmitBuilderExitsForm = (props: ISubmitBuilderExitsFormProps): React.ReactElement => { + const { address: walletAddress, isConnected, chain } = useAccount(); + const [builders, setBuilders] = useState(null); + const [loadingError, setLoadingError] = useState(null); + const [manualMode, setManualMode] = useState(false); + const [selectedPubkey, setSelectedPubkey] = useState(''); + const [manualPubkey, setManualPubkey] = useState(''); + + useEffect(() => { + if (walletAddress) { + setBuilders(null); + setLoadingError(null); + props.loadBuildersCallback(walletAddress).then(setBuilders).catch((e) => setLoadingError(String(e))); + } else { + setBuilders(null); + } + }, [walletAddress, props.loadBuildersCallback]); + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + (window as any).explorer?.initControls?.(); + }, 100); + return () => window.clearTimeout(timeoutId); + }, []); + + const pubkey = manualMode ? manualPubkey.trim() : selectedPubkey; + const pubkeyValid = PUBKEY_RE.test(pubkey); + + return ( +
+
+
+

Submit builder exit requests

+

This tool creates an exit request for a builder you own. The connected wallet must be the builder's execution (withdrawal) address.

+
+
+ +
+
Step 1: Connect your wallet
+
+
+
+ +
+
+ + {isConnected && chain ? + <> +
+
+ +
+
+
+ Select one of the builders owned by your address, or enter a builder public key manually. +
+
+
+ + {loadingError ? +
+ + Could not load builders for your address: {loadingError}. You can still enter a pubkey manually below. +
+ : builders == null ? +
Please wait while we load your builders...
+ : null} + + {!manualMode && builders && builders.length > 0 ? +
+
+ +
+
+ : null} + + {!manualMode && builders && builders.length === 0 && !loadingError ? +
No active builders found for your address. You can enter a pubkey manually below.
+ : null} + +
+
+
+ setManualMode(e.target.checked)} /> + +
+
+
+ + {manualMode ? +
+
+ setManualPubkey(e.target.value)} + /> + {manualPubkey && !pubkeyValid ? +
Enter a valid 48-byte (96 hex char) public key.
+ : null} +
+
+ : null} + + {pubkeyValid ? + <> +
+
+ +
+
+ + + : null} + + : null} +
+ ); +}; + +export default SubmitBuilderExitsForm; diff --git a/ui-package/src/components/SubmitBuilderExitsForm/SubmitBuilderExitsFormProps.ts b/ui-package/src/components/SubmitBuilderExitsForm/SubmitBuilderExitsFormProps.ts new file mode 100644 index 000000000..48ada5907 --- /dev/null +++ b/ui-package/src/components/SubmitBuilderExitsForm/SubmitBuilderExitsFormProps.ts @@ -0,0 +1,12 @@ +export interface ISubmitBuilderExitsFormProps { + builderExitContract: string; + explorerUrl: string; + loadBuildersCallback: (address: string) => Promise; +} + +export interface IBuilder { + index: number; + pubkey: string; + executionAddress: string; + status: string; +} diff --git a/ui-package/src/components/SubmitDepositsForm/DepositGenerator.ts b/ui-package/src/components/SubmitDepositsForm/DepositGenerator.ts index f440f8f08..89c7fca10 100644 --- a/ui-package/src/components/SubmitDepositsForm/DepositGenerator.ts +++ b/ui-package/src/components/SubmitDepositsForm/DepositGenerator.ts @@ -32,6 +32,10 @@ const SigningData = new ContainerType({ export type CredentialType = '00' | '01' | '02' | '03'; +// DepositDomainType selects the signing domain: regular validator deposits use +// DOMAIN_DEPOSIT (0x03000000); builder deposits use DOMAIN_BUILDER_DEPOSIT (0x0E000000). +export type DepositDomainType = 'deposit' | 'builder'; + export interface WithdrawalCredentialConfig { type: CredentialType; address?: string; // For 0x01/0x02: ETH address @@ -133,7 +137,8 @@ export function validateWithdrawalCredentials(credentials: string): boolean { */ export async function generateDeposits( config: GeneratorConfig, - genesisForkVersion: string + genesisForkVersion: string, + domainType: DepositDomainType = 'deposit' ): Promise { // Note: BLS library must be initialized before calling this function // The caller (DepositGeneratorModal) handles BLS initialization @@ -155,7 +160,7 @@ export async function generateDeposits( const masterKey = deriveKeyFromMnemonic(normalizedMnemonic); // Compute signing domain - const signingDomain = computeSigningDomain(genesisForkVersion); + const signingDomain = computeSigningDomain(genesisForkVersion, domainType); const deposits: IDeposit[] = []; @@ -274,7 +279,7 @@ function generateSingleDeposit( }; } -function computeSigningDomain(genesisForkVersion: string): Uint8Array { +function computeSigningDomain(genesisForkVersion: string, domainType: DepositDomainType = 'deposit'): Uint8Array { const forkVersionBytes = hexToBytes(genesisForkVersion); const forkData = { @@ -283,9 +288,10 @@ function computeSigningDomain(genesisForkVersion: string): Uint8Array { }; const forkDataRoot = ForkData.hashTreeRoot(forkData); - // DOMAIN_DEPOSIT = 0x03000000 + // DOMAIN_DEPOSIT = 0x03000000, DOMAIN_BUILDER_DEPOSIT = 0x0E000000 (Gloas/EIP-8282) + const domainPrefix = domainType === 'builder' ? 0x0e : 0x03; const signingDomain = new Uint8Array(32); - signingDomain.set([0x03, 0x00, 0x00, 0x00]); + signingDomain.set([domainPrefix, 0x00, 0x00, 0x00]); signingDomain.set(forkDataRoot.slice(0, 28), 4); return signingDomain; diff --git a/ui-package/src/components/SubmitDepositsForm/DepositGeneratorModal.tsx b/ui-package/src/components/SubmitDepositsForm/DepositGeneratorModal.tsx index ad2b073e8..430793ca1 100644 --- a/ui-package/src/components/SubmitDepositsForm/DepositGeneratorModal.tsx +++ b/ui-package/src/components/SubmitDepositsForm/DepositGeneratorModal.tsx @@ -12,6 +12,7 @@ import { ValidatorOverride, CredentialType, WithdrawalCredentialConfig, + DepositDomainType, } from './DepositGenerator'; interface IDepositGeneratorModalProps { @@ -19,6 +20,10 @@ interface IDepositGeneratorModalProps { defaultWithdrawalAddress?: string; onClose: () => void; onGenerate: (deposits: IDeposit[]) => void; + // Builder mode (Gloas/EIP-8282): sign under DOMAIN_BUILDER_DEPOSIT and lock the + // withdrawal credential to the 0x03 builder prefix. + domainType?: DepositDomainType; + lockBuilderCredentials?: boolean; } type ActiveTab = 'basic' | 'overrides'; @@ -37,7 +42,7 @@ interface IValidatorOverrideState { } const DepositGeneratorModal: React.FC = (props) => { - const { genesisForkVersion, defaultWithdrawalAddress, onClose, onGenerate } = props; + const { genesisForkVersion, defaultWithdrawalAddress, onClose, onGenerate, domainType, lockBuilderCredentials } = props; const [activeTab, setActiveTab] = useState('basic'); const [isGenerating, setIsGenerating] = useState(false); @@ -51,7 +56,7 @@ const DepositGeneratorModal: React.FC = (props) => const [validatorCount, setValidatorCount] = useState(1); const [amountEth, setAmountEth] = useState('32'); const [credentialInputMode, setCredentialInputMode] = useState('type'); - const [credentialType, setCredentialType] = useState('01'); + const [credentialType, setCredentialType] = useState(lockBuilderCredentials ? '03' : '01'); const [withdrawalAddress, setWithdrawalAddress] = useState(defaultWithdrawalAddress || ''); const [rawCredentials, setRawCredentials] = useState(''); @@ -204,7 +209,7 @@ const DepositGeneratorModal: React.FC = (props) => overrides: validatorOverrides, }; - const deposits = await generateDeposits(config, genesisForkVersion); + const deposits = await generateDeposits(config, genesisForkVersion, domainType ?? 'deposit'); onGenerate(deposits); } catch (error) { setGenerationError(error instanceof Error ? error.message : String(error)); @@ -398,12 +403,19 @@ const DepositGeneratorModal: React.FC = (props) =>

{credentialType !== '00' && ( diff --git a/ui-package/src/main.tsx b/ui-package/src/main.tsx index ff98198a8..948300560 100644 --- a/ui-package/src/main.tsx +++ b/ui-package/src/main.tsx @@ -5,6 +5,8 @@ import { IWagmiRainbowProviderProps, IWagmiRainbowProviderConfig } from './compo import { ISubmitDepositsFormProps } from './components/SubmitDepositsForm/SubmitDepositsFormProps'; import { ISubmitConsolidationsFormProps } from './components/SubmitConsolidationsForm/SubmitConsolidationsFormProps'; import { ISubmitWithdrawalsFormProps } from './components/SubmitWithdrawalsForm/SubmitWithdrawalsFormProps'; +import { ISubmitBuilderDepositsFormProps } from './components/SubmitBuilderDepositsForm/SubmitBuilderDepositsFormProps'; +import { ISubmitBuilderExitsFormProps } from './components/SubmitBuilderExitsForm/SubmitBuilderExitsFormProps'; export interface IComponentExports { [component: string]: (container: HTMLElement, cfg: any) => IComponentControls } @@ -52,6 +54,30 @@ function exportComponents(uiPackages: IComponentExports) { ) } ); + + // SubmitBuilderDepositsForm component + const SubmitBuilderDepositsForm = React.lazy>(() => import(/* webpackChunkName: "submit-builder-deposit" */ './components/SubmitBuilderDepositsForm/SubmitBuilderDepositsForm')); + uiPackages.SubmitBuilderDepositsForm = buildComponentLoader<{wagmiConfig: IWagmiRainbowProviderConfig, submitBuilderDepositsConfig: ISubmitBuilderDepositsFormProps}>( + (config) => { + return ( + + + + ) + } + ); + + // SubmitBuilderExitsForm component + const SubmitBuilderExitsForm = React.lazy>(() => import(/* webpackChunkName: "submit-builder-exit" */ './components/SubmitBuilderExitsForm/SubmitBuilderExitsForm')); + uiPackages.SubmitBuilderExitsForm = buildComponentLoader<{wagmiConfig: IWagmiRainbowProviderConfig, submitBuilderExitsConfig: ISubmitBuilderExitsFormProps}>( + (config) => { + return ( + + + + ) + } + ); } function buildComponentLoader(loader: (cfg: TCfg) => React.ReactNode) { diff --git a/utils/format.go b/utils/format.go index 6dcfcd397..6bb59c71d 100644 --- a/utils/format.go +++ b/utils/format.go @@ -832,6 +832,53 @@ func formatValidator(index uint64, name string, icon string, withIndex bool) tem return template.HTML(fmt.Sprintf(" %v", icon, index, index)) } +// FormatProposerWithBuildSource renders a proposer label whose leading icon +// reflects the payload build source on Gloas+ blocks: a house for self-built +// payloads and a hard-hat (linking to the builder) for builder-built payloads. +// Pre-Gloas blocks (hasBuilder == false) fall back to the default validator icon. +// +// Scheduled/missing slots (status == 0) and unknown proposers have no +// determinable build source and are rendered without any leading icon. +func FormatProposerWithBuildSource(status uint8, index uint64, name string, hasBuilder bool, builderIndex uint64, builderURL string) template.HTML { + if status == 0 || index == math.MaxInt64 { + if index == math.MaxInt64 { + return template.HTML(`unknown`) + } + if name != "" { + return template.HTML(fmt.Sprintf(`%v`, index, html.EscapeString(name))) + } + return template.HTML(fmt.Sprintf(`%v`, index, index)) + } + + if !hasBuilder { + return FormatValidator(index, name) + } + + var iconHTML string + if builderIndex == math.MaxUint64 { + // self-built payload + iconHTML = `` + } else { + // builder-built payload - link the icon to the builder URL when known, + // otherwise to the internal builder page + builderLink := fmt.Sprintf("/builder/%v", builderIndex) + external := "" + if builderURL != "" { + builderLink = html.EscapeString(builderURL) + external = ` target="_blank" rel="noopener noreferrer"` + } + iconHTML = fmt.Sprintf(``, builderLink, external, builderIndex) + } + + nameLabel := fmt.Sprintf("%v", index) + labelClass := "validator-index" + if name != "" { + nameLabel = html.EscapeString(name) + labelClass = "validator-name" + } + return template.HTML(fmt.Sprintf(`%v %v`, labelClass, iconHTML, index, nameLabel)) +} + func FormatValidatorNameWithIndex(index uint64, name string) template.HTML { if name != "" { return template.HTML(fmt.Sprintf("%v (%v)", html.EscapeString(name), index)) @@ -843,6 +890,13 @@ func FormatBuilder(index uint64, name string) template.HTML { return formatBuilder(index, name, "", "fa-hard-hat mr-2", false) } +// FormatInactiveBuilder renders a builder whose index has been reused by a different builder +// (EIP-8282). Since the index no longer identifies this pubkey, it links to the details page by +// pubkey instead of by index. +func FormatInactiveBuilder(pubkey []byte) template.HTML { + return template.HTML(fmt.Sprintf(" (inactive)", pubkey)) +} + func FormatBuilderWithIndex(index uint64, name string) template.HTML { return formatBuilder(index, name, "", "fa-hard-hat mr-2", true) } diff --git a/utils/templateFucs.go b/utils/templateFucs.go index 87a10946e..4680d1faf 100644 --- a/utils/templateFucs.go +++ b/utils/templateFucs.go @@ -109,66 +109,68 @@ func GetTemplateFuncs() template.FuncMap { "round": func(i float64, n int) float64 { return math.Round(i*math.Pow10(n)) / math.Pow10(n) }, - "uint64ToTime": func(i uint64) time.Time { return time.Unix(int64(i), 0).UTC() }, - "percent": func(i float64) float64 { return i * 100 }, - "contains": strings.Contains, - "formatAddCommas": FormatAddCommas, - "formatFloat": FormatFloat, - "formatTokenAmount": FormatTokenAmount, - "formatBaseFee": FormatBaseFee, - "formatBlobFeeDifference": FormatBlobFeeDifference, - "formatTransactionValue": FormatTransactionValue, - "formatTransactionFee": FormatTransactionFee, - "formatBitlist": FormatBitlist, - "formatBitvectorValidators": formatBitvectorValidators, - "formatParticipation": FormatParticipation, - "formatEthFromGwei": FormatETHFromGwei, - "formatEthFromGweiP": FormatETHFromGweiP, - "formatEthFromGweiShort": FormatETHFromGweiShort, - "formatFullEthFromGwei": FormatFullEthFromGwei, - "formatEthAddCommasFromGwei": FormatETHAddCommasFromGwei, - "formatBytesAmount": FormatBytesAmount, - "formatAmount": FormatAmount, - "formatBigAmount": FormatBigAmount, - "formatAmountFormatted": FormatAmountFormatted, - "formatGwei": FormatGweiValue, - "formatByteAmount": FormatByteAmount, - "percentage": CalculatePercentage, - "ethBlockLink": FormatEthBlockLink, - "ethBlockHashLink": FormatEthBlockHashLink, - "ethAddressLink": FormatEthAddressLink, - "ethTransactionLink": FormatEthTransactionLink, - "formatEthAddress": FormatEthAddress, - "formatEthAddressShort": FormatEthAddressShort, - "formatEthAddressShortLink": FormatEthAddressShortLink, - "formatEthAddressFull": FormatEthAddressFull, - "formatEthAddressFullLink": FormatEthAddressFullLink, - "formatHexBytes": FormatHexBytes, - "formatHexBytesShort": FormatHexBytesShort, - "formatWeiAmount": FormatWeiAmount, - "formatWeiDeltaAmount": FormatWeiDeltaAmount, - "formatNFTTokenID": FormatNFTTokenID, - "formatEthHashShort": FormatEthHashShort, - "formatContractCreationLink": FormatContractCreationLink, - "formatValidator": FormatValidator, - "formatValidatorWithIndex": FormatValidatorWithIndex, - "formatValidatorNameWithIndex": FormatValidatorNameWithIndex, - "formatSlashedValidator": FormatSlashedValidator, - "formatBuilder": FormatBuilder, - "formatBuilderWithIndex": FormatBuilderWithIndex, - "formatBuilderWithURL": FormatBuilderWithURL, - "formatBuilderWithIndexAndURL": FormatBuilderWithIndexAndURL, - "formatWithdawalCredentials": FormatWithdawalCredentials, - "formatRecentTimeShort": FormatRecentTimeShort, - "formatGraffiti": FormatGraffiti, - "formatSlotStatusTooltip": FormatSlotStatusTooltip, - "formatRecvDelay": FormatRecvDelay, - "formatPercentageAlert": formatPercentageAlert, - "formatAlertNumber": formatAlertNumber, - "isSystemContract": IsSystemContract, - "getSystemContractName": GetSystemContractName, - "calculateBalanceDiff": CalculateBalanceDiff, - "bitwiseAnd": func(a, b interface{}) int64 { return toInt64(a) & toInt64(b) }, + "uint64ToTime": func(i uint64) time.Time { return time.Unix(int64(i), 0).UTC() }, + "percent": func(i float64) float64 { return i * 100 }, + "contains": strings.Contains, + "formatAddCommas": FormatAddCommas, + "formatFloat": FormatFloat, + "formatTokenAmount": FormatTokenAmount, + "formatBaseFee": FormatBaseFee, + "formatBlobFeeDifference": FormatBlobFeeDifference, + "formatTransactionValue": FormatTransactionValue, + "formatTransactionFee": FormatTransactionFee, + "formatBitlist": FormatBitlist, + "formatBitvectorValidators": formatBitvectorValidators, + "formatParticipation": FormatParticipation, + "formatEthFromGwei": FormatETHFromGwei, + "formatEthFromGweiP": FormatETHFromGweiP, + "formatEthFromGweiShort": FormatETHFromGweiShort, + "formatFullEthFromGwei": FormatFullEthFromGwei, + "formatEthAddCommasFromGwei": FormatETHAddCommasFromGwei, + "formatBytesAmount": FormatBytesAmount, + "formatAmount": FormatAmount, + "formatBigAmount": FormatBigAmount, + "formatAmountFormatted": FormatAmountFormatted, + "formatGwei": FormatGweiValue, + "formatByteAmount": FormatByteAmount, + "percentage": CalculatePercentage, + "ethBlockLink": FormatEthBlockLink, + "ethBlockHashLink": FormatEthBlockHashLink, + "ethAddressLink": FormatEthAddressLink, + "ethTransactionLink": FormatEthTransactionLink, + "formatEthAddress": FormatEthAddress, + "formatEthAddressShort": FormatEthAddressShort, + "formatEthAddressShortLink": FormatEthAddressShortLink, + "formatEthAddressFull": FormatEthAddressFull, + "formatEthAddressFullLink": FormatEthAddressFullLink, + "formatHexBytes": FormatHexBytes, + "formatHexBytesShort": FormatHexBytesShort, + "formatWeiAmount": FormatWeiAmount, + "formatWeiDeltaAmount": FormatWeiDeltaAmount, + "formatNFTTokenID": FormatNFTTokenID, + "formatEthHashShort": FormatEthHashShort, + "formatContractCreationLink": FormatContractCreationLink, + "formatValidator": FormatValidator, + "formatValidatorWithIndex": FormatValidatorWithIndex, + "formatValidatorNameWithIndex": FormatValidatorNameWithIndex, + "formatProposerWithBuildSource": FormatProposerWithBuildSource, + "formatSlashedValidator": FormatSlashedValidator, + "formatBuilder": FormatBuilder, + "formatBuilderWithIndex": FormatBuilderWithIndex, + "formatInactiveBuilder": FormatInactiveBuilder, + "formatBuilderWithURL": FormatBuilderWithURL, + "formatBuilderWithIndexAndURL": FormatBuilderWithIndexAndURL, + "formatWithdawalCredentials": FormatWithdawalCredentials, + "formatRecentTimeShort": FormatRecentTimeShort, + "formatGraffiti": FormatGraffiti, + "formatSlotStatusTooltip": FormatSlotStatusTooltip, + "formatRecvDelay": FormatRecvDelay, + "formatPercentageAlert": formatPercentageAlert, + "formatAlertNumber": formatAlertNumber, + "isSystemContract": IsSystemContract, + "getSystemContractName": GetSystemContractName, + "calculateBalanceDiff": CalculateBalanceDiff, + "bitwiseAnd": func(a, b interface{}) int64 { return toInt64(a) & toInt64(b) }, "formatByteSize": func(v any) template.HTML { n := toInt64(v) if n < 0 {