diff --git a/docs/docs.go b/docs/docs.go index 83a7482a..e987fdf6 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2901,6 +2901,32 @@ const docTemplate = `{ } } }, + "api.APIDasGuardianProposerPreference": { + "type": "object", + "properties": { + "dependent_root": { + "type": "string" + }, + "fee_recipient": { + "type": "string" + }, + "from": { + "type": "string" + }, + "gas_limit": { + "type": "integer" + }, + "proposal_slot": { + "type": "integer" + }, + "received_at": { + "type": "string" + }, + "validator_index": { + "type": "integer" + } + } + }, "api.APIDasGuardianScanRequest": { "type": "object", "properties": { @@ -2921,6 +2947,10 @@ const docTemplate = `{ "items": { "type": "integer" } + }, + "wait_for_proposer_preferences_seconds": { + "description": "WaitForProposerPreferencesSeconds, if \u003e 0, keeps the Gloas\n` + "`" + `proposer_preferences` + "`" + ` gossip subscription open for this many seconds\nafter the RPC exchange so the gossip mesh has time to deliver messages.\nCapped at 60s server-side. Ignored on pre-Gloas forks.", + "type": "integer" } } }, @@ -2954,6 +2984,13 @@ const docTemplate = `{ "type": "object", "additionalProperties": true }, + "proposer_preferences": { + "description": "Gloas SignedProposerPreferences sniffed off the peer's gossip during\nthe scan window. Empty pre-Gloas, or when no proposer published in time.", + "type": "array", + "items": { + "$ref": "#/definitions/api.APIDasGuardianProposerPreference" + } + }, "remote_metadata": { "description": "Metadata (from RemoteMetadata)", "allOf": [ diff --git a/docs/swagger.json b/docs/swagger.json index 324204d3..bb9615cc 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2898,6 +2898,32 @@ } } }, + "api.APIDasGuardianProposerPreference": { + "type": "object", + "properties": { + "dependent_root": { + "type": "string" + }, + "fee_recipient": { + "type": "string" + }, + "from": { + "type": "string" + }, + "gas_limit": { + "type": "integer" + }, + "proposal_slot": { + "type": "integer" + }, + "received_at": { + "type": "string" + }, + "validator_index": { + "type": "integer" + } + } + }, "api.APIDasGuardianScanRequest": { "type": "object", "properties": { @@ -2918,6 +2944,10 @@ "items": { "type": "integer" } + }, + "wait_for_proposer_preferences_seconds": { + "description": "WaitForProposerPreferencesSeconds, if \u003e 0, keeps the Gloas\n`proposer_preferences` gossip subscription open for this many seconds\nafter the RPC exchange so the gossip mesh has time to deliver messages.\nCapped at 60s server-side. Ignored on pre-Gloas forks.", + "type": "integer" } } }, @@ -2951,6 +2981,13 @@ "type": "object", "additionalProperties": true }, + "proposer_preferences": { + "description": "Gloas SignedProposerPreferences sniffed off the peer's gossip during\nthe scan window. Empty pre-Gloas, or when no proposer published in time.", + "type": "array", + "items": { + "$ref": "#/definitions/api.APIDasGuardianProposerPreference" + } + }, "remote_metadata": { "description": "Metadata (from RemoteMetadata)", "allOf": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 2783d935..937c1928 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -292,6 +292,23 @@ definitions: syncnets: type: string type: object + api.APIDasGuardianProposerPreference: + properties: + dependent_root: + type: string + fee_recipient: + type: string + from: + type: string + gas_limit: + type: integer + proposal_slot: + type: integer + received_at: + type: string + validator_index: + type: integer + type: object api.APIDasGuardianScanRequest: properties: enr: @@ -307,6 +324,13 @@ definitions: items: type: integer type: array + wait_for_proposer_preferences_seconds: + description: |- + WaitForProposerPreferencesSeconds, if > 0, keeps the Gloas + `proposer_preferences` gossip subscription open for this many seconds + after the RPC exchange so the gossip mesh has time to deliver messages. + Capped at 60s server-side. Ignored on pre-Gloas forks. + type: integer type: object api.APIDasGuardianScanResponse: properties: @@ -327,6 +351,13 @@ definitions: additionalProperties: true description: P2P Information type: object + proposer_preferences: + description: |- + Gloas SignedProposerPreferences sniffed off the peer's gossip during + the scan window. Empty pre-Gloas, or when no proposer published in time. + items: + $ref: '#/definitions/api.APIDasGuardianProposerPreference' + type: array remote_metadata: allOf: - $ref: '#/definitions/api.APIDasGuardianMetadata' diff --git a/go.mod b/go.mod index f16d3ac3..bacc92e0 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/allegro/bigcache/v3 v3.1.0 github.com/cockroachdb/pebble v1.1.5 github.com/ethereum/go-ethereum v1.17.4 - github.com/ethpandaops/eth-das-guardian v0.1.1 + github.com/ethpandaops/eth-das-guardian v0.1.2-0.20260702094214-e1a365ea3c01 github.com/ethpandaops/ethcore v0.0.0-20260604021418-f61e8ea2d4ab github.com/ethpandaops/ethwallclock v0.4.0 github.com/ethpandaops/go-eth2-client v0.1.6-0.20260629130636-6d2ee1978c4e diff --git a/go.sum b/go.sum index fa8617ab..73721943 100644 --- a/go.sum +++ b/go.sum @@ -114,8 +114,8 @@ github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJ github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= github.com/ethereum/go-ethereum v1.17.4 h1:uA4q+qiLp7QImBsjdRbINu8iX6OEVmj4DPc5/E5Fsxc= github.com/ethereum/go-ethereum v1.17.4/go.mod h1:qMdgwqqRAen+aT8P7KKQKi0Qt6RzG4cfejVAbCpJgqA= -github.com/ethpandaops/eth-das-guardian v0.1.1 h1:RN96h3HSAMyU4XUOCqkj/9+lB+UW7cVW4Wr4JzdjmZY= -github.com/ethpandaops/eth-das-guardian v0.1.1/go.mod h1:7amdK4bN9N9Zp0b9Y9FcbDm1YrpeGBm8ix8qpiX0WuY= +github.com/ethpandaops/eth-das-guardian v0.1.2-0.20260702094214-e1a365ea3c01 h1:XxObBGjnyF2Nyf6evVMYIXnuCFGsAEgfqjmCeNmvA/Q= +github.com/ethpandaops/eth-das-guardian v0.1.2-0.20260702094214-e1a365ea3c01/go.mod h1:y5sTESfKLcpUistOLU0TF7Do2MphBVTi8in+KdQkz8E= github.com/ethpandaops/ethcore v0.0.0-20260604021418-f61e8ea2d4ab h1:B3gbA1Jf9pCG4gKlv3WtXNY7pUJF/wC0G+axx/7rq/0= github.com/ethpandaops/ethcore v0.0.0-20260604021418-f61e8ea2d4ab/go.mod h1:jMel7Tumm/0Ni44yb4OTGC6MsMiK7FBQDP/n/GOthpw= github.com/ethpandaops/ethwallclock v0.4.0 h1:+sgnhf4pk6hLPukP076VxkiLloE4L0Yk1yat+ZyHh1g= diff --git a/handlers/api/api_das_guardian.go b/handlers/api/api_das_guardian.go index ab52ca91..6a1efd90 100644 --- a/handlers/api/api_das_guardian.go +++ b/handlers/api/api_das_guardian.go @@ -22,6 +22,11 @@ type APIDasGuardianScanRequest struct { Slots []uint64 `json:"slots,omitempty"` // Optional slot numbers to scan RandomMode string `json:"random_mode,omitempty"` // Random slot selection mode: "non_missed", "with_blobs", "available" RandomCount int32 `json:"random_count,omitempty"` // Number of random slots to select (default: 4) + // WaitForProposerPreferencesSeconds, if > 0, keeps the Gloas + // `proposer_preferences` gossip subscription open for this many seconds + // after the RPC exchange so the gossip mesh has time to deliver messages. + // Capped at 60s server-side. Ignored on pre-Gloas forks. + WaitForProposerPreferencesSeconds int32 `json:"wait_for_proposer_preferences_seconds,omitempty"` } // APIDasGuardianScanResponse represents the response from DAS Guardian scan @@ -42,10 +47,26 @@ type APIDasGuardianScanResult struct { // Metadata (from RemoteMetadata) RemoteMetadata *APIDasGuardianMetadata `json:"remote_metadata,omitempty"` + // Gloas SignedProposerPreferences sniffed off the peer's gossip during + // the scan window. Empty pre-Gloas, or when no proposer published in time. + ProposerPreferences []*APIDasGuardianProposerPreference `json:"proposer_preferences,omitempty"` + // DAS Evaluation Result EvalResult *APIDasGuardianEvalResult `json:"eval_result,omitempty"` } +// APIDasGuardianProposerPreference is one SignedProposerPreferences gossip +// message observed during the scan, attributed to the peer that delivered it. +type APIDasGuardianProposerPreference struct { + ValidatorIndex uint64 `json:"validator_index"` + ProposalSlot uint64 `json:"proposal_slot"` + FeeRecipient string `json:"fee_recipient"` + GasLimit uint64 `json:"gas_limit"` + DependentRoot string `json:"dependent_root"` + ReceivedAt string `json:"received_at"` + From string `json:"from"` +} + // APIDasGuardianStatus represents the beacon node status type APIDasGuardianStatus struct { ForkDigest string `json:"fork_digest"` @@ -110,11 +131,31 @@ func APIDasGuardianScan(w http.ResponseWriter, r *http.Request) { return } + // Honour an optional wait window so the Gloas proposer_preferences + // gossip subscription has time to deliver messages. Cap at 60s so a + // caller can't tie up a worker indefinitely. + waitSeconds := req.WaitForProposerPreferencesSeconds + if waitSeconds < 0 { + waitSeconds = 0 + } + if waitSeconds > 60 { + waitSeconds = 60 + } + scanTimeout := 30 * time.Second + if extra := time.Duration(waitSeconds) * time.Second; extra > 0 { + scanTimeout = 30*time.Second + extra + } + // Create temporary DAS Guardian instance - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), scanTimeout) defer cancel() - dasGuardian, err := services.NewDasGuardian(ctx, logrus.WithField("component", "das-guardian-api")) + guardianOpts := make([]services.DasGuardianOption, 0, 1) + if waitSeconds > 0 { + guardianOpts = append(guardianOpts, services.WithProposerPreferencesWait(time.Duration(waitSeconds)*time.Second)) + } + + dasGuardian, err := services.NewDasGuardian(ctx, logrus.WithField("component", "das-guardian-api"), guardianOpts...) if err != nil { logrus.WithError(err).Error("failed to create DAS Guardian instance") http.Error(w, `{"error": "failed to initialize DAS Guardian"}`, http.StatusInternalServerError) @@ -199,6 +240,28 @@ func APIDasGuardianScan(w http.ResponseWriter, r *http.Request) { } } + // Map any Gloas SignedProposerPreferences messages that were observed + // off the wire during the scan window. + if len(scanResult.ProposerPreferences) > 0 { + prefs := make([]*APIDasGuardianProposerPreference, 0, len(scanResult.ProposerPreferences)) + for _, obs := range scanResult.ProposerPreferences { + if obs == nil || obs.Message == nil || obs.Message.Message == nil { + continue + } + msg := obs.Message.Message + prefs = append(prefs, &APIDasGuardianProposerPreference{ + ValidatorIndex: uint64(msg.ValidatorIndex), + ProposalSlot: uint64(msg.ProposalSlot), + FeeRecipient: fmt.Sprintf("0x%x", msg.FeeRecipient), + GasLimit: msg.TargetGasLimit, + DependentRoot: fmt.Sprintf("0x%x", msg.DependentRoot), + ReceivedAt: obs.ReceivedAt.Format(time.RFC3339), + From: obs.From.String(), + }) + } + result.ProposerPreferences = prefs + } + // Map evaluation result (always present since it's not a pointer) // Extract data from RangeResult and RootResult arrays var downloadedResult [][]string diff --git a/services/dasguardian.go b/services/dasguardian.go index 3756f471..ece5bf82 100644 --- a/services/dasguardian.go +++ b/services/dasguardian.go @@ -19,7 +19,7 @@ type DasGuardian struct { guardian *dasguardian.DasGuardian } -func NewDasGuardian(ctx context.Context, logger logrus.FieldLogger) (*DasGuardian, error) { +func NewDasGuardian(ctx context.Context, logger logrus.FieldLogger, options ...DasGuardianOption) (*DasGuardian, error) { guardianApi := &dasGuardianAPI{} // Convert FieldLogger to *logrus.Logger @@ -39,6 +39,9 @@ func NewDasGuardian(ctx context.Context, logger logrus.FieldLogger) (*DasGuardia ConnectionTimeout: 10 * time.Second, InitTimeout: 1 * time.Second, } + for _, opt := range options { + opt(opts) + } guardian, err := dasguardian.NewDASGuardian(ctx, opts) if err != nil { @@ -50,6 +53,20 @@ func NewDasGuardian(ctx context.Context, logger logrus.FieldLogger) (*DasGuardia }, nil } +// DasGuardianOption customises the DasGuardianConfig used to construct the +// underlying guardian. Used to enable Gloas-only paths like the proposer +// preferences gossip wait without affecting the default scan. +type DasGuardianOption func(*dasguardian.DasGuardianConfig) + +// WithProposerPreferencesWait configures the underlying scan to keep the +// `proposer_preferences` gossip subscription open for the given duration so +// the gossip mesh has time to deliver messages. +func WithProposerPreferencesWait(d time.Duration) DasGuardianOption { + return func(cfg *dasguardian.DasGuardianConfig) { + cfg.ProposerPreferencesWaitDuration = d + } +} + func (d *DasGuardian) Close() error { return d.guardian.Close() } diff --git a/templates/clients/clients_cl.html b/templates/clients/clients_cl.html index 690a1337..3f2f62b5 100644 --- a/templates/clients/clients_cl.html +++ b/templates/clients/clients_cl.html @@ -723,7 +723,23 @@
proposer_preferences gossip sniffed during scan)
+ SignedProposerPreferences were observed from this peer during the wait window. This is expected pre-Gloas, or if none of the peer's validators were due to propose soon.| Validator | +Proposal Slot | +Fee Recipient | +Gas Limit | +Received | +
|---|---|---|---|---|
| ${pref.validator_index.toLocaleString()} | +${pref.proposal_slot.toLocaleString()} | +${escapeHtml(feeShort)} |
+ ${pref.gas_limit.toLocaleString()} | +${escapeHtml(pref.received_at)} | +