From 09988ca3dd26534361cf60fe2a1b3a4f2136af05 Mon Sep 17 00:00:00 2001 From: pk910 Date: Fri, 14 Aug 2026 16:19:34 +0200 Subject: [PATCH 1/4] show early regular builder deposits --- db/builder_deposit_request_txs.go | 31 +- db/builder_exit_request_txs.go | 31 +- db/slots.go | 20 ++ handlers/builder_deposits.go | 74 ++++- .../builder_deposit_indexer.go | 48 +++- .../system_contracts/builder_exit_indexer.go | 48 +++- .../system_contracts/contract_indexer.go | 264 ++++++++++++++---- .../system_contracts/contract_indexer_test.go | 129 +++++++++ .../system_contracts/gloas_activation.go | 155 ++++++++++ services/chainservice_builder_requests.go | 21 ++ .../builder_deposits/builder_deposits.html | 16 +- types/models/builder_deposits.go | 2 + 12 files changed, 764 insertions(+), 75 deletions(-) create mode 100644 indexer/execution/system_contracts/gloas_activation.go diff --git a/db/builder_deposit_request_txs.go b/db/builder_deposit_request_txs.go index bab156cb..31866c66 100644 --- a/db/builder_deposit_request_txs.go +++ b/db/builder_deposit_request_txs.go @@ -80,6 +80,34 @@ func GetBuilderDepositTxsByDequeueRange(ctx context.Context, dequeueFirst uint64 return depositTxs } +// GetBuilderDepositTxsUpToBlock returns all builder deposit request txs up to the given el +// block number, in queue (block number, log index) order. +func GetBuilderDepositTxsUpToBlock(ctx context.Context, maxBlockNumber uint64) []*dbtypes.BuilderDepositTx { + depositTxs := []*dbtypes.BuilderDepositTx{} + + err := ReaderDb.SelectContext(ctx, &depositTxs, `SELECT builder_deposit_request_txs.* + FROM builder_deposit_request_txs + WHERE block_number <= $1 + ORDER BY block_number ASC, block_index ASC + `, maxBlockNumber) + if err != nil { + logger.Errorf("Error while fetching builder deposit txs: %v", err) + return nil + } + + return depositTxs +} + +// UpdateBuilderDepositTxDequeueBlock updates the dequeue block of a builder deposit request tx. +func UpdateBuilderDepositTxDequeueBlock(ctx context.Context, tx *sqlx.Tx, blockRoot []byte, blockIndex uint64, dequeueBlock uint64) error { + _, err := tx.ExecContext(ctx, `UPDATE builder_deposit_request_txs + SET dequeue_block = $1 + WHERE block_root = $2 AND block_index = $3 + `, dequeueBlock, blockRoot, blockIndex) + + return err +} + func GetBuilderDepositTxsByTxHashes(ctx context.Context, txHashes [][]byte) []*dbtypes.BuilderDepositTx { var sql strings.Builder args := make([]any, len(txHashes)) @@ -117,8 +145,9 @@ func GetBuilderDepositTxsFiltered(ctx context.Context, offset uint64, limit uint filterOp := "WHERE" if filter.MinDequeue > 0 { + // dequeue block 0 = queued before dequeue activation, not determinable yet - still pending args = append(args, filter.MinDequeue) - fmt.Fprintf(&sql, " %v dequeue_block >= $%v", filterOp, len(args)) + fmt.Fprintf(&sql, " %v (dequeue_block >= $%v OR dequeue_block = 0)", filterOp, len(args)) filterOp = "AND" } if filter.MaxDequeue > 0 { diff --git a/db/builder_exit_request_txs.go b/db/builder_exit_request_txs.go index eaaab21c..cf335399 100644 --- a/db/builder_exit_request_txs.go +++ b/db/builder_exit_request_txs.go @@ -78,6 +78,34 @@ func GetBuilderExitTxsByDequeueRange(ctx context.Context, dequeueFirst uint64, d return exitTxs } +// GetBuilderExitTxsUpToBlock returns all builder exit request txs up to the given el block +// number, in queue (block number, log index) order. +func GetBuilderExitTxsUpToBlock(ctx context.Context, maxBlockNumber uint64) []*dbtypes.BuilderExitTx { + exitTxs := []*dbtypes.BuilderExitTx{} + + err := ReaderDb.SelectContext(ctx, &exitTxs, `SELECT builder_exit_request_txs.* + FROM builder_exit_request_txs + WHERE block_number <= $1 + ORDER BY block_number ASC, block_index ASC + `, maxBlockNumber) + if err != nil { + logger.Errorf("Error while fetching builder exit txs: %v", err) + return nil + } + + return exitTxs +} + +// UpdateBuilderExitTxDequeueBlock updates the dequeue block of a builder exit request tx. +func UpdateBuilderExitTxDequeueBlock(ctx context.Context, tx *sqlx.Tx, blockRoot []byte, blockIndex uint64, dequeueBlock uint64) error { + _, err := tx.ExecContext(ctx, `UPDATE builder_exit_request_txs + SET dequeue_block = $1 + WHERE block_root = $2 AND block_index = $3 + `, dequeueBlock, blockRoot, blockIndex) + + return err +} + func GetBuilderExitTxsByTxHashes(ctx context.Context, txHashes [][]byte) []*dbtypes.BuilderExitTx { var sql strings.Builder args := make([]any, len(txHashes)) @@ -115,8 +143,9 @@ func GetBuilderExitTxsFiltered(ctx context.Context, offset uint64, limit uint32, filterOp := "WHERE" if filter.MinDequeue > 0 { + // dequeue block 0 = queued before dequeue activation, not determinable yet - still pending args = append(args, filter.MinDequeue) - fmt.Fprintf(&sql, " %v dequeue_block >= $%v", filterOp, len(args)) + fmt.Fprintf(&sql, " %v (dequeue_block >= $%v OR dequeue_block = 0)", filterOp, len(args)) filterOp = "AND" } if filter.MaxDequeue > 0 { diff --git a/db/slots.go b/db/slots.go index 1fa39e1f..74f3ec33 100644 --- a/db/slots.go +++ b/db/slots.go @@ -700,6 +700,26 @@ func GetHighestRootBeforeSlot(ctx context.Context, slot uint64, withOrphaned boo return result } +// GetFirstCanonicalElBlockNumber returns the slot and el block number of the first canonical +// block at or after the given slot that carries an execution payload. +func GetFirstCanonicalElBlockNumber(ctx context.Context, minSlot uint64) (uint64, uint64, bool) { + result := struct { + Slot uint64 `db:"slot"` + EthBlockNumber uint64 `db:"eth_block_number"` + }{} + + err := ReaderDb.GetContext(ctx, &result, ` + SELECT slot, eth_block_number FROM slots + WHERE slot >= $1 AND status = 1 AND eth_block_number IS NOT NULL + ORDER BY slot ASC LIMIT 1 + `, minSlot) + if err != nil { + return 0, 0, false + } + + return result.Slot, result.EthBlockNumber, true +} + func GetSlotAssignment(ctx context.Context, slot uint64) uint64 { proposer := uint64(math.MaxInt64) err := ReaderDb.GetContext(ctx, &proposer, ` diff --git a/handlers/builder_deposits.go b/handlers/builder_deposits.go index c6242055..2fcf99d9 100644 --- a/handlers/builder_deposits.go +++ b/handlers/builder_deposits.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "strconv" + "time" "github.com/ethereum/go-ethereum/common" "github.com/ethpandaops/dora/dbtypes" @@ -362,6 +363,49 @@ func buildBuilderDepositsProjectionPageData(ctx context.Context, pageIdx uint64, pageData.NewDepositEstimateTime = projection.NewDepositEstimateTime } + // Regular builder deposits already queued in the builder deposit contract: they can be + // submitted before the fork, but stay locked in the queue until dequeuing starts with the + // first Gloas payload, so no builder index is assigned yet (builders onboarded at the fork + // transition are registered first). Listed before the projected onboarding deposits, like + // pending request txs on the post-fork page. + pageOffset := (pageIdx - 1) * pageSize + queuedFilter := &dbtypes.BuilderDepositTxFilter{ + PublicKey: common.FromHex(pubkey), + } + if minAmount != 0 { + queuedFilter.MinAmount = &minAmount + } + if maxAmount != 0 { + queuedFilter.MaxAmount = &maxAmount + } + + queuedTxs, totalTxRows := services.GlobalBeaconService.GetQueuedBuilderDepositTxs(ctx, queuedFilter, pageOffset, uint32(pageSize)) + pageData.QueuedRegularCount = totalTxRows + + queuedRows := make([]*models.BuilderDepositsPageDataDeposit, 0, len(queuedTxs)) + for _, queuedTx := range queuedTxs { + depositTx := queuedTx.Transaction + queuedRows = append(queuedRows, &models.BuilderDepositsPageDataDeposit{ + IsQueuedRegular: true, + Time: time.Unix(int64(depositTx.BlockTime), 0), + PublicKey: depositTx.PublicKey, + WithdrawalCredentials: depositTx.WithdrawalCredentials, + Amount: depositTx.Amount, + BlockNumber: depositTx.BlockNumber, + HasTransaction: true, + TransactionHash: depositTx.TxHash, + TransactionOrphaned: queuedTx.TransactionOrphaned, + TransactionDetails: &models.BuilderPageDataDepositTxDetails{ + BlockNumber: depositTx.BlockNumber, + BlockHash: fmt.Sprintf("%#x", depositTx.BlockRoot), + BlockTime: depositTx.BlockTime, + TxOrigin: common.Address(depositTx.TxSender).Hex(), + TxTarget: common.Address(depositTx.TxTarget).Hex(), + TxHash: fmt.Sprintf("%#x", depositTx.TxHash), + }, + }) + } + // Map and filter the projected deposits (slot / pubkey / amount; builder-index filter ignored). pubkeyFilter := common.FromHex(pubkey) matched := make([]*models.BuilderDepositsPageDataDeposit, 0) @@ -441,16 +485,28 @@ func buildBuilderDepositsProjectionPageData(ctx context.Context, pageIdx uint64, } } - totalRows := uint64(len(matched)) - start := (pageIdx - 1) * pageSize - end := start + pageSize - if start > totalRows { - start = totalRows - } - if end > totalRows { - end = totalRows + // combined pagination: queued request txs first, then the projected onboarding deposits + totalRows := totalTxRows + uint64(len(matched)) + + deposits := queuedRows + if uint64(len(deposits)) < pageSize { + matchedOffset := uint64(0) + if pageOffset > totalTxRows { + matchedOffset = pageOffset - totalTxRows + } + if matchedOffset > uint64(len(matched)) { + matchedOffset = uint64(len(matched)) + } + + matchedEnd := matchedOffset + pageSize - uint64(len(deposits)) + if matchedEnd > uint64(len(matched)) { + matchedEnd = uint64(len(matched)) + } + + deposits = append(deposits, matched[matchedOffset:matchedEnd]...) } - pageData.Deposits = matched[start:end] + + pageData.Deposits = deposits pageData.DepositCount = uint64(len(pageData.Deposits)) if pageData.DepositCount > 0 { diff --git a/indexer/execution/system_contracts/builder_deposit_indexer.go b/indexer/execution/system_contracts/builder_deposit_indexer.go index 9b1366ca..9897a5f3 100644 --- a/indexer/execution/system_contracts/builder_deposit_indexer.go +++ b/indexer/execution/system_contracts/builder_deposit_indexer.go @@ -21,10 +21,11 @@ import ( // 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] + indexerCtx *execution.IndexerCtx + logger logrus.FieldLogger + indexer *contractIndexer[dbtypes.BuilderDepositTx] + matcher *transactionMatcher[builderDepositMatch] + activationResolver *gloasActivationResolver } type builderDepositMatch struct { @@ -41,8 +42,9 @@ func NewBuilderDepositIndexer(indexer *execution.IndexerCtx) *BuilderDepositInde } bi := &BuilderDepositIndexer{ - indexerCtx: indexer, - logger: indexer.Logger.WithField("indexer", "builder_deposits"), + indexerCtx: indexer, + logger: indexer.Logger.WithField("indexer", "builder_deposits"), + activationResolver: newGloasActivationResolver(indexer), } specs := indexer.ChainState.GetSpecs() @@ -59,6 +61,10 @@ func NewBuilderDepositIndexer(indexer *execution.IndexerCtx) *BuilderDepositInde deployBlock: uint64(utils.Config.ExecutionApi.GloasDeployBlock), dequeueRate: specs.MaxBuilderDepositRequestsPerPayload, + queueActivationBlock: bi.activationResolver.resolveActivationBlock, + loadRebaseRows: bi.loadRebaseRows, + persistRebaseRows: bi.persistRebaseRows, + processFinalTx: bi.processFinalTx, processRecentTx: bi.processRecentTx, persistTxs: bi.persistBuilderDepositTxs, @@ -180,6 +186,36 @@ func (bi *BuilderDepositIndexer) parseRequestLog(log *types.Log) *dbtypes.Builde return requestTx } +// loadRebaseRows loads persisted builder deposit request txs for the one-time dequeue rebase. +func (bi *BuilderDepositIndexer) loadRebaseRows(maxBlockNumber uint64) []*dequeueRebaseRow { + depositTxs := db.GetBuilderDepositTxsUpToBlock(bi.indexerCtx.Ctx, maxBlockNumber) + + rows := make([]*dequeueRebaseRow, len(depositTxs)) + for idx, depositTx := range depositTxs { + rows[idx] = &dequeueRebaseRow{ + blockRoot: depositTx.BlockRoot, + blockNumber: depositTx.BlockNumber, + blockIndex: depositTx.BlockIndex, + forkId: depositTx.ForkId, + dequeueBlock: depositTx.DequeueBlock, + } + } + + return rows +} + +// persistRebaseRows persists rebased dequeue blocks of builder deposit request txs. +func (bi *BuilderDepositIndexer) persistRebaseRows(tx *sqlx.Tx, rows []*dequeueRebaseRow) error { + for _, row := range rows { + err := db.UpdateBuilderDepositTxDequeueBlock(bi.indexerCtx.Ctx, tx, row.blockRoot, row.blockIndex, row.dequeueBlock) + if err != nil { + return fmt.Errorf("error while updating builder deposit tx dequeue block: %w", err) + } + } + + return nil +} + // persistBuilderDepositTxs persists builder deposit request txs to the database. func (bi *BuilderDepositIndexer) persistBuilderDepositTxs(tx *sqlx.Tx, requests []*dbtypes.BuilderDepositTx) error { requestCount := len(requests) diff --git a/indexer/execution/system_contracts/builder_exit_indexer.go b/indexer/execution/system_contracts/builder_exit_indexer.go index 9c42f921..f62e92fc 100644 --- a/indexer/execution/system_contracts/builder_exit_indexer.go +++ b/indexer/execution/system_contracts/builder_exit_indexer.go @@ -20,10 +20,11 @@ import ( // 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] + indexerCtx *execution.IndexerCtx + logger logrus.FieldLogger + indexer *contractIndexer[dbtypes.BuilderExitTx] + matcher *transactionMatcher[builderExitMatch] + activationResolver *gloasActivationResolver } type builderExitMatch struct { @@ -40,8 +41,9 @@ func NewBuilderExitIndexer(indexer *execution.IndexerCtx) *BuilderExitIndexer { } bi := &BuilderExitIndexer{ - indexerCtx: indexer, - logger: indexer.Logger.WithField("indexer", "builder_exits"), + indexerCtx: indexer, + logger: indexer.Logger.WithField("indexer", "builder_exits"), + activationResolver: newGloasActivationResolver(indexer), } specs := indexer.ChainState.GetSpecs() @@ -58,6 +60,10 @@ func NewBuilderExitIndexer(indexer *execution.IndexerCtx) *BuilderExitIndexer { deployBlock: uint64(utils.Config.ExecutionApi.GloasDeployBlock), dequeueRate: specs.MaxBuilderExitRequestsPerPayload, + queueActivationBlock: bi.activationResolver.resolveActivationBlock, + loadRebaseRows: bi.loadRebaseRows, + persistRebaseRows: bi.persistRebaseRows, + processFinalTx: bi.processFinalTx, processRecentTx: bi.processRecentTx, persistTxs: bi.persistBuilderExitTxs, @@ -173,6 +179,36 @@ func (bi *BuilderExitIndexer) parseRequestLog(log *types.Log) *dbtypes.BuilderEx return requestTx } +// loadRebaseRows loads persisted builder exit request txs for the one-time dequeue rebase. +func (bi *BuilderExitIndexer) loadRebaseRows(maxBlockNumber uint64) []*dequeueRebaseRow { + exitTxs := db.GetBuilderExitTxsUpToBlock(bi.indexerCtx.Ctx, maxBlockNumber) + + rows := make([]*dequeueRebaseRow, len(exitTxs)) + for idx, exitTx := range exitTxs { + rows[idx] = &dequeueRebaseRow{ + blockRoot: exitTx.BlockRoot, + blockNumber: exitTx.BlockNumber, + blockIndex: exitTx.BlockIndex, + forkId: exitTx.ForkId, + dequeueBlock: exitTx.DequeueBlock, + } + } + + return rows +} + +// persistRebaseRows persists rebased dequeue blocks of builder exit request txs. +func (bi *BuilderExitIndexer) persistRebaseRows(tx *sqlx.Tx, rows []*dequeueRebaseRow) error { + for _, row := range rows { + err := db.UpdateBuilderExitTxDequeueBlock(bi.indexerCtx.Ctx, tx, row.blockRoot, row.blockIndex, row.dequeueBlock) + if err != nil { + return fmt.Errorf("error while updating builder exit tx dequeue block: %w", err) + } + } + + return nil +} + // persistBuilderExitTxs persists builder exit request txs to the database. func (bi *BuilderExitIndexer) persistBuilderExitTxs(tx *sqlx.Tx, requests []*dbtypes.BuilderExitTx) error { requestCount := len(requests) diff --git a/indexer/execution/system_contracts/contract_indexer.go b/indexer/execution/system_contracts/contract_indexer.go index cb0f3d4e..51d79a8e 100644 --- a/indexer/execution/system_contracts/contract_indexer.go +++ b/indexer/execution/system_contracts/contract_indexer.go @@ -37,6 +37,20 @@ type contractIndexerOptions[TxType any] struct { deployBlock uint64 // block number from where to start crawling logs dequeueRate uint64 // number of logs to dequeue per block, 0 for no queue + // queueActivationBlock resolves the first el block number where request dequeuing is active + // on the given fork (nil = finalized canonical view). The queue is locked before that block, + // so requests logged earlier get dequeue block 0 (not determinable yet) until the activation + // block is observable. Block numbers shift with missed slots and can differ between forks, so + // the resolution must come from indexed post-fork blocks. nil = dequeuing active since deployment. + queueActivationBlock func(fork *exectx.ForkWithClients) (uint64, bool) + + // loadRebaseRows loads all persisted request txs up to the given el block number in queue + // order for the one-time dequeue rebase (required when queueActivationBlock is set) + loadRebaseRows func(maxBlockNumber uint64) []*dequeueRebaseRow + + // persistRebaseRows persists rebased dequeue blocks (required when queueActivationBlock is set) + persistRebaseRows func(tx *sqlx.Tx, rows []*dequeueRebaseRow) error + // processFinalTx processes a finalized transaction log processFinalTx func(log *types.Log, tx *types.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, parentTxs []*TxType) (*TxType, error) @@ -47,11 +61,25 @@ type contractIndexerOptions[TxType any] struct { persistTxs func(tx *sqlx.Tx, txs []*TxType) error } +// dequeueRebaseRow is a persisted request tx reference used by the one-time dequeue rebase. +type dequeueRebaseRow struct { + blockRoot []byte + blockNumber uint64 + blockIndex uint64 + forkId uint64 + dequeueBlock uint64 +} + // contractIndexerState represents the current state of the contract indexer type contractIndexerState struct { FinalBlock uint64 `json:"final_block"` FinalQueueLen uint64 `json:"final_queue"` ForkStates map[beacon.ForkKey]*contractIndexerForkState `json:"fork_states"` + + // QueueActivationBlock is the finalized el block number where request dequeuing started + // (0 = not resolved yet). Set once by the dequeue rebase for contracts with a + // queueActivationBlock resolver. + QueueActivationBlock uint64 `json:"activation_block,omitempty"` } // contractIndexerForkState represents the state of the contract indexer for a specific unfinalized fork @@ -110,6 +138,13 @@ func (ci *contractIndexer[_]) runContractIndexer() error { ci.loadState() } + // rebase pre-activation dequeue blocks first, so the transaction matcher never sees the + // activation block range with unassigned (0) dequeue blocks + err := ci.runDequeueRebase() + if err != nil { + return fmt.Errorf("error while rebasing dequeue blocks: %w", err) + } + finalizedEpoch, _ := ci.indexer.ChainState.GetFinalizedCheckpoint() if finalizedEpoch > 0 { finalizedBlockNumber := ci.getFinalizedBlockNumber() @@ -156,6 +191,167 @@ func (ci *contractIndexer[_]) getFinalizedBlockNumber() uint64 { return finalizedBlockNumber } +// getQueueActivationBlock returns the first el block number where request dequeuing is active +// for the given fork (nil = finalized canonical view). Contracts without an activation +// resolver dequeue since deployment. +func (ci *contractIndexer[_]) getQueueActivationBlock(fork *exectx.ForkWithClients) (uint64, bool) { + if ci.options.queueActivationBlock == nil { + return 0, true + } + + if ci.state.QueueActivationBlock != 0 { + return ci.state.QueueActivationBlock, true + } + + return ci.options.queueActivationBlock(fork) +} + +// applyQueueDequeues applies the requests dequeued in blocks [fromBlock, toBlock] (inclusive) +// to the queue length. Dequeuing only happens from the activation block onwards, so earlier +// blocks in the range dequeue nothing; while the activation block is unknown the queue only grows. +func (ci *contractIndexer[_]) applyQueueDequeues(queueLength, fromBlock, toBlock, activationBlock uint64, activationKnown bool) uint64 { + if ci.options.dequeueRate == 0 || !activationKnown { + return queueLength + } + + if fromBlock < activationBlock { + fromBlock = activationBlock + } + + if toBlock < fromBlock { + return queueLength + } + + dequeuedRequests := (toBlock - fromBlock + 1) * ci.options.dequeueRate + if dequeuedRequests > queueLength { + return 0 + } + + return queueLength - dequeuedRequests +} + +// calculateDequeueBlock returns the el block number where a request logged in logBlock leaves +// the contract queue, given the queue length at the start of logBlock. While the activation +// block is not known yet it returns 0 (not determinable - assigned by the dequeue rebase once +// the first post-activation block is finalized). +func (ci *contractIndexer[_]) calculateDequeueBlock(logBlock, queueLength, activationBlock uint64, activationKnown bool) uint64 { + if ci.options.dequeueRate == 0 { + return logBlock + } + + if !activationKnown { + return 0 + } + + dequeueBase := logBlock + if dequeueBase < activationBlock { + dequeueBase = activationBlock + } + + return dequeueBase + (queueLength / ci.options.dequeueRate) +} + +// computeRebasedDequeueBlocks replays the given request tx rows (in queue order) through the +// queue with dequeuing starting at the activation block. Finalized rows (fork id 0) get their +// exact dequeue blocks; non-finalized rows with a stale pre-activation dequeue block are placed +// behind the finalized backlog (matching prefers finalized txs, but they must leave the pending +// window eventually). It returns the rows whose dequeue block changed and the queue length +// remaining after the last finalized block. +func (ci *contractIndexer[_]) computeRebasedDequeueBlocks(rows []*dequeueRebaseRow, activationBlock, finalBlock uint64) ([]*dequeueRebaseRow, uint64) { + updates := make([]*dequeueRebaseRow, 0, len(rows)) + strandedRows := make([]*dequeueRebaseRow, 0) + queueLength := uint64(0) + queueBlock := uint64(0) + finalizedCount := uint64(0) + + for _, row := range rows { + if row.forkId != 0 { + if row.dequeueBlock < activationBlock { + strandedRows = append(strandedRows, row) + } + + continue + } + + if row.blockNumber > queueBlock { + queueLength = ci.applyQueueDequeues(queueLength, queueBlock, row.blockNumber-1, activationBlock, true) + queueBlock = row.blockNumber + } + + dequeueBlock := ci.calculateDequeueBlock(row.blockNumber, queueLength, activationBlock, true) + queueLength++ + finalizedCount++ + + if dequeueBlock != row.dequeueBlock { + row.dequeueBlock = dequeueBlock + updates = append(updates, row) + } + } + + for idx, row := range strandedRows { + row.dequeueBlock = activationBlock + ((finalizedCount + uint64(idx)) / ci.options.dequeueRate) + updates = append(updates, row) + } + + // preserve the queue state up to and including the last finalized block + if finalBlock >= queueBlock { + queueLength = ci.applyQueueDequeues(queueLength, queueBlock, finalBlock, activationBlock, true) + } + + return updates, queueLength +} + +// runDequeueRebase reassigns the dequeue blocks of already-persisted request txs once the queue +// activation block is final. Requests enqueued before the activation fork are stored with +// dequeue block 0, as block numbers shift with missed slots and may differ between forks until +// the boundary is finalized. Once the first post-activation block is finalized, the finalized +// rows are replayed through the queue to assign their real dequeue blocks and to reseed the +// tracked queue length. Runs once; the resolved activation block is kept in the indexer state. +func (ci *contractIndexer[_]) runDequeueRebase() error { + if ci.options.queueActivationBlock == nil || ci.options.dequeueRate == 0 { + return nil + } + + if ci.state.QueueActivationBlock != 0 { + return nil + } + + activationBlock, activationKnown := ci.options.queueActivationBlock(nil) + if !activationKnown { + return nil + } + + // include stale rows up to the activation block even if the finalized crawl is behind + maxBlockNumber := ci.state.FinalBlock + if activationBlock > 0 && activationBlock-1 > maxBlockNumber { + maxBlockNumber = activationBlock - 1 + } + + rows := ci.options.loadRebaseRows(maxBlockNumber) + updates, queueLength := ci.computeRebasedDequeueBlocks(rows, activationBlock, ci.state.FinalBlock) + + err := db.RunDBTransaction(func(tx *sqlx.Tx) error { + if len(updates) > 0 { + err := ci.options.persistRebaseRows(tx, updates) + if err != nil { + return fmt.Errorf("error while persisting rebased dequeue blocks: %w", err) + } + } + + ci.state.QueueActivationBlock = activationBlock + ci.state.FinalQueueLen = queueLength + + return ci.persistState(tx) + }) + if err != nil { + return err + } + + ci.logger.Infof("queue activation block %v resolved, rebased dequeue blocks of %v request txs (%v queued)", activationBlock, len(updates), queueLength) + + return nil +} + // loadFilteredLogs fetches filtered logs from the execution client func (ci *contractIndexer[_]) loadFilteredLogs(ctx context.Context, client *execution.Client, query ethereum.FilterQuery) ([]types.Log, error) { ctx, cancel := context.WithTimeout(ctx, 60*time.Second) @@ -202,6 +398,8 @@ func (ci *contractIndexer[TxType]) processFinalizedBlocks(finalizedBlockNumber u ctx, cancel := context.WithCancel(ci.indexer.Ctx) defer cancel() + activationBlock, activationKnown := ci.getQueueActivationBlock(nil) + retryCount := 0 // process blocks in range until the finalized block is reached @@ -296,24 +494,15 @@ func (ci *contractIndexer[TxType]) processFinalizedBlocks(finalizedBlockNumber u ci.logger.Warnf("contract log for block %v received after block %v", log.BlockNumber, queueBlock) return nil } else if ci.options.dequeueRate > 0 && queueBlock < log.BlockNumber { - // calculate how many requests were dequeued since the last processed log - dequeuedRequests := (log.BlockNumber - queueBlock) * ci.options.dequeueRate - if dequeuedRequests > queueLength { - queueLength = 0 - } else { - queueLength -= dequeuedRequests - } - + // apply the requests dequeued since the last processed log + queueLength = ci.applyQueueDequeues(queueLength, queueBlock, log.BlockNumber-1, activationBlock, activationKnown) queueBlock = log.BlockNumber } // calculate the dequeue block number for the current log - var dequeueBlock uint64 + dequeueBlock := ci.calculateDequeueBlock(log.BlockNumber, queueLength, activationBlock, activationKnown) if ci.options.dequeueRate > 0 { - dequeueBlock = log.BlockNumber + (queueLength / ci.options.dequeueRate) queueLength++ - } else { - dequeueBlock = log.BlockNumber } // process the log and get the corresponding transaction @@ -329,18 +518,9 @@ func (ci *contractIndexer[TxType]) processFinalizedBlocks(finalizedBlockNumber u requestTxs = append(requestTxs, requestTx) } - // calculate how many requests were dequeued at the end of the current block range - if ci.options.dequeueRate > 0 { - // we need to add 1 to the block range as we want to preserve the queue state after the last block in the range - dequeuedRequests := (toBlock - queueBlock + 1) * ci.options.dequeueRate - if dequeuedRequests > queueLength { - queueLength = 0 - } else { - queueLength -= dequeuedRequests - } - - queueBlock = toBlock - } + // apply the requests dequeued up to and including the last block in the range, + // so the persisted queue length reflects the state after the whole range + queueLength = ci.applyQueueDequeues(queueLength, queueBlock, toBlock, activationBlock, activationKnown) if len(requestTxs) > 0 { ci.logger.Infof("crawled transactions for block %v - %v: %v events", ci.state.FinalBlock, toBlock, len(requestTxs)) @@ -421,6 +601,11 @@ func (ci *contractIndexer[TxType]) processRecentBlocksForFork(headFork *exectx.F } }() + // the activation block may differ between forks until the boundary is finalized, so it is + // resolved along the processed fork; rows written with an unfinalized activation block are + // re-crawled (and the pre-activation backlog rebased) by the finalization routine + activationBlock, activationKnown := ci.getQueueActivationBlock(headFork) + queueBlock := startBlockNumber // process blocks in range until the head el block is reached @@ -516,23 +701,15 @@ func (ci *contractIndexer[TxType]) processRecentBlocksForFork(headFork *exectx.F ci.logger.Warnf("contract log for block %v received after block %v", log.BlockNumber, queueBlock) return nil } else if ci.options.dequeueRate > 0 && queueBlock < log.BlockNumber { - dequeuedRequests := (log.BlockNumber - queueBlock) * ci.options.dequeueRate - if dequeuedRequests > queueLength { - queueLength = 0 - } else { - queueLength -= dequeuedRequests - } - + // apply the requests dequeued since the last processed log + queueLength = ci.applyQueueDequeues(queueLength, queueBlock, log.BlockNumber-1, activationBlock, activationKnown) queueBlock = log.BlockNumber } // calculate the dequeue block number for the current log - var dequeueBlock uint64 + dequeueBlock := ci.calculateDequeueBlock(log.BlockNumber, queueLength, activationBlock, activationKnown) if ci.options.dequeueRate > 0 { - dequeueBlock = log.BlockNumber + (queueLength / ci.options.dequeueRate) queueLength++ - } else { - dequeueBlock = log.BlockNumber } // process the log and get the corresponding transaction @@ -548,24 +725,17 @@ func (ci *contractIndexer[TxType]) processRecentBlocksForFork(headFork *exectx.F requestTxs = append(requestTxs, requestTx) } - // calculate how many requests were dequeued at the end of the current block range - if ci.options.dequeueRate > 0 { - dequeuedRequests := (toBlock - queueBlock + 1) * ci.options.dequeueRate - if dequeuedRequests > queueLength { - queueLength = 0 - } else { - queueLength -= dequeuedRequests - } - } - - queueBlock = toBlock + // apply the requests dequeued up to and including the last block in the range, + // so the persisted queue length reflects the state after the whole range + queueLength = ci.applyQueueDequeues(queueLength, queueBlock, toBlock, activationBlock, activationKnown) + queueBlock = toBlock + 1 if len(requestTxs) > 0 { ci.logger.Infof("crawled recent contract logs for fork %v (%v-%v): %v events", headFork.ForkId, startBlockNumber, toBlock, len(requestTxs)) } // persist the processed transactions and update the indexer state - err := ci.persistRecentRequestTxs(headFork.ForkId, queueBlock, queueLength, requestTxs) + err := ci.persistRecentRequestTxs(headFork.ForkId, toBlock, queueLength, requestTxs) if err != nil { return fmt.Errorf("could not persist contract logs: %v", err) } diff --git a/indexer/execution/system_contracts/contract_indexer_test.go b/indexer/execution/system_contracts/contract_indexer_test.go index a4bb786b..dc76ee53 100644 --- a/indexer/execution/system_contracts/contract_indexer_test.go +++ b/indexer/execution/system_contracts/contract_indexer_test.go @@ -1,6 +1,7 @@ package system_contracts import ( + "fmt" "math/big" "testing" @@ -28,3 +29,131 @@ func TestTxRecipient(t *testing.T) { t.Errorf("call tx recipient = %x, want %x", got, to) } } + +// TestApplyQueueDequeues checks the activation-aware queue drain: nothing dequeues while the +// activation block is unknown or before it, and only the post-activation part of a block range +// counts. +func TestApplyQueueDequeues(t *testing.T) { + ci := newContractIndexer(nil, nil, &contractIndexerOptions[struct{}]{dequeueRate: 2}) + + tests := []struct { + name string + queueLength uint64 + fromBlock uint64 + toBlock uint64 + activationBlock uint64 + activationKnown bool + want uint64 + }{ + {"activation unknown keeps queue", 10, 5, 20, 0, false, 10}, + {"range fully before activation", 10, 5, 7, 8, true, 10}, + {"range straddling activation", 10, 5, 10, 8, true, 4}, // blocks 8-10 dequeue 3*2 + {"range fully after activation", 10, 10, 10, 8, true, 8}, // block 10 dequeues 2 + {"drain clamps at zero", 3, 8, 20, 8, true, 0}, + {"no activation gate dequeues everywhere", 10, 5, 7, 0, true, 4}, // blocks 5-7 dequeue 3*2 + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ci.applyQueueDequeues(tt.queueLength, tt.fromBlock, tt.toBlock, tt.activationBlock, tt.activationKnown) + if got != tt.want { + t.Errorf("applyQueueDequeues() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestCalculateDequeueBlock checks the dequeue block assignment: requests logged before the +// activation block dequeue from the activation block onwards, and get the 0 sentinel while the +// activation block is not known yet. +func TestCalculateDequeueBlock(t *testing.T) { + ci := newContractIndexer(nil, nil, &contractIndexerOptions[struct{}]{dequeueRate: 2}) + + tests := []struct { + name string + logBlock uint64 + queueLength uint64 + activationBlock uint64 + activationKnown bool + want uint64 + }{ + {"activation unknown yields sentinel", 5, 3, 0, false, 0}, + {"pre-activation log dequeues from activation", 5, 3, 8, true, 9}, + {"post-activation log dequeues from itself", 10, 5, 8, true, 12}, + {"empty queue dequeues in own block", 10, 0, 8, true, 10}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ci.calculateDequeueBlock(tt.logBlock, tt.queueLength, tt.activationBlock, tt.activationKnown) + if got != tt.want { + t.Errorf("calculateDequeueBlock() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestComputeRebasedDequeueBlocks checks the one-time dequeue rebase: finalized rows enqueued +// before the activation block are replayed into exact activation-based dequeue blocks, stale +// non-finalized rows are parked behind the finalized backlog, and the remaining queue length is +// reseeded. +func TestComputeRebasedDequeueBlocks(t *testing.T) { + ci := newContractIndexer(nil, nil, &contractIndexerOptions[struct{}]{dequeueRate: 2}) + + t.Run("pre-activation backlog", func(t *testing.T) { + // 5 finalized requests queued before activation block 100, crawled up to block 90; one + // stale row from a non-finalized fork + rows := []*dequeueRebaseRow{ + {blockNumber: 10, blockIndex: 0, forkId: 0, dequeueBlock: 10}, + {blockNumber: 10, blockIndex: 1, forkId: 0, dequeueBlock: 10}, + {blockNumber: 10, blockIndex: 2, forkId: 0, dequeueBlock: 11}, + {blockNumber: 15, blockIndex: 0, forkId: 5, dequeueBlock: 15}, + {blockNumber: 20, blockIndex: 0, forkId: 0, dequeueBlock: 20}, + {blockNumber: 50, blockIndex: 0, forkId: 0, dequeueBlock: 0}, + } + + updates, queueLength := ci.computeRebasedDequeueBlocks(rows, 100, 90) + + if queueLength != 5 { + t.Errorf("queue length = %v, want 5", queueLength) + } + + wantDequeues := map[string]uint64{ + "10:0": 100, "10:1": 100, "10:2": 101, "20:0": 101, "50:0": 102, // finalized backlog + "15:0": 102, // stale fork row parked behind the 5 finalized requests + } + if len(updates) != len(wantDequeues) { + t.Errorf("updates = %v rows, want %v", len(updates), len(wantDequeues)) + } + for _, row := range updates { + key := fmt.Sprintf("%v:%v", row.blockNumber, row.blockIndex) + if want, ok := wantDequeues[key]; !ok || row.dequeueBlock != want { + t.Errorf("row %v dequeue block = %v, want %v", key, row.dequeueBlock, want) + } + } + }) + + t.Run("crawl already past activation", func(t *testing.T) { + // 3 requests queued before activation block 100, crawled up to block 105: the whole + // backlog dequeues in blocks 100-101 and the queue is empty again + rows := []*dequeueRebaseRow{ + {blockNumber: 10, blockIndex: 0, forkId: 0, dequeueBlock: 10}, + {blockNumber: 10, blockIndex: 1, forkId: 0, dequeueBlock: 10}, + {blockNumber: 10, blockIndex: 2, forkId: 0, dequeueBlock: 10}, + } + + updates, queueLength := ci.computeRebasedDequeueBlocks(rows, 100, 105) + + if queueLength != 0 { + t.Errorf("queue length = %v, want 0", queueLength) + } + if len(updates) != 3 { + t.Fatalf("updates = %v rows, want 3", len(updates)) + } + for idx, want := range []uint64{100, 100, 101} { + if updates[idx].dequeueBlock != want { + t.Errorf("row %v dequeue block = %v, want %v", idx, updates[idx].dequeueBlock, want) + } + } + }) +} diff --git a/indexer/execution/system_contracts/gloas_activation.go b/indexer/execution/system_contracts/gloas_activation.go new file mode 100644 index 00000000..e8cb8514 --- /dev/null +++ b/indexer/execution/system_contracts/gloas_activation.go @@ -0,0 +1,155 @@ +package system_contracts + +import ( + "math" + "sync" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + + "github.com/ethpandaops/dora/db" + "github.com/ethpandaops/dora/indexer/beacon" + "github.com/ethpandaops/dora/indexer/execution" +) + +// gloasActivationResolver resolves the first el block number processed under Gloas rules - the +// block whose payload executes the first builder request dequeue system call. The builder +// contract queues accept requests before the fork but stay locked until that block, and since +// el block numbers are monotonic counters that shift with missed slots, the activation block +// can only be observed from indexed post-fork blocks - never predicted from the fork schedule. +type gloasActivationResolver struct { + indexerCtx *execution.IndexerCtx + + mutex sync.Mutex + forkCache map[beacon.ForkKey]uint64 +} + +// newGloasActivationResolver creates a new gloas activation block resolver. +func newGloasActivationResolver(indexerCtx *execution.IndexerCtx) *gloasActivationResolver { + return &gloasActivationResolver{ + indexerCtx: indexerCtx, + forkCache: make(map[beacon.ForkKey]uint64, 4), + } +} + +// resolveActivationBlock returns the first el block number where builder request dequeuing is +// active on the given fork (nil = finalized canonical view). The finalized view only reports +// the block once the fork boundary is finalized, so its result is stable and safe to persist; +// unfinalized forks may disagree on the block number until then. +func (r *gloasActivationResolver) resolveActivationBlock(fork *execution.ForkWithClients) (uint64, bool) { + chainState := r.indexerCtx.ChainState + + specs := chainState.GetSpecs() + if specs == nil || specs.GloasForkEpoch == nil || *specs.GloasForkEpoch == math.MaxUint64 { + return 0, false + } + + forkEpoch := phase0.Epoch(*specs.GloasForkEpoch) + if chainState.CurrentEpoch() < forkEpoch { + return 0, false + } + + forkSlot := chainState.EpochToSlot(forkEpoch) + + if fork != nil { + return r.resolveUnfinalizedActivationBlock(forkSlot, fork) + } + + // Finalized view: the db only contiguously covers epochs the finalization routine has + // already processed (which lags the finalized checkpoint by up to an epoch) and - while a + // backfill sync is running - the epochs the synchronizer has written. Only report an + // activation block found strictly below that coverage: a not-yet-written region around the + // fork boundary must yield "unknown" rather than a later (wrong) block number. + beaconIndexer := r.indexerCtx.BeaconIndexer + + coveredEpoch, _ := beaconIndexer.GetBlockCacheState() + if syncRunning, syncEpoch := beaconIndexer.GetSynchronizerState(); syncRunning && syncEpoch < coveredEpoch { + coveredEpoch = syncEpoch + } + + if coveredEpoch <= forkEpoch { + return 0, false + } + + slot, blockNumber, found := db.GetFirstCanonicalElBlockNumber(r.indexerCtx.Ctx, uint64(forkSlot)) + if !found || phase0.Slot(slot) >= chainState.EpochToSlot(coveredEpoch) { + return 0, false + } + + return blockNumber, true +} + +// resolveUnfinalizedActivationBlock resolves the activation block along a specific unfinalized +// fork from the block cache: the first block at or after the fork slot on that fork (or one of +// its parents) that carries an execution payload. +func (r *gloasActivationResolver) resolveUnfinalizedActivationBlock(forkSlot phase0.Slot, fork *execution.ForkWithClients) (uint64, bool) { + r.mutex.Lock() + cachedBlock, isCached := r.forkCache[fork.ForkId] + r.mutex.Unlock() + + if isCached { + return cachedBlock, true + } + + beaconIndexer := r.indexerCtx.BeaconIndexer + + // the walk needs the boundary region in the block cache; if it is already pruned, the + // finalized resolution (which sets the persisted activation block) must be used instead + _, prunedEpoch := beaconIndexer.GetBlockCacheState() + if forkSlot < r.indexerCtx.ChainState.EpochToSlot(prunedEpoch) { + return 0, false + } + + headBlock := beaconIndexer.GetCanonicalHead(&fork.ForkId) + if headBlock == nil { + return 0, false + } + + parentForkIds := beaconIndexer.GetParentForkIds(fork.ForkId) + forkIds := make(map[beacon.ForkKey]bool, len(parentForkIds)+1) + forkIds[fork.ForkId] = true + for _, parentForkId := range parentForkIds { + forkIds[parentForkId] = true + } + + for slot := forkSlot; slot <= headBlock.Slot; slot++ { + for _, block := range beaconIndexer.GetBlocksBySlot(slot) { + if !forkIds[block.GetForkId()] { + continue + } + + blockIndex := block.GetBlockIndex(r.indexerCtx.Ctx) + if blockIndex == nil || blockIndex.ExecutionNumber == 0 { + continue + } + + r.cacheActivationBlock(fork.ForkId, blockIndex.ExecutionNumber) + + return blockIndex.ExecutionNumber, true + } + } + + return 0, false +} + +// cacheActivationBlock stores a resolved per-fork activation block, evicting entries of forks +// that no longer have a head. Forks come and go for as long as the boundary is unfinalized (an +// unbounded window on non-finalizing chains), so the eviction keeps the cache bounded by the +// number of live forks. +func (r *gloasActivationResolver) cacheActivationBlock(forkId beacon.ForkKey, blockNumber uint64) { + forkHeads := r.indexerCtx.BeaconIndexer.GetForkHeads() + aliveForkIds := make(map[beacon.ForkKey]bool, len(forkHeads)) + for _, forkHead := range forkHeads { + aliveForkIds[forkHead.ForkId] = true + } + + r.mutex.Lock() + defer r.mutex.Unlock() + + for cachedForkId := range r.forkCache { + if !aliveForkIds[cachedForkId] { + delete(r.forkCache, cachedForkId) + } + } + + r.forkCache[forkId] = blockNumber +} diff --git a/services/chainservice_builder_requests.go b/services/chainservice_builder_requests.go index 58cc2d14..41eb4914 100644 --- a/services/chainservice_builder_requests.go +++ b/services/chainservice_builder_requests.go @@ -101,6 +101,27 @@ func (bs *ChainService) GetBuilderDepositsByFilter(ctx context.Context, filter * return combinedResults, totalPendingTxResults, totalReqResults } +// GetQueuedBuilderDepositTxs returns builder deposit request txs that are still queued in the +// builder deposit contract. Before Gloas activation every request tx is queued - dequeuing only +// starts with the first Gloas payload - so no dequeue-block filter is applied (the dequeue +// block of pre-activation requests is not determinable anyway, as it depends on the yet +// unknown activation block number). +func (bs *ChainService) GetQueuedBuilderDepositTxs(ctx context.Context, filter *dbtypes.BuilderDepositTxFilter, pageOffset uint64, pageSize uint32) ([]*CombinedBuilderDeposit, uint64) { + canonicalForkIds := bs.GetCanonicalForkIds() + + dbTransactions, totalRows, _ := db.GetBuilderDepositTxsFiltered(ctx, pageOffset, pageSize, filter) + + results := make([]*CombinedBuilderDeposit, 0, len(dbTransactions)) + for _, depositTx := range dbTransactions { + results = append(results, &CombinedBuilderDeposit{ + Transaction: depositTx, + TransactionOrphaned: !bs.isCanonicalForkId(depositTx.ForkId, canonicalForkIds), + }) + } + + return results, totalRows +} + 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 { diff --git a/templates/builder_deposits/builder_deposits.html b/templates/builder_deposits/builder_deposits.html index 816a9067..cad630dd 100644 --- a/templates/builder_deposits/builder_deposits.html +++ b/templates/builder_deposits/builder_deposits.html @@ -24,14 +24,16 @@

