From 29c2aa184d74cd18c99b1cb85f88f26880dc5cfa Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Fri, 22 May 2026 09:51:54 +0200 Subject: [PATCH 1/4] feat(das-guardian): surface Gloas proposer preferences in scan modal eth-das-guardian now sniffs SignedProposerPreferences off the Gloas `proposer_preferences` gossip topic and attaches the observations to each scan result. Pull them through the JSON API and render a small table in the DAS Guardian modal showing validator, proposal slot, fee recipient (truncated, full value on hover), gas limit and the receive timestamp. Bumps github.com/ethpandaops/eth-das-guardian to a pre-release SHA from ethpandaops/eth-das-guardian#6. --- handlers/api/api_das_guardian.go | 38 +++++++++++++++++++++++++++++++ templates/clients/clients_cl.html | 38 ++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/handlers/api/api_das_guardian.go b/handlers/api/api_das_guardian.go index ab52ca91f..660468a9b 100644 --- a/handlers/api/api_das_guardian.go +++ b/handlers/api/api_das_guardian.go @@ -42,10 +42,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"` @@ -199,6 +215,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.GasLimit, + 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/templates/clients/clients_cl.html b/templates/clients/clients_cl.html index 690a13374..7cc62739b 100644 --- a/templates/clients/clients_cl.html +++ b/templates/clients/clients_cl.html @@ -2264,7 +2264,43 @@
Node Meta `; } - + + if (result.proposer_preferences && result.proposer_preferences.length > 0) { + const prefs = result.proposer_preferences; + html += `
+
+ Proposer Preferences + ${prefs.length} + (Gloas proposer_preferences gossip sniffed during scan) +
+
+ + + + + + + + + + + `; + for (const pref of prefs) { + const feeShort = pref.fee_recipient + ? pref.fee_recipient.slice(0, 10) + '…' + pref.fee_recipient.slice(-6) + : '-'; + html += ` + + + + + + `; + } + html += `
ValidatorProposal SlotFee RecipientGas LimitReceived
${pref.validator_index.toLocaleString()}${pref.proposal_slot.toLocaleString()}${escapeHtml(feeShort)}${pref.gas_limit.toLocaleString()}${escapeHtml(pref.received_at)}
+
`; + } + // DAS Evaluation Results if (result.eval_result) { const evalResult = result.eval_result; From cb31fe553cca11b4a010f4f43871626b3879ce96 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Fri, 22 May 2026 10:14:41 +0200 Subject: [PATCH 2/4] docs: regenerate swagger for proposer_preferences API additions --- docs/docs.go | 33 +++++++++++++++++++++++++++++++++ docs/swagger.json | 33 +++++++++++++++++++++++++++++++++ docs/swagger.yaml | 24 ++++++++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/docs/docs.go b/docs/docs.go index 83a7482a5..5adaf05ec 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": { @@ -2954,6 +2980,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 324204d3a..4594ff54e 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": { @@ -2951,6 +2977,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 2783d935c..c51235fac 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: @@ -327,6 +344,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' From 8c1e8bf8743424667fdd332a7b43cfa12d9d38dc Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Fri, 22 May 2026 10:26:00 +0200 Subject: [PATCH 3/4] feat(das-guardian): add Proposer Preferences scan option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fourth scan type alongside Random/Specific Slots/Metadata Only: "Proposer Preferences" — runs a metadata-only DAS Guardian scan and keeps the Gloas `proposer_preferences` gossip subscription open for a caller-configurable wait window (default 24s, capped at 60s server side) so the gossipsub mesh has time to deliver SignedProposerPreferences messages from the scanned peer. - API request gains wait_for_proposer_preferences_seconds (int, 0–60). - services.NewDasGuardian now accepts functional options; the API handler enables WithProposerPreferencesWait when the new field is set. - Scan timeout is extended by the wait window so the HTTP request doesn't time out before the gossip listener. - Modal shows a friendly empty state when the explicit Preferences scan returns zero observations (expected pre-Gloas, or when none of the peer's validators were about to propose). Pulls in ethpandaops/eth-das-guardian@67ee99d which exposes the ProposerPreferencesWaitDuration knob. --- docs/docs.go | 4 ++ docs/swagger.json | 4 ++ docs/swagger.yaml | 7 +++ handlers/api/api_das_guardian.go | 29 +++++++++- services/dasguardian.go | 19 ++++++- templates/clients/clients_cl.html | 90 ++++++++++++++++++++----------- 6 files changed, 120 insertions(+), 33 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 5adaf05ec..e987fdf66 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2947,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" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 4594ff54e..bb9615cca 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2944,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" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index c51235fac..937c1928d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -324,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: diff --git a/handlers/api/api_das_guardian.go b/handlers/api/api_das_guardian.go index 660468a9b..ea2bf60b2 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 @@ -126,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) diff --git a/services/dasguardian.go b/services/dasguardian.go index 3756f471d..ece5bf82b 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 7cc62739b..3f2f62b5e 100644 --- a/templates/clients/clients_cl.html +++ b/templates/clients/clients_cl.html @@ -723,7 +723,23 @@
Scan Options
Metadata Only - Quick scan for node status and custody information (no DAS column validation) - +
+ + +
+ + + + `; } - if (result.proposer_preferences && result.proposer_preferences.length > 0) { - const prefs = result.proposer_preferences; + const preferencesScan = $('input[name="scanType"]:checked').val() === 'preferences'; + if ((result.proposer_preferences && result.proposer_preferences.length > 0) || preferencesScan) { + const prefs = result.proposer_preferences || []; html += `
Proposer Preferences ${prefs.length} (Gloas proposer_preferences gossip sniffed during scan) -
-
- - - - - - - - - - - `; - for (const pref of prefs) { - const feeShort = pref.fee_recipient - ? pref.fee_recipient.slice(0, 10) + '…' + pref.fee_recipient.slice(-6) - : '-'; - html += ` - - - - - - `; + `; + if (prefs.length === 0) { + html += `
No 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.
`; + } else { + html += `
+
ValidatorProposal SlotFee RecipientGas LimitReceived
${pref.validator_index.toLocaleString()}${pref.proposal_slot.toLocaleString()}${escapeHtml(feeShort)}${pref.gas_limit.toLocaleString()}${escapeHtml(pref.received_at)}
+ + + + + + + + + + `; + for (const pref of prefs) { + const feeShort = pref.fee_recipient + ? pref.fee_recipient.slice(0, 10) + '…' + pref.fee_recipient.slice(-6) + : '-'; + html += ` + + + + + + `; + } + html += `
ValidatorProposal SlotFee RecipientGas LimitReceived
${pref.validator_index.toLocaleString()}${pref.proposal_slot.toLocaleString()}${escapeHtml(feeShort)}${pref.gas_limit.toLocaleString()}${escapeHtml(pref.received_at)}
`; } - html += `
- `; + html += ``; } // DAS Evaluation Results From 090fe698a99a69dac06e5331109769f8fdcedcf3 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Thu, 2 Jul 2026 11:27:15 +0200 Subject: [PATCH 4/4] deps: pin eth-das-guardian + go-eth2-client to EIP-7688 (alpha.12 prep) Targets the glamsterdam-devnet-7 line: go-eth2-client is pinned to ethpandaops/go-eth2-client#34 (pk910/eip-7688, stable containers & progressive lists for consensus-specs v1.7.0-alpha.12), matching the devnet branch. eth-das-guardian is pinned to the head of ethpandaops/eth-das-guardian#6, which carries the Gloas proposer_preferences sniffer and builds against the same eip-7688 go-eth2-client. ProposerPreferences.TargetGasLimit is unchanged under EIP-7688, so the API mapping in api_das_guardian.go needs no further changes. --- go.mod | 2 +- go.sum | 4 ++-- handlers/api/api_das_guardian.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index f16d3ac30..bacc92e03 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 fa8617ab3..737219436 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 ea2bf60b2..6a1efd90d 100644 --- a/handlers/api/api_das_guardian.go +++ b/handlers/api/api_das_guardian.go @@ -253,7 +253,7 @@ func APIDasGuardianScan(w http.ResponseWriter, r *http.Request) { ValidatorIndex: uint64(msg.ValidatorIndex), ProposalSlot: uint64(msg.ProposalSlot), FeeRecipient: fmt.Sprintf("0x%x", msg.FeeRecipient), - GasLimit: msg.GasLimit, + GasLimit: msg.TargetGasLimit, DependentRoot: fmt.Sprintf("0x%x", msg.DependentRoot), ReceivedAt: obs.ReceivedAt.Format(time.RFC3339), From: obs.From.String(),