From 57fc0fb47d6215e63ae9ab4a567d8fc03295654e Mon Sep 17 00:00:00 2001 From: pk910 Date: Wed, 17 Jun 2026 17:40:34 +0200 Subject: [PATCH 01/22] EIP-8282 decoupled builder deposits & exits --- clients/consensus/chainspec.go | 12 +- clients/execution/chainstate.go | 10 +- clients/execution/rpc/ethconfig.go | 12 +- cmd/dora-explorer/main.go | 2 + db/builder_deposit_request_txs.go | 192 ++++++++ db/builder_deposits.go | 176 +++++++ db/builder_exit_request_txs.go | 183 +++++++ db/builder_exits.go | 167 +++++++ .../pgsql/20260617140000_builder-requests.sql | 134 ++++++ .../20260617140000_builder-requests.sql | 134 ++++++ dbtypes/dbtypes.go | 93 ++++ dbtypes/other.go | 40 ++ go.mod | 2 +- go.sum | 2 + handlers/builder_deposits.go | 234 +++++++++ handlers/builder_exits.go | 220 +++++++++ handlers/pageData.go | 58 ++- handlers/slot.go | 2 +- handlers/voluntary_exits.go | 127 ++--- indexer/beacon/block.go | 18 + indexer/beacon/depositsig/depositsig.go | 19 + indexer/beacon/statetransition/operations.go | 183 ++++--- indexer/beacon/writedb.go | 100 +++- .../builder_deposit_indexer.go | 269 +++++++++++ .../system_contracts/builder_exit_indexer.go | 262 ++++++++++ services/chainservice.go | 40 +- services/chainservice_builder_requests.go | 453 ++++++++++++++++++ .../builder_deposits/builder_deposits.html | 237 +++++++++ templates/builder_exits/builder_exits.html | 226 +++++++++ .../voluntary_exits/voluntary_exits.html | 52 +- types/config.go | 1 + types/models/builder_deposits.go | 54 +++ types/models/builder_exits.go | 52 ++ types/models/voluntary_exits.go | 2 - 34 files changed, 3515 insertions(+), 253 deletions(-) create mode 100644 db/builder_deposit_request_txs.go create mode 100644 db/builder_deposits.go create mode 100644 db/builder_exit_request_txs.go create mode 100644 db/builder_exits.go create mode 100644 db/schema/pgsql/20260617140000_builder-requests.sql create mode 100644 db/schema/sqlite/20260617140000_builder-requests.sql create mode 100644 handlers/builder_deposits.go create mode 100644 handlers/builder_exits.go create mode 100644 indexer/execution/system_contracts/builder_deposit_indexer.go create mode 100644 indexer/execution/system_contracts/builder_exit_indexer.go create mode 100644 services/chainservice_builder_requests.go create mode 100644 templates/builder_deposits/builder_deposits.html create mode 100644 templates/builder_exits/builder_exits.html create mode 100644 types/models/builder_deposits.go create mode 100644 types/models/builder_exits.go 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/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/cmd/dora-explorer/main.go b/cmd/dora-explorer/main.go index c61dc13b4..dca9984d2 100644 --- a/cmd/dora-explorer/main.go +++ b/cmd/dora-explorer/main.go @@ -236,6 +236,8 @@ 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("/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/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..1bdf8e738 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-0.20260617135310-2e8b95855e4a 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 diff --git a/go.sum b/go.sum index e8b9904c8..864ecfbfe 100644 --- a/go.sum +++ b/go.sum @@ -122,6 +122,8 @@ github.com/ethpandaops/ethwallclock v0.4.0 h1:+sgnhf4pk6hLPukP076VxkiLloE4L0Yk1y 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-0.20260617135310-2e8b95855e4a h1:owLQZ8513InaTtDTJB13DDMoXzgC+6BELXJfs99Gs60= +github.com/ethpandaops/go-eth2-client v0.1.4-0.20260617135310-2e8b95855e4a/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= diff --git a/handlers/builder_deposits.go b/handlers/builder_deposits.go new file mode 100644 index 000000000..6904a72a7 --- /dev/null +++ b/handlers/builder_deposits.go @@ -0,0 +1,234 @@ +package handlers + +import ( + "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/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", + "_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 { + 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() + + 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 + if deposit.Request.BuilderIndex != nil { + depositData.HasBuilderIndex = true + depositData.BuilderIndex = *deposit.Request.BuilderIndex + } + } 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 deposit.Transaction.BuilderIndex != nil { + depositData.HasBuilderIndex = true + depositData.BuilderIndex = *deposit.Transaction.BuilderIndex + } + } + + if deposit.Transaction != nil { + depositData.HasTransaction = true + depositData.TransactionHash = deposit.Transaction.TxHash + depositData.TransactionOrphaned = deposit.TransactionOrphaned + } + + 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 +} diff --git a/handlers/builder_exits.go b/handlers/builder_exits.go new file mode 100644 index 000000000..9f740fa13 --- /dev/null +++ b/handlers/builder_exits.go @@ -0,0 +1,220 @@ +package handlers + +import ( + "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/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", + "_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() + + 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 + if exit.Request.BuilderIndex != nil { + exitData.HasBuilderIndex = true + exitData.BuilderIndex = *exit.Request.BuilderIndex + } + } else if exit.Transaction != nil { + exitData.SourceAddress = exit.Transaction.SourceAddress + exitData.PublicKey = exit.Transaction.PublicKey + exitData.BlockNumber = exit.Transaction.BlockNumber + if exit.Transaction.BuilderIndex != nil { + exitData.HasBuilderIndex = true + exitData.BuilderIndex = *exit.Transaction.BuilderIndex + } + } + + if exit.Transaction != nil { + exitData.HasTransaction = true + exitData.TransactionHash = exit.Transaction.TxHash + exitData.TransactionOrphaned = exit.TransactionOrphaned + } + + 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..f2810392d 100644 --- a/handlers/pageData.go +++ b/handlers/pageData.go @@ -212,19 +212,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 +282,7 @@ func createMenuItems(active string) []types.MainMenuItem { }) } - return []types.MainMenuItem{ + mainMenu := []types.MainMenuItem{ { Label: "Blockchain", IsActive: active == "blockchain", @@ -306,12 +293,45 @@ 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. + if specs != nil && specs.GloasForkEpoch != nil && uint64(chainState.CurrentEpoch()) >= *specs.GloasForkEpoch { + buildersMenu := []types.NavigationGroup{ + { + Links: []types.NavigationLink{ + { + Label: "Builders List", + Path: "/builders", + Icon: "fa-building", + }, + { + Label: "Builder Deposits", + Path: "/builders/deposits", + Icon: "fa-file-signature", + }, + { + Label: "Builder Exits", + Path: "/builders/exits", + Icon: "fa-door-open", + }, + }, + }, + } + 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/slot.go b/handlers/slot.go index d514b9826..ea7878f56 100644 --- a/handlers/slot.go +++ b/handlers/slot.go @@ -959,7 +959,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 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/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/statetransition/operations.go b/indexer/beacon/statetransition/operations.go index 14a643a83..46b41e628 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 + } + + // 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:]) } - return false + 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 } - addBuilderToRegistry(s, pubkey, withdrawalCredentials, amount, slot) + if getPendingBalanceToWithdrawForBuilder(s, builderIndex) != 0 { + return + } + 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), diff --git a/indexer/beacon/writedb.go b/indexer/beacon/writedb.go index 8c78ec9d1..87b3cf9e7 100644 --- a/indexer/beacon/writedb.go +++ b/indexer/beacon/writedb.go @@ -169,6 +169,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 { @@ -732,7 +744,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 +800,92 @@ 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 overrideForkId != nil { + dbDeposit.ForkId = uint64(*overrideForkId) + } + + dbDeposits[idx] = dbDeposit + } + + return dbDeposits +} + +// 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 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_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/templates/builder_deposits/builder_deposits.html b/templates/builder_deposits/builder_deposits.html new file mode 100644 index 000000000..0bd2bda44 --- /dev/null +++ b/templates/builder_deposits/builder_deposits.html @@ -0,0 +1,237 @@ +{{ define "page" }} +
+
+

+ Builder Deposits +

+ +
+ +
+
+ +
+
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 }} + {{ else }} + pending + {{ end }} + + {{ if $deposit.IsIncluded }} + {{ formatRecentTimeShort $deposit.Time }} + {{ else }} + - + {{ end }} + + {{ if $deposit.HasBuilderIndex }} + {{ formatBuilderWithIndex $deposit.BuilderIndex "" }} + {{ else }} + - + {{ end }} + +
+ 0x{{ printf "%x" $deposit.PublicKey }} +
+
+
+ {{ formatWithdawalCredentials $deposit.WithdrawalCredentials }} + + {{ formatEthFromGwei $deposit.Amount }} + {{ if not $deposit.IsIncluded }} + Pending + {{ else if $deposit.Orphaned }} + Orphaned + {{ else }} + Included + {{ end }} + + {{ if $deposit.HasTransaction }} + 0x{{ printf "%x" $deposit.TransactionHash }} + {{ else }} + - + {{ end }} +
+
+ {{ template "professor_svg" }} +
+
+
+ {{ if gt .TotalPages 1 }} +
+
+
+
Showing builder deposits from slot {{ .FirstIndex }} to {{ .LastIndex }}
+
+
+
+
+ +
+
+
+ {{ end }} +
+ +
+
+{{ end }} +{{ define "js" }}{{ end }} +{{ define "css" }} + +{{ end }} diff --git a/templates/builder_exits/builder_exits.html b/templates/builder_exits/builder_exits.html new file mode 100644 index 000000000..7f00eb6f1 --- /dev/null +++ b/templates/builder_exits/builder_exits.html @@ -0,0 +1,226 @@ +{{ 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 }} + - + {{ 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 }} + 0x{{ printf "%x" $exit.TransactionHash }} + {{ else }} + - + {{ end }} +
+
+ {{ template "professor_svg" }} +
+
+
+ {{ if gt .TotalPages 1 }} +
+
+
+
Showing builder exits from slot {{ .FirstIndex }} to {{ .LastIndex }}
+
+
+
+
+ +
+
+
+ {{ end }} +
+ +
+
+{{ 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..14939e3f1 --- /dev/null +++ b/types/models/builder_deposits.go @@ -0,0 +1,54 @@ +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"` + + 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"` + Result uint8 `json:"result"` + HasTransaction bool `json:"has_transaction"` + TransactionHash []byte `json:"tx_hash" ssz-size:"32"` + TransactionOrphaned bool `json:"tx_orphaned"` + BlockNumber uint64 `json:"block_number"` +} diff --git a/types/models/builder_exits.go b/types/models/builder_exits.go new file mode 100644 index 000000000..8672a7039 --- /dev/null +++ b/types/models/builder_exits.go @@ -0,0 +1,52 @@ +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"` + Result uint8 `json:"result"` + HasTransaction bool `json:"has_transaction"` + TransactionHash []byte `json:"tx_hash" ssz-size:"32"` + TransactionOrphaned bool `json:"tx_orphaned"` + BlockNumber uint64 `json:"block_number"` +} 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"` From 50c2deb047f6132096c2185b4e1efd4edeacf4ac Mon Sep 17 00:00:00 2001 From: pk910 Date: Wed, 17 Jun 2026 17:53:41 +0200 Subject: [PATCH 02/22] show builder requests and existing execution requests on a new combined execution requests tab (slot details) --- handlers/slot.go | 53 +++++++++ indexer/beacon/writedb.go | 27 +++-- templates/slot/builder_deposit_requests.html | 40 +++++++ templates/slot/builder_exit_requests.html | 38 ++++++ templates/slot/slot.html | 71 +++++------ types/models/slot.go | 118 +++++++++++-------- 6 files changed, 246 insertions(+), 101 deletions(-) create mode 100644 templates/slot/builder_deposit_requests.html create mode 100644 templates/slot/builder_exit_requests.html diff --git a/handlers/slot.go b/handlers/slot.go index ea7878f56..11bb1bcec 100644 --- a/handlers/slot.go +++ b/handlers/slot.go @@ -21,6 +21,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 +53,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", @@ -978,6 +981,8 @@ func getSlotPageBlockData(ctx context.Context, blockData *services.CombinedBlock getSlotPageDepositRequests(pageData, requests.Deposits) getSlotPageWithdrawalRequests(pageData, requests.Withdrawals) getSlotPageConsolidationRequests(pageData, requests.Consolidations) + getSlotPageBuilderDeposits(pageData, requests.BuilderDeposits) + getSlotPageBuilderExits(pageData, requests.BuilderExits) } } @@ -1317,6 +1322,54 @@ func getSlotPageConsolidationRequests(pageData *models.SlotPageBlockData, consol pageData.ConsolidationRequestsCount = uint64(len(pageData.ConsolidationRequests)) } +func getSlotPageBuilderDeposits(pageData *models.SlotPageBlockData, builderDeposits []*gloas.BuilderDepositRequest) { + pageData.BuilderDepositRequests = make([]*models.SlotPageBuilderDepositRequest, 0, len(builderDeposits)) + + for _, builderDeposit := range builderDeposits { + requestData := &models.SlotPageBuilderDepositRequest{ + PublicKey: builderDeposit.Pubkey[:], + WithdrawalCreds: builderDeposit.WithdrawalCredentials, + Amount: uint64(builderDeposit.Amount), + Signature: builderDeposit.Signature[:], + } + + if rawIdx, found := services.GlobalBeaconService.GetValidatorIndexByPubkey(builderDeposit.Pubkey); found { + fullIndex := uint64(rawIdx) + if fullIndex&services.BuilderIndexFlag != 0 { + requestData.HasBuilderIndex = true + requestData.BuilderIndex = fullIndex &^ services.BuilderIndexFlag + } + } + + pageData.BuilderDepositRequests = append(pageData.BuilderDepositRequests, requestData) + } + + pageData.BuilderDepositRequestsCount = uint64(len(pageData.BuilderDepositRequests)) +} + +func getSlotPageBuilderExits(pageData *models.SlotPageBlockData, builderExits []*gloas.BuilderExitRequest) { + pageData.BuilderExitRequests = make([]*models.SlotPageBuilderExitRequest, 0, len(builderExits)) + + for _, builderExit := range builderExits { + requestData := &models.SlotPageBuilderExitRequest{ + SourceAddress: builderExit.SourceAddress[:], + PublicKey: builderExit.Pubkey[:], + } + + if rawIdx, found := services.GlobalBeaconService.GetValidatorIndexByPubkey(builderExit.Pubkey); found { + fullIndex := uint64(rawIdx) + if fullIndex&services.BuilderIndexFlag != 0 { + requestData.HasBuilderIndex = true + requestData.BuilderIndex = fullIndex &^ services.BuilderIndexFlag + } + } + + 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/indexer/beacon/writedb.go b/indexer/beacon/writedb.go index 87b3cf9e7..ec73fc427 100644 --- a/indexer/beacon/writedb.go +++ b/indexer/beacon/writedb.go @@ -344,11 +344,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) @@ -380,8 +385,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)), @@ -582,15 +587,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)) diff --git a/templates/slot/builder_deposit_requests.html b/templates/slot/builder_deposit_requests.html new file mode 100644 index 000000000..981d2dfd8 --- /dev/null +++ b/templates/slot/builder_deposit_requests.html @@ -0,0 +1,40 @@ +{{ define "block_builder_deposit_requests" }} +
+ + + + + + + + + + + {{ range $i, $req := .Block.BuilderDepositRequests }} + + + + + + + {{ end }} + +
BuilderPublic KeyWithdrawal CredentialsAmount
+ {{- if $req.HasBuilderIndex }} + {{ formatBuilderWithIndex $req.BuilderIndex "" }} + {{- 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..232314c89 --- /dev/null +++ b/templates/slot/builder_exit_requests.html @@ -0,0 +1,38 @@ +{{ define "block_builder_exit_requests" }} +
+ + + + + + + + + + {{ range $i, $req := .Block.BuilderExitRequests }} + + + + + + {{ end }} + +
Source AddressBuilderPublic Key
+
+ +
+ {{ ethAddressLink $req.SourceAddress }} +
+ {{- if $req.HasBuilderIndex }} + {{ formatBuilderWithIndex $req.BuilderIndex "" }} + {{- else }} + - + {{- end }} + +
+ +
+ 0x{{ printf "%x" $req.PublicKey }} +
+
+{{ end }} diff --git a/templates/slot/slot.html b/templates/slot/slot.html index 2edc6459c..d1e300b5e 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,37 @@

Showing {{ .Block.BlobsCount }} Blob sid {{ template "block_blobSidecar" . }}

{{ end }} - {{ if gt .Block.DepositRequestsCount 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 }} -
-
-
- {{ 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 }} -
-
-
- {{ template "block_withdrawal_requests" . }} -
- {{ end }} - {{ if gt .Block.ConsolidationRequestsCount 0 }} -
+ {{ $executionRequestsCount := addUI64 (addUI64 (addUI64 (addUI64 .Block.DepositRequestsCount .Block.WithdrawalRequestsCount) .Block.ConsolidationRequestsCount) .Block.BuilderDepositRequestsCount) .Block.BuilderExitRequestsCount }} + {{ if gt $executionRequestsCount 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 }} +

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_consolidation_requests" . }} + {{ if gt .Block.DepositRequestsCount 0 }} +

Deposit Requests {{ .Block.DepositRequestsCount }}

+ {{ template "block_deposit_requests" . }} + {{ 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 }}
{{ end }} {{ if and .Block.ExecutionData .Block.ExecutionData.BlockAccessList }} diff --git a/types/models/slot.go b/types/models/slot.go index b48f1a207..4f8c95711 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,22 @@ 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"` +} + +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"` +} + type SlotPageBid struct { ParentRoot []byte `json:"parent_root"` ParentHash []byte `json:"parent_hash"` From 543baf9b72cb142c909b1730bea44f8606cf4041 Mon Sep 17 00:00:00 2001 From: pk910 Date: Wed, 17 Jun 2026 18:14:17 +0200 Subject: [PATCH 03/22] fix deposit index matching for eip-8282 behaviour --- services/chainservice_deposits.go | 63 ++--- services/chainservice_deposits_test.go | 309 +++++++++++++++++++++++++ 2 files changed, 328 insertions(+), 44 deletions(-) create mode 100644 services/chainservice_deposits_test.go diff --git a/services/chainservice_deposits.go b/services/chainservice_deposits.go index 596921697..34c801fe7 100644 --- a/services/chainservice_deposits.go +++ b/services/chainservice_deposits.go @@ -429,8 +429,8 @@ func (bs *ChainService) GetIndexedDepositQueue(ctx context.Context, headBlock *b // 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) + lastIncludedDeposit := bs.getRecentIncludedDeposits(ctx, queueBlockRoot) + indexes, postponed := resolveQueueDepositIndexes(queue, lastIncludedDeposit) for idx := range indexedQueue.Queue { indexedQueue.Queue[idx].DepositIndex = indexes[idx] } @@ -593,12 +593,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 +627,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 +732,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 +796,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 +816,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]) + } + } +} From 613fd2116e2a03b25849eaaeade959a7c6c23e14 Mon Sep 17 00:00:00 2001 From: pk910 Date: Wed, 17 Jun 2026 23:12:02 +0200 Subject: [PATCH 04/22] add submission pages for builder operations --- cmd/dora-explorer/main.go | 2 + handlers/pageData.go | 22 ++ handlers/submit_builder_deposit.go | 89 +++++++ handlers/submit_builder_exit.go | 154 +++++++++++++ .../submit_builder_deposit.html | 62 +++++ .../submit_builder_exit.html | 65 ++++++ types/models/submit_builder_deposit.go | 12 + types/models/submit_builder_exit.go | 19 ++ .../BuilderDepositEntry.tsx | 96 ++++++++ .../BuilderDepositsTable.tsx | 218 ++++++++++++++++++ .../SubmitBuilderDepositsForm.tsx | 102 ++++++++ .../SubmitBuilderDepositsFormProps.ts | 5 + .../BuilderExitReview.tsx | 204 ++++++++++++++++ .../SubmitBuilderExitsForm.tsx | 144 ++++++++++++ .../SubmitBuilderExitsFormProps.ts | 12 + .../SubmitDepositsForm/DepositGenerator.ts | 16 +- .../DepositGeneratorModal.tsx | 26 ++- ui-package/src/main.tsx | 26 +++ 18 files changed, 1262 insertions(+), 12 deletions(-) create mode 100644 handlers/submit_builder_deposit.go create mode 100644 handlers/submit_builder_exit.go create mode 100644 templates/submit_builder_deposit/submit_builder_deposit.html create mode 100644 templates/submit_builder_exit/submit_builder_exit.html create mode 100644 types/models/submit_builder_deposit.go create mode 100644 types/models/submit_builder_exit.go create mode 100644 ui-package/src/components/SubmitBuilderDepositsForm/BuilderDepositEntry.tsx create mode 100644 ui-package/src/components/SubmitBuilderDepositsForm/BuilderDepositsTable.tsx create mode 100644 ui-package/src/components/SubmitBuilderDepositsForm/SubmitBuilderDepositsForm.tsx create mode 100644 ui-package/src/components/SubmitBuilderDepositsForm/SubmitBuilderDepositsFormProps.ts create mode 100644 ui-package/src/components/SubmitBuilderExitsForm/BuilderExitReview.tsx create mode 100644 ui-package/src/components/SubmitBuilderExitsForm/SubmitBuilderExitsForm.tsx create mode 100644 ui-package/src/components/SubmitBuilderExitsForm/SubmitBuilderExitsFormProps.ts diff --git a/cmd/dora-explorer/main.go b/cmd/dora-explorer/main.go index dca9984d2..1abd8a2cb 100644 --- a/cmd/dora-explorer/main.go +++ b/cmd/dora-explorer/main.go @@ -238,6 +238,8 @@ func startFrontend(router *mux.Router) { 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/handlers/pageData.go b/handlers/pageData.go index f2810392d..f8bbf7031 100644 --- a/handlers/pageData.go +++ b/handlers/pageData.go @@ -318,6 +318,28 @@ func createMenuItems(active string) []types.MainMenuItem { }, }, } + + builderSubmitLinks := []types.NavigationLink{} + if utils.Config.Frontend.ShowSubmitDeposit { + builderSubmitLinks = append(builderSubmitLinks, types.NavigationLink{ + Label: "Submit Builder Deposit", + Path: "/builders/submit_deposit", + Icon: "fa-file-import", + }) + } + if utils.Config.Frontend.ShowSubmitElRequests { + builderSubmitLinks = append(builderSubmitLinks, types.NavigationLink{ + Label: "Submit Builder 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", 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/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/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/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) { From 6a2224a91e15bcbc936ced2296c795da10943c4f Mon Sep 17 00:00:00 2001 From: pk910 Date: Fri, 19 Jun 2026 02:10:15 +0200 Subject: [PATCH 05/22] bump go-eth2-client --- go.mod | 2 +- go.sum | 8 ++-- indexer/beacon/statetransition/operations.go | 40 +++++++++++++++++++- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 1bdf8e738..533358372 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.4-0.20260617135310-2e8b95855e4a + github.com/ethpandaops/go-eth2-client v0.1.4-0.20260618225213-e8d135e8cdc1 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 diff --git a/go.sum b/go.sum index 864ecfbfe..1df9979ef 100644 --- a/go.sum +++ b/go.sum @@ -120,10 +120,10 @@ 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-0.20260617135310-2e8b95855e4a h1:owLQZ8513InaTtDTJB13DDMoXzgC+6BELXJfs99Gs60= -github.com/ethpandaops/go-eth2-client v0.1.4-0.20260617135310-2e8b95855e4a/go.mod h1:U3KdR8QSq8vqs9LWSGAF4ETHJpcB62E1DFf0gVMgWv0= +github.com/ethpandaops/go-eth2-client v0.1.4-0.20260618143220-d4d071326327 h1:MQY7NmRieJbknjitZ9VlFO7Uw7B3s0WVMGLboBJv76w= +github.com/ethpandaops/go-eth2-client v0.1.4-0.20260618143220-d4d071326327/go.mod h1:U3KdR8QSq8vqs9LWSGAF4ETHJpcB62E1DFf0gVMgWv0= +github.com/ethpandaops/go-eth2-client v0.1.4-0.20260618225213-e8d135e8cdc1 h1:kQC/fTbBdZ8uZxFd779/EezbpWb6KMd+wPIlXjUMoDo= +github.com/ethpandaops/go-eth2-client v0.1.4-0.20260618225213-e8d135e8cdc1/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= diff --git a/indexer/beacon/statetransition/operations.go b/indexer/beacon/statetransition/operations.go index 46b41e628..12be3e035 100644 --- a/indexer/beacon/statetransition/operations.go +++ b/indexer/beacon/statetransition/operations.go @@ -260,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) { @@ -707,6 +742,7 @@ func processExecutionPayloadBid(s *stateAccessor, block *all.SignedBeaconBlock) Amount: bid.Value, BuilderIndex: bid.BuilderIndex, }, + ProposerIndex: block.Message.ProposerIndex, } } } From abbd1031f0a28816d68047a2ef13fe079890f993 Mon Sep 17 00:00:00 2001 From: pk910 Date: Fri, 19 Jun 2026 02:34:37 +0200 Subject: [PATCH 06/22] fix el client readiness checks --- clients/execution/clientlogic.go | 12 ++++-------- clients/execution/rpc/executionapi.go | 22 ++++++++++++++++++---- 2 files changed, 22 insertions(+), 12 deletions(-) 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/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) { From 2496721ee0363bc9fb7318c02feea4d8a6b4a19e Mon Sep 17 00:00:00 2001 From: pk910 Date: Fri, 19 Jun 2026 03:07:17 +0200 Subject: [PATCH 07/22] various fixes for builder deposits & builder details UI --- handlers/builder_deposits.go | 9 +++ handlers/builder_exits.go | 9 +++ handlers/pageData.go | 10 +-- .../builder_deposits/builder_deposits.html | 18 +++++- templates/builder_exits/builder_exits.html | 18 +++++- templates/slot/slot.html | 64 ++++++++++++------- types/models/builder_deposits.go | 31 ++++----- types/models/builder_exits.go | 29 +++++---- 8 files changed, 128 insertions(+), 60 deletions(-) diff --git a/handlers/builder_deposits.go b/handlers/builder_deposits.go index 6904a72a7..8b7a243d8 100644 --- a/handlers/builder_deposits.go +++ b/handlers/builder_deposits.go @@ -20,6 +20,7 @@ import ( func BuilderDeposits(w http.ResponseWriter, r *http.Request) { var templateFiles = append(layoutTemplateFiles, "builder_deposits/builder_deposits.html", + "_shared/txDetailsModal.html", "_svg/professor.html", ) @@ -197,6 +198,14 @@ func buildBuilderDepositsPageData(ctx context.Context, pageIdx uint64, pageSize 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) diff --git a/handlers/builder_exits.go b/handlers/builder_exits.go index 9f740fa13..d8a45a047 100644 --- a/handlers/builder_exits.go +++ b/handlers/builder_exits.go @@ -20,6 +20,7 @@ import ( func BuilderExits(w http.ResponseWriter, r *http.Request) { var templateFiles = append(layoutTemplateFiles, "builder_exits/builder_exits.html", + "_shared/txDetailsModal.html", "_svg/professor.html", ) @@ -183,6 +184,14 @@ func buildBuilderExitsPageData(ctx context.Context, pageIdx uint64, pageSize uin 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) diff --git a/handlers/pageData.go b/handlers/pageData.go index f8bbf7031..5551ca6a3 100644 --- a/handlers/pageData.go +++ b/handlers/pageData.go @@ -301,17 +301,17 @@ func createMenuItems(active string) []types.MainMenuItem { { Links: []types.NavigationLink{ { - Label: "Builders List", + Label: "Builders", Path: "/builders", Icon: "fa-building", }, { - Label: "Builder Deposits", + Label: "Deposits", Path: "/builders/deposits", Icon: "fa-file-signature", }, { - Label: "Builder Exits", + Label: "Exits", Path: "/builders/exits", Icon: "fa-door-open", }, @@ -322,14 +322,14 @@ func createMenuItems(active string) []types.MainMenuItem { builderSubmitLinks := []types.NavigationLink{} if utils.Config.Frontend.ShowSubmitDeposit { builderSubmitLinks = append(builderSubmitLinks, types.NavigationLink{ - Label: "Submit Builder Deposit", + Label: "Submit Deposit", Path: "/builders/submit_deposit", Icon: "fa-file-import", }) } if utils.Config.Frontend.ShowSubmitElRequests { builderSubmitLinks = append(builderSubmitLinks, types.NavigationLink{ - Label: "Submit Builder Exit", + Label: "Submit Exit", Path: "/builders/submit_exit", Icon: "fa-door-open", }) diff --git a/templates/builder_deposits/builder_deposits.html b/templates/builder_deposits/builder_deposits.html index 0bd2bda44..310628f1e 100644 --- a/templates/builder_deposits/builder_deposits.html +++ b/templates/builder_deposits/builder_deposits.html @@ -167,7 +167,17 @@

{{ if $deposit.HasTransaction }} - 0x{{ printf "%x" $deposit.TransactionHash }} +
+ {{ ethTransactionLink $deposit.TransactionHash 0 }} +
+ +
+ {{ if $deposit.TransactionDetails }} +
+ +
+ {{ end }} +
{{ else }} - {{ end }} @@ -223,9 +233,12 @@

+ {{ template "txDetailsModal" . }} {{ end }} -{{ define "js" }}{{ 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 index 7f00eb6f1..0d23e5acd 100644 --- a/templates/builder_exits/builder_exits.html +++ b/templates/builder_exits/builder_exits.html @@ -156,7 +156,17 @@

{{ if $exit.HasTransaction }} - 0x{{ printf "%x" $exit.TransactionHash }} +
+ {{ ethTransactionLink $exit.TransactionHash 0 }} +
+ +
+ {{ if $exit.TransactionDetails }} +
+ +
+ {{ end }} +
{{ else }} - {{ end }} @@ -212,9 +222,12 @@

+ {{ template "txDetailsModal" . }} {{ end }} -{{ define "js" }}{{ end }} +{{ define "js" }} +{{ template "txDetailsModal-js" . }} +{{ end }} {{ define "css" }} +{{ template "txDetailsModal-css" . }} {{ end }} diff --git a/templates/slot/slot.html b/templates/slot/slot.html index d1e300b5e..93148ecba 100644 --- a/templates/slot/slot.html +++ b/templates/slot/slot.html @@ -242,33 +242,43 @@

Showing {{ .Block.BlobsCount }} Blob sid {{ if gt $executionRequestsCount 0 }}
-
+
-

Showing {{ $executionRequestsCount }} Execution Requests{{ if .Block.RequestsFromParentPayload }} from the Parent Payload{{ 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 }}
+ {{ if gt .Block.DepositRequestsCount 0 }} +
+

Deposit Requests {{ .Block.DepositRequestsCount }}

+ {{ template "block_deposit_requests" . }} +
+ {{ 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 }}
- {{ if gt .Block.DepositRequestsCount 0 }} -

Deposit Requests {{ .Block.DepositRequestsCount }}

- {{ template "block_deposit_requests" . }} - {{ 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 }}
{{ end }} {{ if and .Block.ExecutionData .Block.ExecutionData.BlockAccessList }} @@ -607,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/types/models/builder_deposits.go b/types/models/builder_deposits.go index 14939e3f1..e3c923e73 100644 --- a/types/models/builder_deposits.go +++ b/types/models/builder_deposits.go @@ -36,19 +36,20 @@ type BuilderDepositsPageData struct { } 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"` - Result uint8 `json:"result"` - HasTransaction bool `json:"has_transaction"` - TransactionHash []byte `json:"tx_hash" ssz-size:"32"` - TransactionOrphaned bool `json:"tx_orphaned"` - BlockNumber uint64 `json:"block_number"` + 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"` + 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"` } diff --git a/types/models/builder_exits.go b/types/models/builder_exits.go index 8672a7039..382ecfd0d 100644 --- a/types/models/builder_exits.go +++ b/types/models/builder_exits.go @@ -35,18 +35,19 @@ type BuilderExitsPageData struct { } 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"` - Result uint8 `json:"result"` - HasTransaction bool `json:"has_transaction"` - TransactionHash []byte `json:"tx_hash" ssz-size:"32"` - TransactionOrphaned bool `json:"tx_orphaned"` - BlockNumber uint64 `json:"block_number"` + 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"` + 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"` } From eb72c7d357ac730945ef5f00e19c84dd83c2afae Mon Sep 17 00:00:00 2001 From: pk910 Date: Fri, 19 Jun 2026 03:32:13 +0200 Subject: [PATCH 08/22] split builder & validator pubkey and check builder indexes for index reuse in UI --- handlers/builder.go | 32 +++++------ handlers/builder_deposits.go | 37 ++++++++++-- handlers/builder_exits.go | 37 ++++++++++-- handlers/slot.go | 57 ++++++++++++++----- indexer/beacon/buildercache.go | 4 +- indexer/beacon/indexer.go | 6 ++ indexer/beacon/indexer_getter.go | 40 ++++++------- indexer/beacon/pubkeycache.go | 39 ++++++++++++- indexer/beacon/writedb.go | 8 +++ services/chainservice_builder.go | 40 +++++++++++++ services/chainservice_deposits.go | 2 +- .../builder_deposits/builder_deposits.html | 2 + templates/builder_exits/builder_exits.html | 2 + templates/slot/builder_deposit_requests.html | 2 + templates/slot/builder_exit_requests.html | 2 + types/models/builder_deposits.go | 1 + types/models/builder_exits.go | 1 + types/models/slot.go | 22 +++---- utils/format.go | 7 +++ utils/templateFucs.go | 1 + 20 files changed, 261 insertions(+), 81 deletions(-) diff --git a/handlers/builder.go b/handlers/builder.go index 3c2eae1e0..b26d23163 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,32 @@ 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) 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 } 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 index 8b7a243d8..c234ca0ec 100644 --- a/handlers/builder_deposits.go +++ b/handlers/builder_deposits.go @@ -1,6 +1,7 @@ package handlers import ( + "bytes" "context" "fmt" "net/http" @@ -12,6 +13,7 @@ import ( "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" ) @@ -165,6 +167,28 @@ func buildBuilderDepositsPageData(ctx context.Context, pageIdx uint64, pageSize chainState := services.GlobalBeaconService.GetChainState() + // 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{} @@ -179,18 +203,19 @@ func buildBuilderDepositsPageData(ctx context.Context, pageIdx uint64, pageSize depositData.Amount = deposit.Request.Amount depositData.Result = deposit.Request.Result depositData.BlockNumber = deposit.Request.BlockNumber - if deposit.Request.BuilderIndex != nil { - depositData.HasBuilderIndex = true - depositData.BuilderIndex = *deposit.Request.BuilderIndex - } } 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 deposit.Transaction.BuilderIndex != nil { + } + + 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 = *deposit.Transaction.BuilderIndex + depositData.BuilderIndex = *idx + } else { + depositData.IsInactiveBuilder = true } } diff --git a/handlers/builder_exits.go b/handlers/builder_exits.go index d8a45a047..4cc777e0f 100644 --- a/handlers/builder_exits.go +++ b/handlers/builder_exits.go @@ -1,6 +1,7 @@ package handlers import ( + "bytes" "context" "fmt" "net/http" @@ -12,6 +13,7 @@ import ( "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" ) @@ -153,6 +155,28 @@ func buildBuilderExitsPageData(ctx context.Context, pageIdx uint64, pageSize uin 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{} @@ -166,17 +190,18 @@ func buildBuilderExitsPageData(ctx context.Context, pageIdx uint64, pageSize uin exitData.PublicKey = exit.Request.PublicKey exitData.Result = exit.Request.Result exitData.BlockNumber = exit.Request.BlockNumber - if exit.Request.BuilderIndex != nil { - exitData.HasBuilderIndex = true - exitData.BuilderIndex = *exit.Request.BuilderIndex - } } else if exit.Transaction != nil { exitData.SourceAddress = exit.Transaction.SourceAddress exitData.PublicKey = exit.Transaction.PublicKey exitData.BlockNumber = exit.Transaction.BlockNumber - if exit.Transaction.BuilderIndex != nil { + } + + 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 = *exit.Transaction.BuilderIndex + exitData.BuilderIndex = *idx + } else { + exitData.IsInactiveBuilder = true } } diff --git a/handlers/slot.go b/handlers/slot.go index 11bb1bcec..13303ef57 100644 --- a/handlers/slot.go +++ b/handlers/slot.go @@ -1,6 +1,7 @@ package handlers import ( + "bytes" "context" "encoding/hex" "encoding/json" @@ -981,8 +982,8 @@ func getSlotPageBlockData(ctx context.Context, blockData *services.CombinedBlock getSlotPageDepositRequests(pageData, requests.Deposits) getSlotPageWithdrawalRequests(pageData, requests.Withdrawals) getSlotPageConsolidationRequests(pageData, requests.Consolidations) - getSlotPageBuilderDeposits(pageData, requests.BuilderDeposits) - getSlotPageBuilderExits(pageData, requests.BuilderExits) + getSlotPageBuilderDeposits(ctx, pageData, requests.BuilderDeposits) + getSlotPageBuilderExits(ctx, pageData, requests.BuilderExits) } } @@ -1322,10 +1323,24 @@ func getSlotPageConsolidationRequests(pageData *models.SlotPageBlockData, consol pageData.ConsolidationRequestsCount = uint64(len(pageData.ConsolidationRequests)) } -func getSlotPageBuilderDeposits(pageData *models.SlotPageBlockData, builderDeposits []*gloas.BuilderDepositRequest) { +func getSlotPageBuilderDeposits(ctx context.Context, pageData *models.SlotPageBlockData, builderDeposits []*gloas.BuilderDepositRequest) { pageData.BuilderDepositRequests = make([]*models.SlotPageBuilderDepositRequest, 0, len(builderDeposits)) - for _, builderDeposit := range 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, @@ -1333,11 +1348,12 @@ func getSlotPageBuilderDeposits(pageData *models.SlotPageBlockData, builderDepos Signature: builderDeposit.Signature[:], } - if rawIdx, found := services.GlobalBeaconService.GetValidatorIndexByPubkey(builderDeposit.Pubkey); found { - fullIndex := uint64(rawIdx) - if fullIndex&services.BuilderIndexFlag != 0 { + if resolvedOk[i] { + if b := builders[resolvedIdx[i]]; b != nil && bytes.Equal(b.PublicKey[:], builderDeposit.Pubkey[:]) { requestData.HasBuilderIndex = true - requestData.BuilderIndex = fullIndex &^ services.BuilderIndexFlag + requestData.BuilderIndex = uint64(resolvedIdx[i]) + } else { + requestData.IsInactiveBuilder = true } } @@ -1347,20 +1363,33 @@ func getSlotPageBuilderDeposits(pageData *models.SlotPageBlockData, builderDepos pageData.BuilderDepositRequestsCount = uint64(len(pageData.BuilderDepositRequests)) } -func getSlotPageBuilderExits(pageData *models.SlotPageBlockData, builderExits []*gloas.BuilderExitRequest) { +func getSlotPageBuilderExits(ctx context.Context, pageData *models.SlotPageBlockData, builderExits []*gloas.BuilderExitRequest) { pageData.BuilderExitRequests = make([]*models.SlotPageBuilderExitRequest, 0, len(builderExits)) - for _, builderExit := range 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 rawIdx, found := services.GlobalBeaconService.GetValidatorIndexByPubkey(builderExit.Pubkey); found { - fullIndex := uint64(rawIdx) - if fullIndex&services.BuilderIndexFlag != 0 { + if resolvedOk[i] { + if b := builders[resolvedIdx[i]]; b != nil && bytes.Equal(b.PublicKey[:], builderExit.Pubkey[:]) { requestData.HasBuilderIndex = true - requestData.BuilderIndex = fullIndex &^ services.BuilderIndexFlag + requestData.BuilderIndex = uint64(resolvedIdx[i]) + } else { + requestData.IsInactiveBuilder = true } } 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/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/writedb.go b/indexer/beacon/writedb.go index ec73fc427..04144ac8b 100644 --- a/indexer/beacon/writedb.go +++ b/indexer/beacon/writedb.go @@ -843,6 +843,10 @@ func (dbw *dbWriter) buildDbBuilderDeposits(block *Block, orphaned bool, overrid 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) } @@ -885,6 +889,10 @@ func (dbw *dbWriter) buildDbBuilderExits(block *Block, orphaned bool, overrideFo 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) } 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_deposits.go b/services/chainservice_deposits.go index 34c801fe7..69de7e159 100644 --- a/services/chainservice_deposits.go +++ b/services/chainservice_deposits.go @@ -566,7 +566,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) diff --git a/templates/builder_deposits/builder_deposits.html b/templates/builder_deposits/builder_deposits.html index 310628f1e..752f063d2 100644 --- a/templates/builder_deposits/builder_deposits.html +++ b/templates/builder_deposits/builder_deposits.html @@ -141,6 +141,8 @@

{{ if $deposit.HasBuilderIndex }} {{ formatBuilderWithIndex $deposit.BuilderIndex "" }} + {{ else if $deposit.IsInactiveBuilder }} + {{ formatInactiveBuilder $deposit.PublicKey }} {{ else }} - {{ end }} diff --git a/templates/builder_exits/builder_exits.html b/templates/builder_exits/builder_exits.html index 0d23e5acd..1a39adb0e 100644 --- a/templates/builder_exits/builder_exits.html +++ b/templates/builder_exits/builder_exits.html @@ -134,6 +134,8 @@

{{ if $exit.HasBuilderIndex }} {{ formatBuilderWithIndex $exit.BuilderIndex "" }} + {{ else if $exit.IsInactiveBuilder }} + {{ formatInactiveBuilder $exit.PublicKey }} {{ else }} - {{ end }} diff --git a/templates/slot/builder_deposit_requests.html b/templates/slot/builder_deposit_requests.html index 981d2dfd8..1594f3861 100644 --- a/templates/slot/builder_deposit_requests.html +++ b/templates/slot/builder_deposit_requests.html @@ -15,6 +15,8 @@ {{- if $req.HasBuilderIndex }} {{ formatBuilderWithIndex $req.BuilderIndex "" }} + {{- else if $req.IsInactiveBuilder }} + {{ formatInactiveBuilder $req.PublicKey }} {{- else }} - {{- end }} diff --git a/templates/slot/builder_exit_requests.html b/templates/slot/builder_exit_requests.html index 232314c89..0ae37e50e 100644 --- a/templates/slot/builder_exit_requests.html +++ b/templates/slot/builder_exit_requests.html @@ -20,6 +20,8 @@ {{- if $req.HasBuilderIndex }} {{ formatBuilderWithIndex $req.BuilderIndex "" }} + {{- else if $req.IsInactiveBuilder }} + {{ formatInactiveBuilder $req.PublicKey }} {{- else }} - {{- end }} diff --git a/types/models/builder_deposits.go b/types/models/builder_deposits.go index e3c923e73..4d29f793a 100644 --- a/types/models/builder_deposits.go +++ b/types/models/builder_deposits.go @@ -46,6 +46,7 @@ type BuilderDepositsPageDataDeposit struct { 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 Result uint8 `json:"result"` HasTransaction bool `json:"has_transaction"` TransactionHash []byte `json:"tx_hash" ssz-size:"32"` diff --git a/types/models/builder_exits.go b/types/models/builder_exits.go index 382ecfd0d..4077d21c6 100644 --- a/types/models/builder_exits.go +++ b/types/models/builder_exits.go @@ -44,6 +44,7 @@ type BuilderExitsPageDataExit struct { 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"` diff --git a/types/models/slot.go b/types/models/slot.go index 4f8c95711..e820f573e 100644 --- a/types/models/slot.go +++ b/types/models/slot.go @@ -338,19 +338,21 @@ type SlotPageConsolidationRequest struct { } 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"` + 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"` + 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 { diff --git a/utils/format.go b/utils/format.go index 6dcfcd397..72c867f8d 100644 --- a/utils/format.go +++ b/utils/format.go @@ -843,6 +843,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..b1ea6d068 100644 --- a/utils/templateFucs.go +++ b/utils/templateFucs.go @@ -156,6 +156,7 @@ func GetTemplateFuncs() template.FuncMap { "formatSlashedValidator": FormatSlashedValidator, "formatBuilder": FormatBuilder, "formatBuilderWithIndex": FormatBuilderWithIndex, + "formatInactiveBuilder": FormatInactiveBuilder, "formatBuilderWithURL": FormatBuilderWithURL, "formatBuilderWithIndexAndURL": FormatBuilderWithIndexAndURL, "formatWithdawalCredentials": FormatWithdawalCredentials, From ab71256686ee06d560ab807e745f12b06ccba795 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:06:31 +0000 Subject: [PATCH 09/22] build(deps): bump the ui-package-dependencies group across 1 directory with 4 updates Bumps the ui-package-dependencies group with 4 updates in the /ui-package directory: [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query), [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react), [viem](https://github.com/wevm/viem) and [sass](https://github.com/sass/dart-sass). Updates `@tanstack/react-query` from 5.100.14 to 5.101.0 - [Release notes](https://github.com/TanStack/query/releases) - [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md) - [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.101.0/packages/react-query) Updates `@types/react` from 19.2.16 to 19.2.17 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react) Updates `viem` from 2.52.0 to 2.52.2 - [Release notes](https://github.com/wevm/viem/releases) - [Commits](https://github.com/wevm/viem/compare/viem@2.52.0...viem@2.52.2) Updates `sass` from 1.100.0 to 1.101.0 - [Release notes](https://github.com/sass/dart-sass/releases) - [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md) - [Commits](https://github.com/sass/dart-sass/compare/1.100.0...1.101.0) --- updated-dependencies: - dependency-name: "@tanstack/react-query" dependency-version: 5.101.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: ui-package-dependencies - dependency-name: "@types/react" dependency-version: 19.2.17 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: ui-package-dependencies - dependency-name: viem dependency-version: 2.52.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: ui-package-dependencies - dependency-name: sass dependency-version: 1.101.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: ui-package-dependencies ... Signed-off-by: dependabot[bot] --- ui-package/package-lock.json | 48 ++++++++++++++++++------------------ ui-package/package.json | 8 +++--- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/ui-package/package-lock.json b/ui-package/package-lock.json index 5a268af5f..b264663bb 100644 --- a/ui-package/package-lock.json +++ b/ui-package/package-lock.json @@ -12,14 +12,14 @@ "@chainsafe/bls": "^8.2.0", "@chainsafe/ssz": "^1.6.0", "@rainbow-me/rainbowkit": "^2.2.11", - "@tanstack/react-query": "^5.100.14", - "@types/react": "^19.2.16", + "@tanstack/react-query": "^5.101.0", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "react": "^19.2.7", "react-bootstrap": "^2.10.10", "react-dom": "^19.2.7", "react-select": "^5.10.2", - "viem": "^2.52.0", + "viem": "^2.52.2", "wagmi": "^2.19.5" }, "devDependencies": { @@ -36,7 +36,7 @@ "css-loader": "^7.1.4", "mini-css-extract-plugin": "^2.10.2", "postcss-loader": "^8.2.1", - "sass": "^1.100.0", + "sass": "^1.101.0", "sass-loader": "^17.0.0", "style-loader": "^4.0.0", "ts-loader": "^9.6.0", @@ -5535,9 +5535,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.100.14", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz", - "integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==", + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", "license": "MIT", "funding": { "type": "github", @@ -5545,12 +5545,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.100.14", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz", - "integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==", + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.100.14" + "@tanstack/query-core": "5.101.0" }, "funding": { "type": "github", @@ -5643,9 +5643,9 @@ "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" }, "node_modules/@types/react": { - "version": "19.2.16", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", - "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -11138,9 +11138,9 @@ } }, "node_modules/sass": { - "version": "1.100.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz", - "integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==", + "version": "1.101.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz", + "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", "dev": true, "license": "MIT", "dependencies": { @@ -12268,9 +12268,9 @@ } }, "node_modules/viem": { - "version": "2.52.0", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.52.0.tgz", - "integrity": "sha512-py2QPYe9e1f4DmPJCsXF7zHmyZ0PkJrBxdQZ5dvNXvzy3UzWkUn7dNfC0TMeNm6Qv1tKw3b6qXXExpx6L0oMbw==", + "version": "2.52.2", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.52.2.tgz", + "integrity": "sha512-HSU12p5aD/kAPZfrlbCUqdiP4P/c6hQ9AhfTS51VbLUQIjkWd1d5EjrCx/SCxZ0zhZVRn4Iv5X5WDqXPG8Ubew==", "funding": [ { "type": "github", @@ -12285,7 +12285,7 @@ "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", - "ox": "0.14.27", + "ox": "0.14.29", "ws": "8.20.1" }, "peerDependencies": { @@ -12379,9 +12379,9 @@ } }, "node_modules/viem/node_modules/ox": { - "version": "0.14.27", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.27.tgz", - "integrity": "sha512-+xhLHo/f+f4BH121/1Pomm/1vgBBda1wYiFpTvjSo8o5OcEj76Pf1hGPJiepoYMTQoTm2SKdSBvWkFWk5l07PA==", + "version": "0.14.29", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.29.tgz", + "integrity": "sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg==", "funding": [ { "type": "github", diff --git a/ui-package/package.json b/ui-package/package.json index 38bf0008e..b3e189c48 100644 --- a/ui-package/package.json +++ b/ui-package/package.json @@ -18,7 +18,7 @@ "css-loader": "^7.1.4", "mini-css-extract-plugin": "^2.10.2", "postcss-loader": "^8.2.1", - "sass": "^1.100.0", + "sass": "^1.101.0", "sass-loader": "^17.0.0", "style-loader": "^4.0.0", "ts-loader": "^9.6.0", @@ -32,14 +32,14 @@ "@chainsafe/bls": "^8.2.0", "@chainsafe/ssz": "^1.6.0", "@rainbow-me/rainbowkit": "^2.2.11", - "@tanstack/react-query": "^5.100.14", - "@types/react": "^19.2.16", + "@tanstack/react-query": "^5.101.0", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "react": "^19.2.7", "react-bootstrap": "^2.10.10", "react-dom": "^19.2.7", "react-select": "^5.10.2", - "viem": "^2.52.0", + "viem": "^2.52.2", "wagmi": "^2.19.5" }, "scripts": { From 2048c8137210ba87925178f13c24fee30658fb57 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Fri, 19 Jun 2026 11:30:56 +0200 Subject: [PATCH 10/22] feat(slots): show payload build source icon in proposer column Repurpose the proposer icon on the slots list to indicate the payload build source on Gloas+ blocks: a house icon for self-built payloads and a hard-hat icon for builder-built payloads. The hard-hat links to the builder URL when known (otherwise the internal builder page). Pre-Gloas blocks keep the default validator icon. Builder data is now populated whenever the Proposer column is shown, not only when the optional Builder column is enabled. --- handlers/slots.go | 4 +- templates/slots/slots.html | 2 +- utils/format.go | 37 +++++++++++ utils/templateFucs.go | 123 +++++++++++++++++++------------------ 4 files changed, 102 insertions(+), 64 deletions(-) diff --git a/handlers/slots.go b/handlers/slots.go index fbd79cdde..924a4f888 100644 --- a/handlers/slots.go +++ b/handlers/slots.go @@ -309,8 +309,8 @@ 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) + if pageData.DisplayBuilder || pageData.DisplayProposer { if dbSlot.BuilderIndex == -1 { slotData.HasBuilder = true slotData.BuilderIndex = math.MaxUint64 diff --git a/templates/slots/slots.html b/templates/slots/slots.html index 36aac590e..47a518742 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.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/utils/format.go b/utils/format.go index 72c867f8d..538ebacdc 100644 --- a/utils/format.go +++ b/utils/format.go @@ -832,6 +832,43 @@ 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. +func FormatProposerWithBuildSource(index uint64, name string, hasBuilder bool, builderIndex uint64, builderURL string) template.HTML { + 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) + } + + if index == math.MaxInt64 { + return template.HTML(fmt.Sprintf(`%v unknown`, iconHTML)) + } + 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)) diff --git a/utils/templateFucs.go b/utils/templateFucs.go index b1ea6d068..4680d1faf 100644 --- a/utils/templateFucs.go +++ b/utils/templateFucs.go @@ -109,67 +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, - "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) }, + "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 { From 360502e1e15c4e751657dc1bc3ad83791a34c379 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Fri, 19 Jun 2026 11:36:24 +0200 Subject: [PATCH 11/22] fix(slots): no build-source icon for scheduled/missing slots Scheduled and missing slots have no payload yet, so dbSlot.BuilderIndex defaults to 0 and was incorrectly rendered as a builder-built (hard-hat) icon. Only populate build-source info for slots with an actual block (proposed or orphaned). --- handlers/slots.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/handlers/slots.go b/handlers/slots.go index 924a4f888..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 (needed for the Builder column and the proposer build-source icon) - if pageData.DisplayBuilder || pageData.DisplayProposer { + // 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 From 10bd1cf66548020a9083eadb5c0bc98574e2a972 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Fri, 19 Jun 2026 11:40:32 +0200 Subject: [PATCH 12/22] fix(slots): no proposer icon for unknown/missing payloads EIP-7732 emits a synthetic missing-block row per slot (status Missing, unknown proposer). These have no determinable build source, so render them without any leading icon instead of falling back to the validator person icon. Scheduled/missing slots and unknown proposers now show the proposer label (or 'unknown') with no favicon. --- templates/slots/slots.html | 2 +- utils/format.go | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/templates/slots/slots.html b/templates/slots/slots.html index 47a518742..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 }}{{ formatProposerWithBuildSource $slot.Proposer $slot.ProposerName $slot.HasBuilder $slot.BuilderIndex $slot.BuilderURL }}{{ 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/utils/format.go b/utils/format.go index 538ebacdc..6bb59c71d 100644 --- a/utils/format.go +++ b/utils/format.go @@ -836,7 +836,20 @@ func formatValidator(index uint64, name string, icon string, withIndex bool) tem // 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. -func FormatProposerWithBuildSource(index uint64, name string, hasBuilder bool, builderIndex uint64, builderURL string) template.HTML { +// +// 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) } @@ -857,9 +870,6 @@ func FormatProposerWithBuildSource(index uint64, name string, hasBuilder bool, b iconHTML = fmt.Sprintf(``, builderLink, external, builderIndex) } - if index == math.MaxInt64 { - return template.HTML(fmt.Sprintf(`%v unknown`, iconHTML)) - } nameLabel := fmt.Sprintf("%v", index) labelClass := "validator-index" if name != "" { From 84d88ed30f28dc99274a7734371cd08e4b122fd5 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Fri, 19 Jun 2026 12:56:21 +0200 Subject: [PATCH 13/22] feat(blocks): show payload build source icon in proposer column Apply the same proposer build-source icon treatment to the Blocks page as the Slots page: house for self-built, hard-hat (linking to builder) for builder-built, and no icon for scheduled/missing/unknown rows. --- handlers/blocks.go | 5 +++-- templates/blocks/blocks.html | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) 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/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 }} From 9c4798f9fce384d350bc112493da077aff1a3f2c Mon Sep 17 00:00:00 2001 From: pk910 Date: Fri, 19 Jun 2026 14:59:00 +0200 Subject: [PATCH 14/22] add gloas fork transition to state transition --- indexer/beacon/epochstate.go | 51 ++-- indexer/beacon/statetransition/block.go | 4 + indexer/beacon/statetransition/fork.go | 246 ++++++++++++++++++ .../beacon/statetransition/statetransition.go | 10 + 4 files changed, 276 insertions(+), 35 deletions(-) create mode 100644 indexer/beacon/statetransition/fork.go diff --git a/indexer/beacon/epochstate.go b/indexer/beacon/epochstate.go index 6904e31bb..c916cfe0a 100644 --- a/indexer/beacon/epochstate.go +++ b/indexer/beacon/epochstate.go @@ -172,24 +172,27 @@ 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 - } - 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 +376,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 +447,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/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/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 From f46df7078c33b5cf92043e5bcfaae7bdfcf10b42 Mon Sep 17 00:00:00 2001 From: pk910 Date: Sat, 20 Jun 2026 14:53:23 +0200 Subject: [PATCH 15/22] bump go-eth2-client --- go.mod | 2 +- go.sum | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 533358372..55e341279 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.4-0.20260618225213-e8d135e8cdc1 + 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 diff --git a/go.sum b/go.sum index 1df9979ef..9cb29d963 100644 --- a/go.sum +++ b/go.sum @@ -120,10 +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.4-0.20260618143220-d4d071326327 h1:MQY7NmRieJbknjitZ9VlFO7Uw7B3s0WVMGLboBJv76w= -github.com/ethpandaops/go-eth2-client v0.1.4-0.20260618143220-d4d071326327/go.mod h1:U3KdR8QSq8vqs9LWSGAF4ETHJpcB62E1DFf0gVMgWv0= -github.com/ethpandaops/go-eth2-client v0.1.4-0.20260618225213-e8d135e8cdc1 h1:kQC/fTbBdZ8uZxFd779/EezbpWb6KMd+wPIlXjUMoDo= -github.com/ethpandaops/go-eth2-client v0.1.4-0.20260618225213-e8d135e8cdc1/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= From 20ee5da154d23c5a5de0a907baeedcecd1e8e7fa Mon Sep 17 00:00:00 2001 From: pk910 Date: Mon, 22 Jun 2026 01:10:11 +0200 Subject: [PATCH 16/22] bump dynamic-ssz --- blockdb/types/execdata_sections_ssz.go | 17 ++++++++++++++++- go.mod | 4 ++-- go.sum | 8 ++++---- indexer/beacon/epochstats_ssz.go | 7 ++++++- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/blockdb/types/execdata_sections_ssz.go b/blockdb/types/execdata_sections_ssz.go index 0307bb476..2ac1afd4d 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/go.mod b/go.mod index 55e341279..73e8e5ec2 100644 --- a/go.mod +++ b/go.mod @@ -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 9cb29d963..5eba08f8e 100644 --- a/go.sum +++ b/go.sum @@ -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/indexer/beacon/epochstats_ssz.go b/indexer/beacon/epochstats_ssz.go index a6b8f4da0..01cf9ae36 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) From ee7b9a1833662a8de11ef34d10e0cac72cba02be Mon Sep 17 00:00:00 2001 From: pk910 Date: Mon, 22 Jun 2026 01:58:48 +0200 Subject: [PATCH 17/22] show early-onboarded builder deposits --- db/deposit_txs.go | 44 +++++++ handlers/builder.go | 10 ++ handlers/builder_deposits.go | 9 ++ indexer/beacon/epochcache.go | 1 + indexer/beacon/epochstate.go | 4 +- indexer/beacon/epochstats.go | 22 +++- indexer/beacon/writedb.go | 119 ++++++++++++++++++ templates/builder/recentDeposits.html | 4 +- .../builder_deposits/builder_deposits.html | 3 + types/models/builder_deposits.go | 1 + types/models/builders.go | 1 + 11 files changed, 214 insertions(+), 4 deletions(-) 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/handlers/builder.go b/handlers/builder.go index b26d23163..0f36952ed 100644 --- a/handlers/builder.go +++ b/handlers/builder.go @@ -469,6 +469,15 @@ func buildBuilderRecentDeposits(ctx context.Context, pubkey []byte, chainState * WithOrphaned: 1, } 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", @@ -479,6 +488,7 @@ func buildBuilderRecentDeposits(ctx context.Context, pubkey []byte, chainState * 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.Amount = deposit.Transaction.Amount entry.Time = time.Unix(int64(deposit.Transaction.BlockTime), 0) diff --git a/handlers/builder_deposits.go b/handlers/builder_deposits.go index c234ca0ec..47ddda566 100644 --- a/handlers/builder_deposits.go +++ b/handlers/builder_deposits.go @@ -167,6 +167,14 @@ func buildBuilderDepositsPageData(ctx context.Context, pageIdx uint64, pageSize 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 { @@ -203,6 +211,7 @@ func buildBuilderDepositsPageData(ctx context.Context, pageIdx uint64, pageSize 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 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 c916cfe0a..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 @@ -189,6 +190,7 @@ func (s *epochState) loadState(ctx context.Context, client *Client, cache *epoch } epochTransitionDur := time.Since(epochStart) s.delayedBuilderPaymentRefs = transitionInfo.DelayedBuilderPayments + s.gloasOnboardedDeposits = transitionInfo.GloasOnboardedDeposits client.logger.Infof("applied epoch transition for epoch %v in %v", s.targetEpoch, epochTransitionDur.Round(time.Millisecond)) 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/writedb.go b/indexer/beacon/writedb.go index 04144ac8b..87963b0cb 100644 --- a/indexer/beacon/writedb.go +++ b/indexer/beacon/writedb.go @@ -1,6 +1,7 @@ package beacon import ( + "bytes" "fmt" "math" @@ -857,6 +858,124 @@ func (dbw *dbWriter) buildDbBuilderDeposits(block *Block, orphaned bool, overrid 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 { 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 index 752f063d2..0694f072e 100644 --- a/templates/builder_deposits/builder_deposits.html +++ b/templates/builder_deposits/builder_deposits.html @@ -127,6 +127,9 @@

{{ else }} {{ formatAddCommas $deposit.SlotNumber }} {{ end }} + {{ if $deposit.IsOnboarding }} + + {{ end }} {{ else }} pending {{ end }} diff --git a/types/models/builder_deposits.go b/types/models/builder_deposits.go index 4d29f793a..e429b6814 100644 --- a/types/models/builder_deposits.go +++ b/types/models/builder_deposits.go @@ -47,6 +47,7 @@ type BuilderDepositsPageDataDeposit struct { 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"` 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"` From 23c519d42892739a3fc4ea620432d42681e99782 Mon Sep 17 00:00:00 2001 From: pk910 Date: Mon, 22 Jun 2026 04:36:03 +0200 Subject: [PATCH 18/22] display early builder deposits --- handlers/builder_deposits.go | 199 +++++++++++++ handlers/pageData.go | 5 +- services/chainservice_builder_onboarding.go | 279 ++++++++++++++++++ services/chainservice_deposits.go | 87 +++++- .../builder_deposits/builder_deposits.html | 73 ++++- types/models/builder_deposits.go | 32 ++ 6 files changed, 656 insertions(+), 19 deletions(-) create mode 100644 services/chainservice_builder_onboarding.go diff --git a/handlers/builder_deposits.go b/handlers/builder_deposits.go index 47ddda566..b5d606163 100644 --- a/handlers/builder_deposits.go +++ b/handlers/builder_deposits.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "math" "net/http" "net/url" "strconv" @@ -101,6 +102,15 @@ func getBuilderDepositsPageData(pageIdx uint64, pageSize uint64, minSlot uint64, } 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)) @@ -275,3 +285,192 @@ func buildBuilderDepositsPageData(ctx context.Context, pageIdx uint64, pageSize 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/pageData.go b/handlers/pageData.go index 5551ca6a3..99141c95a 100644 --- a/handlers/pageData.go +++ b/handlers/pageData.go @@ -3,6 +3,7 @@ package handlers import ( "errors" "fmt" + "math" "net/http" "strings" "syscall" @@ -296,7 +297,9 @@ func createMenuItems(active string) []types.MainMenuItem { } // Builders menu group (Gloas/EIP-8282): builders are tracked separately from validators. - if specs != nil && specs.GloasForkEpoch != nil && uint64(chainState.CurrentEpoch()) >= *specs.GloasForkEpoch { + // 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{ 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_deposits.go b/services/chainservice_deposits.go index 69de7e159..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 - } - - // Assign EL deposit indexes by position, flagging postponed (reordered) entries - // that must instead be resolved by slot. + // 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) - 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 + if lastIncludedDeposit != nil && lastIncludedDeposit.Index != nil { + anchorIndex := *lastIncludedDeposit.Index + indexedQueue.LastIncludedDepositIndex = &anchorIndex + } + + // 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 + } } } @@ -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 } diff --git a/templates/builder_deposits/builder_deposits.html b/templates/builder_deposits/builder_deposits.html index 0694f072e..ba3d463cc 100644 --- a/templates/builder_deposits/builder_deposits.html +++ b/templates/builder_deposits/builder_deposits.html @@ -14,6 +14,55 @@

+ + {{ 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 }} +
@@ -142,7 +191,13 @@

{{ end }} - {{ if $deposit.HasBuilderIndex }} + {{ if $deposit.IsProjected }} + {{ if $deposit.IsQueued }} + #{{ $deposit.QueuePosition }} + {{ else }} + - + {{ end }} + {{ else if $deposit.HasBuilderIndex }} {{ formatBuilderWithIndex $deposit.BuilderIndex "" }} {{ else if $deposit.IsInactiveBuilder }} {{ formatInactiveBuilder $deposit.PublicKey }} @@ -162,7 +217,21 @@

{{ formatEthFromGwei $deposit.Amount }} - {{ if not $deposit.IsIncluded }} + {{ 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 diff --git a/types/models/builder_deposits.go b/types/models/builder_deposits.go index e429b6814..0cb648f87 100644 --- a/types/models/builder_deposits.go +++ b/types/models/builder_deposits.go @@ -19,6 +19,25 @@ type BuilderDepositsPageData struct { 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"` @@ -54,4 +73,17 @@ type BuilderDepositsPageDataDeposit struct { 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) } From 4915cd2c277f14085d60ccb91c36749d647125f2 Mon Sep 17 00:00:00 2001 From: pk910 Date: Mon, 22 Jun 2026 19:30:37 +0200 Subject: [PATCH 19/22] reduce log verbosity for page call errors --- services/chainservice_blocks.go | 44 ++++++--------------------------- 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/services/chainservice_blocks.go b/services/chainservice_blocks.go index 30636780c..b5c0aaba0 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 From 38752ad60b073eee4d1b4c4fe4740fcb7b480a7a Mon Sep 17 00:00:00 2001 From: pk910 Date: Tue, 23 Jun 2026 00:15:10 +0200 Subject: [PATCH 20/22] fix cache issue with value types --- handlers/block.go | 10 +++++----- handlers/search.go | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) 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/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 } From 80c8af31ff0b6fc9342c54d3de2768ec49fcc2a3 Mon Sep 17 00:00:00 2001 From: pk910 Date: Tue, 23 Jun 2026 02:17:22 +0200 Subject: [PATCH 21/22] fix `GetHighestElBlockNumber` for blocks without payloads --- services/chainservice_blocks.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/services/chainservice_blocks.go b/services/chainservice_blocks.go index b5c0aaba0..20cf62dfe 100644 --- a/services/chainservice_blocks.go +++ b/services/chainservice_blocks.go @@ -1401,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() From 6608e1e9a109951ee765202b8cbedaaef0cfeed0 Mon Sep 17 00:00:00 2001 From: pk910 Date: Tue, 23 Jun 2026 05:22:01 +0200 Subject: [PATCH 22/22] `go fmt` --- blockdb/types/execdata_sections_ssz.go | 2 +- indexer/beacon/epochstats_ssz.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/blockdb/types/execdata_sections_ssz.go b/blockdb/types/execdata_sections_ssz.go index 2ac1afd4d..97375edec 100644 --- a/blockdb/types/execdata_sections_ssz.go +++ b/blockdb/types/execdata_sections_ssz.go @@ -416,7 +416,7 @@ func (t *StateChangeAccount) UnmarshalSSZ(buf []byte) (err error) { return sszutils.ErrorWithPathf(sszutils.ErrFixedFieldsEOFFn(buflen, 96), "Slots[%d]", idx1) } if buflen > 96 { - return sszutils.ErrorWithPathf(sszutils.ErrTrailingDataFn(buflen - 96), "Slots[%d]", idx1) + return sszutils.ErrorWithPathf(sszutils.ErrTrailingDataFn(buflen-96), "Slots[%d]", idx1) } { // Field #0 'Slot' (static) buf := buf[0:32] diff --git a/indexer/beacon/epochstats_ssz.go b/indexer/beacon/epochstats_ssz.go index 01cf9ae36..c87624c9a 100644 --- a/indexer/beacon/epochstats_ssz.go +++ b/indexer/beacon/epochstats_ssz.go @@ -246,7 +246,7 @@ func (t *EpochStatsPacked) UnmarshalSSZ(buf []byte) (err error) { return sszutils.ErrorWithPathf(sszutils.ErrFixedFieldsEOFFn(buflen, 8), "ActiveValidators[%d]", idx1) } if buflen > 8 { - return sszutils.ErrorWithPathf(sszutils.ErrTrailingDataFn(buflen - 8), "ActiveValidators[%d]", idx1) + return sszutils.ErrorWithPathf(sszutils.ErrTrailingDataFn(buflen-8), "ActiveValidators[%d]", idx1) } { // Field #0 'ValidatorIndexOffset' (static) buf := buf[0:4]