Gloas activates at epoch {{ .GloasForkEpoch }} ({{ formatRecentTimeShort .GloasForkTime }}). - Builder deposits are not recorded on-chain yet — the entries below are the actual 0xB0-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. + Builder deposits are not recorded on-chain yet — the entries below are the actual 0xB0-credential deposits made through the validator deposit contract, + each annotated with its projected fate at the fork (based on the deposit churn limit and pending queue), plus the regular builder deposits already + queued in the builder deposit contract. Projections 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 .QueuedRegularCount 0 }}
  • {{ .QueuedRegularCount }} regular builder deposit{{ if ne .QueuedRegularCount 1 }}s{{ end }} queued in the builder deposit contract — dequeued after the fork, no builder index yet
  • {{ 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 }} @@ -184,14 +186,16 @@

    {{ end }} - {{ if $deposit.IsIncluded }} + {{ if or $deposit.IsIncluded $deposit.IsQueuedRegular }} {{ formatRecentTimeShort $deposit.Time }} {{ else }} - {{ end }} - {{ if $deposit.IsProjected }} + {{ if $deposit.IsQueuedRegular }} + no index yet + {{ else if $deposit.IsProjected }} {{ if $deposit.IsQueued }} #{{ $deposit.QueuePosition }} {{ else }} @@ -217,7 +221,9 @@

    {{ formatEthFromGwei $deposit.Amount }} - {{ if $deposit.IsProjected }} + {{ if $deposit.IsQueuedRegular }} + Queued + {{ else if $deposit.IsProjected }} {{ if $deposit.ProjectedAlreadyProcessed }} Too early {{ else if $deposit.ProjectedTooEarly }} diff --git a/types/models/builder_deposits.go b/types/models/builder_deposits.go index 64936579..2e51c15c 100644 --- a/types/models/builder_deposits.go +++ b/types/models/builder_deposits.go @@ -31,6 +31,7 @@ type BuilderDepositsPageData struct { InvalidSignatureCount uint64 `json:"invalid_signature_count"` KeptAsValidatorCount uint64 `json:"kept_as_validator_count"` TotalQueueProcessedBeforeFork uint64 `json:"total_queue_processed_before_fork"` + QueuedRegularCount uint64 `json:"queued_regular_count"` // regular deposits queued in the builder deposit contract until the fork // "Is it safe to deposit right now" indicator (projection mode only). HasSafetyEstimate bool `json:"has_safety_estimate"` @@ -78,6 +79,7 @@ type BuilderDepositsPageDataDeposit struct { // Pre-Gloas projection fields (set when the parent page is in projection mode). IsProjected bool `json:"is_projected"` + IsQueuedRegular bool `json:"is_queued_regular"` // regular deposit queued in the builder deposit contract until the fork; no builder index assigned yet 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 From 20eba509436df9072d7d120f6831a7229efc6533 Mon Sep 17 00:00:00 2001 From: pk910 Date: Fri, 14 Aug 2026 16:21:22 +0200 Subject: [PATCH 2/4] trigger CI From 763ed5b16b61458ab057e5878b79d60db6c1831a Mon Sep 17 00:00:00 2001 From: pk910 Date: Fri, 14 Aug 2026 16:26:38 +0200 Subject: [PATCH 3/4] generic fork activation resolver --- .../builder_deposit_indexer.go | 11 ++-- .../system_contracts/builder_exit_indexer.go | 11 ++-- ...gloas_activation.go => fork_activation.go} | 51 ++++++++++++------- 3 files changed, 46 insertions(+), 27 deletions(-) rename indexer/execution/system_contracts/{gloas_activation.go => fork_activation.go} (67%) diff --git a/indexer/execution/system_contracts/builder_deposit_indexer.go b/indexer/execution/system_contracts/builder_deposit_indexer.go index 9897a5f3..9516d737 100644 --- a/indexer/execution/system_contracts/builder_deposit_indexer.go +++ b/indexer/execution/system_contracts/builder_deposit_indexer.go @@ -11,6 +11,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/sirupsen/logrus" + "github.com/ethpandaops/dora/clients/consensus" "github.com/ethpandaops/dora/clients/execution/rpc" "github.com/ethpandaops/dora/db" "github.com/ethpandaops/dora/dbtypes" @@ -25,7 +26,7 @@ type BuilderDepositIndexer struct { logger logrus.FieldLogger indexer *contractIndexer[dbtypes.BuilderDepositTx] matcher *transactionMatcher[builderDepositMatch] - activationResolver *gloasActivationResolver + activationResolver *forkActivationResolver } type builderDepositMatch struct { @@ -42,9 +43,11 @@ func NewBuilderDepositIndexer(indexer *execution.IndexerCtx) *BuilderDepositInde } bi := &BuilderDepositIndexer{ - indexerCtx: indexer, - logger: indexer.Logger.WithField("indexer", "builder_deposits"), - activationResolver: newGloasActivationResolver(indexer), + indexerCtx: indexer, + logger: indexer.Logger.WithField("indexer", "builder_deposits"), + activationResolver: newForkActivationResolver(indexer, func(specs *consensus.ChainSpec) *uint64 { + return specs.GloasForkEpoch + }), } specs := indexer.ChainState.GetSpecs() diff --git a/indexer/execution/system_contracts/builder_exit_indexer.go b/indexer/execution/system_contracts/builder_exit_indexer.go index f62e92fc..849455bd 100644 --- a/indexer/execution/system_contracts/builder_exit_indexer.go +++ b/indexer/execution/system_contracts/builder_exit_indexer.go @@ -10,6 +10,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/sirupsen/logrus" + "github.com/ethpandaops/dora/clients/consensus" "github.com/ethpandaops/dora/clients/execution/rpc" "github.com/ethpandaops/dora/db" "github.com/ethpandaops/dora/dbtypes" @@ -24,7 +25,7 @@ type BuilderExitIndexer struct { logger logrus.FieldLogger indexer *contractIndexer[dbtypes.BuilderExitTx] matcher *transactionMatcher[builderExitMatch] - activationResolver *gloasActivationResolver + activationResolver *forkActivationResolver } type builderExitMatch struct { @@ -41,9 +42,11 @@ func NewBuilderExitIndexer(indexer *execution.IndexerCtx) *BuilderExitIndexer { } bi := &BuilderExitIndexer{ - indexerCtx: indexer, - logger: indexer.Logger.WithField("indexer", "builder_exits"), - activationResolver: newGloasActivationResolver(indexer), + indexerCtx: indexer, + logger: indexer.Logger.WithField("indexer", "builder_exits"), + activationResolver: newForkActivationResolver(indexer, func(specs *consensus.ChainSpec) *uint64 { + return specs.GloasForkEpoch + }), } specs := indexer.ChainState.GetSpecs() diff --git a/indexer/execution/system_contracts/gloas_activation.go b/indexer/execution/system_contracts/fork_activation.go similarity index 67% rename from indexer/execution/system_contracts/gloas_activation.go rename to indexer/execution/system_contracts/fork_activation.go index e8cb8514..715bc301 100644 --- a/indexer/execution/system_contracts/gloas_activation.go +++ b/indexer/execution/system_contracts/fork_activation.go @@ -6,44 +6,57 @@ import ( "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/ethpandaops/dora/clients/consensus" "github.com/ethpandaops/dora/db" "github.com/ethpandaops/dora/indexer/beacon" "github.com/ethpandaops/dora/indexer/execution" ) -// gloasActivationResolver resolves the first el block number processed under Gloas rules - the -// block whose payload executes the first builder request dequeue system call. The builder -// contract queues accept requests before the fork but stay locked until that block, and since -// el block numbers are monotonic counters that shift with missed slots, the activation block -// can only be observed from indexed post-fork blocks - never predicted from the fork schedule. -type gloasActivationResolver struct { +// forkActivationResolver resolves the first el block number processed under a fork's rules - +// the block whose payload executes the contract's first request dequeue system call. Contract +// queues that accept requests before their activation fork (e.g. the Gloas builder contracts) +// stay locked until that block, and since el block numbers are monotonic counters that shift +// with missed slots, the activation block can only be observed from indexed post-fork blocks - +// never predicted from the fork schedule. +type forkActivationResolver struct { indexerCtx *execution.IndexerCtx + // forkEpochSpec extracts the activation fork epoch from the chain specs + // (nil / MaxUint64 = fork not scheduled) + forkEpochSpec func(specs *consensus.ChainSpec) *uint64 + mutex sync.Mutex forkCache map[beacon.ForkKey]uint64 } -// newGloasActivationResolver creates a new gloas activation block resolver. -func newGloasActivationResolver(indexerCtx *execution.IndexerCtx) *gloasActivationResolver { - return &gloasActivationResolver{ - indexerCtx: indexerCtx, - forkCache: make(map[beacon.ForkKey]uint64, 4), +// newForkActivationResolver creates a new fork activation block resolver for the fork epoch +// extracted by the given spec callback. +func newForkActivationResolver(indexerCtx *execution.IndexerCtx, forkEpochSpec func(specs *consensus.ChainSpec) *uint64) *forkActivationResolver { + return &forkActivationResolver{ + indexerCtx: indexerCtx, + forkEpochSpec: forkEpochSpec, + forkCache: make(map[beacon.ForkKey]uint64, 4), } } -// resolveActivationBlock returns the first el block number where builder request dequeuing is -// active on the given fork (nil = finalized canonical view). The finalized view only reports -// the block once the fork boundary is finalized, so its result is stable and safe to persist; +// resolveActivationBlock returns the first el block number where request dequeuing is active +// on the given fork (nil = finalized canonical view). The finalized view only reports the +// block once the fork boundary is finalized, so its result is stable and safe to persist; // unfinalized forks may disagree on the block number until then. -func (r *gloasActivationResolver) resolveActivationBlock(fork *execution.ForkWithClients) (uint64, bool) { +func (r *forkActivationResolver) resolveActivationBlock(fork *execution.ForkWithClients) (uint64, bool) { chainState := r.indexerCtx.ChainState specs := chainState.GetSpecs() - if specs == nil || specs.GloasForkEpoch == nil || *specs.GloasForkEpoch == math.MaxUint64 { + if specs == nil { + return 0, false + } + + forkEpochSpec := r.forkEpochSpec(specs) + if forkEpochSpec == nil || *forkEpochSpec == math.MaxUint64 { return 0, false } - forkEpoch := phase0.Epoch(*specs.GloasForkEpoch) + forkEpoch := phase0.Epoch(*forkEpochSpec) if chainState.CurrentEpoch() < forkEpoch { return 0, false } @@ -81,7 +94,7 @@ func (r *gloasActivationResolver) resolveActivationBlock(fork *execution.ForkWit // resolveUnfinalizedActivationBlock resolves the activation block along a specific unfinalized // fork from the block cache: the first block at or after the fork slot on that fork (or one of // its parents) that carries an execution payload. -func (r *gloasActivationResolver) resolveUnfinalizedActivationBlock(forkSlot phase0.Slot, fork *execution.ForkWithClients) (uint64, bool) { +func (r *forkActivationResolver) resolveUnfinalizedActivationBlock(forkSlot phase0.Slot, fork *execution.ForkWithClients) (uint64, bool) { r.mutex.Lock() cachedBlock, isCached := r.forkCache[fork.ForkId] r.mutex.Unlock() @@ -135,7 +148,7 @@ func (r *gloasActivationResolver) resolveUnfinalizedActivationBlock(forkSlot pha // that no longer have a head. Forks come and go for as long as the boundary is unfinalized (an // unbounded window on non-finalizing chains), so the eviction keeps the cache bounded by the // number of live forks. -func (r *gloasActivationResolver) cacheActivationBlock(forkId beacon.ForkKey, blockNumber uint64) { +func (r *forkActivationResolver) cacheActivationBlock(forkId beacon.ForkKey, blockNumber uint64) { forkHeads := r.indexerCtx.BeaconIndexer.GetForkHeads() aliveForkIds := make(map[beacon.ForkKey]bool, len(forkHeads)) for _, forkHead := range forkHeads { From bfc4caee22c88dc830fd65fd1eedb2fb6c1976e0 Mon Sep 17 00:00:00 2001 From: pk910 Date: Fri, 14 Aug 2026 16:41:12 +0200 Subject: [PATCH 4/4] make badge color less intensive --- templates/builder_deposits/builder_deposits.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/builder_deposits/builder_deposits.html b/templates/builder_deposits/builder_deposits.html index cad630dd..b2bd4a00 100644 --- a/templates/builder_deposits/builder_deposits.html +++ b/templates/builder_deposits/builder_deposits.html @@ -194,10 +194,10 @@

    {{ if $deposit.IsQueuedRegular }} - no index yet + no index yet {{ else if $deposit.IsProjected }} {{ if $deposit.IsQueued }} - #{{ $deposit.QueuePosition }} + #{{ $deposit.QueuePosition }} {{ else }} - {{ end }}