Skip to content

fix(notifier): keep gloas exec-block pre-gloas-shaped#19

Closed
lodekeeper wants to merge 9 commits into
unstablefrom
feat/notifier-prev-slot-payload
Closed

fix(notifier): keep gloas exec-block pre-gloas-shaped#19
lodekeeper wants to merge 9 commits into
unstablefrom
feat/notifier-prev-slot-payload

Conversation

@lodekeeper
Copy link
Copy Markdown
Owner

@lodekeeper lodekeeper commented Apr 21, 2026

Summary

Keeps the exec-block: notifier row pre-Gloas-shaped on Gloas heads. Only fix: normalize the internal PayloadSeparated fork-choice status to valid in the printed label, so the log never surfaces raw enum strings.

No new full/empty marker for now — we discussed a few encodings (valid/full|empty, trailing annotations, separate warning log) and none felt right on the notifier. Operators can already infer an empty-parent streak implicitly: exec-block: number+hash stay flat while slot:/head: advance.

Output

Pre-Gloas (unchanged):

Synced - slot: N - head: 0x… - exec-block: valid(19876543 0x12…) - finalized: … - peers: 42

Gloas (same shape, PayloadSeparated normalized to valid):

Synced - slot: N - head: 0x… - exec-block: valid(19876543 0x12…) - finalized: … - peers: 42

Implementation

Single file change in packages/beacon-node/src/node/notifier.ts: in getHeadExecutionInfo, collapse ExecutionStatus.PayloadSeparated → "valid" before lowercasing. Plus a focused unit test covering pre-Gloas Valid, Gloas PayloadSeparated, and Gloas Valid heads all rendering the same pre-Gloas-shaped row.

We can revisit surfacing full/empty later if the implicit signal turns out to be insufficient in practice.

Test plan

  • Pre-Gloas output is byte-identical to current unstable.
  • Gloas devnet: exec-block: row renders as valid(<number> <hash>) under normal flow, never as payloadseparated(...).

Adds a `prev-payload:` row to the node notifier that surfaces the
execution payload status of the block the next proposer would build
on top of — the parent of the current head. This is the payload that
will be referenced by the next block's parent hash.

- Pre-Gloas: parent's single variant is always FULL, shows block
  number + hash.
- Gloas: parent may be FULL (payload revealed) or EMPTY (PTC voted
  not-present).

🤖 Generated with AI assistance
@nflaig
Copy link
Copy Markdown

nflaig commented Apr 21, 2026

please fix the diff, it shows commits from unstable branch

@lodekeeper lodekeeper closed this Apr 21, 2026
@lodekeeper lodekeeper reopened this Apr 21, 2026
@lodekeeper
Copy link
Copy Markdown
Owner Author

Fixed — fork's unstable was 5 commits behind ChainSafe/unstable, so those 5 commits were polluting the diff. Fast-forwarded fork's unstable to abc719ddc1 and reopened the PR to force GitHub to recompute the diff. Now shows 1 commit, 1 file (packages/beacon-node/src/node/notifier.ts).

const headRow = `head: ${headDiffInfo}${prettyBytes(headInfo.blockRoot)}`;

const executionInfo = getHeadExecutionInfo(config, clockEpoch, headState, headInfo);
const prevPayloadInfo = getPrevSlotPayloadInfo(chain.forkChoice, headInfo, clockEpoch, config);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you make this so complicated? once we have the block in gloas, we know the parent hash from the bid in the block, and the only way to import that block is if we have imported the parent hash execution payload, so there isn't ever the case where this can be empty/full/pending, this is fork choice internal details that should not matter here

can't we just log this the same way as pre-gloas as exec-block?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point — I over-engineered this. The multi-variant getBlockHex(root, FULL|EMPTY) lookup is internal fork-choice bookkeeping that the notifier user doesn't care about.

Will simplify to mirror pre-gloas exec-block format:

const parent = forkChoice.getBlockHexDefaultStatus(headInfo.parentRoot);
if (parent === null || parent.executionStatus === ExecutionStatus.PreMerge) return [];
const statusStr = parent.executionStatus.toLowerCase();
if (parent.executionPayloadBlockHash !== null) {
  return [`prev-payload: ${statusStr}(${parent.executionPayloadNumber} ${prettyBytesShort(parent.executionPayloadBlockHash)})`];
}
return [`prev-payload: ${statusStr}`];

That gets us prev-payload: valid(19876543 0x123456…) using executionStatus (valid/invalid/syncing) instead of my invented full/empty/pending.

One clarification for the Gloas case: when the parent's payload was missed (bid never revealed, inherited parent hash from grandparent), the default variant's executionPayloadBlockHash may be null. Should I:
(a) emit prev-payload: ${statusStr} (no hash/number — would hide the fact that a payload was missed), or
(b) drop the row entirely in that case?

Leaning (a) for visibility. LMK if you'd prefer something else.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplified per your feedback — pushed ab55d4b.

  • exec-block: is back to the same operator-facing shape as pre-Gloas.
  • No prev-payload: row and no empty-parent / FULL / EMPTY / PENDING annotation in the log output.
  • For Gloas imported heads I only keep the minimal normalization PayloadSeparated -> valid, so we don't print an internal status string while still logging the inherited execution anchor already carried on headInfo.executionPayloadBlockHash / executionPayloadNumber.
  • Also dropped the unrelated envelope/import provenance test drift from this branch and replaced it with one focused notifier unit test file (3/3 passing locally).

So the branch is back to the notifier-only scope your comment was asking for.

@lodekeeper
Copy link
Copy Markdown
Owner Author

Design plan, converged after first-principles pass + two review rounds (gpt-advisor, devils-advocate, codex defender fallback):

What changes vs. the current PR

  • Drop prev-payload: entirely. You were right: it leaks fork-choice-internal FULL/EMPTY/PENDING bookkeeping into the notifier when, for an imported Gloas block, the parent's execution payload is already guaranteed imported. No second row needed.
  • Restore exec-block: as the single execution row, with one sentence of meaning that holds pre- and post-Gloas:

    the execution block the node would currently build on for the next block

    • Pre-Gloas: unchanged — head's own payload (derived from headInfo.executionStatus / number / hash, exactly as today).
    • Gloas FULL head: head's revealed payload.
    • Gloas PENDING/EMPTY head: the inherited execution anchor from the bid's parent_block_hash. Number/status come from the parent ProtoBlock if resolvable; if only the hash is known, degrade explicitly to exec-block: unresolved(<hash>) rather than inventing valid(...).
  • Keep payload: (PR feat: add Gloas payload status to node notifier #18's row) but only for non-FULL lifecycle: payload: pending / payload: empty. Omit it in the FULL happy path — the informative row there is already exec-block:.
  • Atomic snapshot: both rows, when emitted together, must be derived from the same head / fork-choice view so timing never produces an inconsistent pair.

Example output

Pre-Gloas:
Synced - slot: N - head: 0x… - exec-block: valid(19876543 0x12…) - finalized: … - peers: 42

Gloas FULL:
Synced - slot: N - head: 0x… - exec-block: valid(19876544 0x23…) - finalized: … - peers: 42

Gloas PENDING:
Synced - slot: N - head: 0x… - exec-block: valid(19876543 0x12…) - payload: pending - finalized: … - peers: 42

Gloas EMPTY:
Synced - slot: N - head: 0x… - exec-block: valid(19876543 0x12…) - payload: empty - finalized: … - peers: 42

Degraded anchor only:
Synced - slot: N - head: 0x… - exec-block: unresolved(0x12…) - payload: pending - finalized: … - peers: 42

Implementation shape (single PR, only notifier.ts)

  1. Remove getPrevSlotPayloadInfo() and getResolvedParentBlock() from PR fix(notifier): keep gloas exec-block pre-gloas-shaped #19.
  2. Reshape PR feat: add Gloas payload status to node notifier #18's getGloasExecutionInfo() into two helpers:
    • getHeadExecutionInfo() → emits exec-block: for pre-Gloas AND Gloas, using the bid parent anchor for PENDING/EMPTY heads.
    • getGloasPayloadInfo() → emits payload: pending / payload: empty only.
  3. Row order unchanged: … head - exec-block - payload (optional) - finalized - peers.

Tests

  • Pre-Gloas output byte-for-byte unchanged.
  • Gloas FULL → exec-block: present, no payload: row.
  • Gloas PENDING → exec-block: with inherited anchor, payload: pending.
  • Gloas EMPTY → exec-block: with inherited anchor, payload: empty.
  • Degraded anchor → exec-block: unresolved(<hash>).
  • Same-head PENDING → FULL transition: exec-block: may legitimately change (documented, not a bug).
  • No prev-payload: row emitted anywhere.

One open question for you

PR #18 and PR #19 both touch notifier.ts. Cleanest is to close PR #19 and rework PR #18 into this shape (since PR #18 already introduced payload: and Option B refines it), then the whole Gloas notifier story lands as one review. Alternatively I can land PR #18 as-is and do Option B in PR #19. I'd prefer the first — fewer review cycles and no transient state where payload: full(...) lives in unstable. OK with that?

Full design doc + review artifacts (advisor / devils-advocate / defender rounds) kept locally and available if you want to see the reasoning trail.

Nico's feedback on PR #19: once a gloas block is imported, its parent
execution payload is guaranteed imported+validated, so there is no
operator-facing reason to resolve the parent variant and print FULL/
EMPTY/PENDING. Fork-choice already stores the bid's parent_block_hash
and the parent variant's block number on the head ProtoBlock at import
time, so exec-block: <status>(<number> <hash>) renders the correct
parent anchor for gloas PENDING/EMPTY heads with no lookup.

The only remaining display issue was that executionStatus for gloas
non-FULL heads is PayloadSeparated, which .toLowerCase() would render
as "payloadseparated" - ugly and misleading. Map it to "valid" because
the parent anchor's payload was validated before the block could be
imported.

Result: pre-gloas notifier format unchanged, and gloas output matches
that format across PENDING/EMPTY/FULL states with the parent anchor or
head's own payload in the (number hash) tuple as appropriate. Missed
payloads are visible via EL-block-number stagnation across slots.

Removes getPrevSlotPayloadInfo and getResolvedParentBlock.

🤖 Generated with AI assistance
@lodekeeper lodekeeper changed the title feat(notifier): show previous slot payload status fix(notifier): render gloas PayloadSeparated head as valid exec-block Apr 21, 2026
Replace the PayloadSeparated->"valid" mapping with a lookup of the
parent variant the head built on, using head.parentBlockHash (the bid's
parent_block_hash). Render the exec-block status as "empty" when the
parent's payloadStatus was EMPTY, "full" otherwise.

This preserves missed-payload visibility in the notifier: two consecutive
logs with the same exec-block hash and "empty" label signal a string of
missed payloads, since a block building on an EMPTY parent inherits the
grandparent's anchor as its bid.parent_block_hash.

Pre-gloas behavior is unchanged (head.parentBlockHash is null pre-gloas,
so the existing executionStatus.toLowerCase() path is preserved).

🤖 Generated with AI assistance
…loas empty-parent heads

Combine the pre-Gloas valid/syncing label (EL validation signal) with the Gloas
parent-variant signal. PayloadSeparated heads render as 'valid' since the
displayed exec-block is the parent's already-imported anchor. When the head
built on an EMPTY parent variant, suffix '/empty' so two consecutive logs with
the same hash and '/empty' reveal a string of missed payloads.

🤖 Generated with AI assistance
@lodekeeper lodekeeper changed the title fix(notifier): render gloas PayloadSeparated head as valid exec-block fix(notifier): preserve exec-block validation label and suffix /empty for gloas empty-parent heads Apr 21, 2026
@lodekeeper
Copy link
Copy Markdown
Owner Author

Iteration 3 pushed as 33c5c6evalid kept as the base label, with /empty appended only when the head built on an EMPTY parent variant. FULL parents render exactly like pre-Gloas.

Final output shape

Pre-Gloas:                 exec-block: valid(N hash)          # unchanged
Gloas, parent FULL:        exec-block: valid(N hash)          # same format as pre-Gloas
Gloas, parent EMPTY:       exec-block: valid/empty(N hash)    # hash stays flat across consecutive empty logs
EL optimistic + EMPTY:     exec-block: syncing/empty(N hash)  # combined label composes

Diff vs iteration 2

Iteration 2 overrode the EL validation signal with full/empty. This iteration layers them:

let executionStatusStr =
  headInfo.executionStatus === ExecutionStatus.PayloadSeparated ? "valid" : headInfo.executionStatus.toLowerCase();

if (headInfo.parentBlockHash !== null) {
  const parentVariant = forkChoice.getBlockHexAndBlockHash(headInfo.parentRoot, headInfo.parentBlockHash);
  if (parentVariant?.payloadStatus === PayloadStatus.EMPTY) {
    executionStatusStr = `${executionStatusStr}/empty`;
  }
}

PR body + title updated to match. Lint clean. Let me know if /empty as the separator works or if you'd prefer another shape (e.g. empty:valid, valid [empty-parent], etc.).

…label suffix

Move the EMPTY-parent signal out of the status label into a trailing
'empty-parent' annotation after the tuple. Keeps the historical
valid(...)/syncing(...)/invalid(...) grammar intact, avoids conflating
EL validation state with the orthogonal fork-choice parent variant, and
preserves existing prefix-based tooling.

Output:
  Pre-Gloas / Gloas FULL-parent: exec-block: valid(N hash)
  Gloas EMPTY-parent:            exec-block: valid(N hash) empty-parent

🤖 Generated with AI assistance
@lodekeeper lodekeeper changed the title fix(notifier): preserve exec-block validation label and suffix /empty for gloas empty-parent heads fix(notifier): render gloas exec-block with empty-parent annotation Apr 21, 2026
@lodekeeper
Copy link
Copy Markdown
Owner Author

Ran another advisor cycle on the encoding. Both advisors (codex-advisor xhigh + devil's-advocate pass) converged on swapping the /empty label-suffix for a trailing empty-parent annotation after the tuple. Shipped as 4e87492.

Why not the slash-suffix

  1. / reads as alternation in log context. valid/empty parses as "valid OR empty?" at a glance, not "valid with empty parent."
  2. Orthogonal axes fused into one token. EL validation state (valid/syncing/invalid) and fork-choice parent variant (FULL/EMPTY) are independent dimensions. Stuffing them into one label blows up label cardinality once more state gets surfaced (e.g. Gloas optimistic sync → syncing/empty, optimistic/empty, etc.).
  3. Breaks existing tooling matching exec-block: (valid|syncing|invalid)\(. Anyone grepping on the status token for alerts / dashboards suddenly misses the /empty case.
  4. Ambiguous about what is empty. empty could read as "the exec-block shown is empty" when it's actually the parent payload that was empty.

New output

Pre-Gloas:                  exec-block: valid(N hash)                    # unchanged
Gloas, parent FULL:         exec-block: valid(N hash)                    # byte-identical to pre-Gloas
Gloas, parent EMPTY:        exec-block: valid(N hash) empty-parent       # single trailing annotation

Grammar stays <status>(<number> <hash>) everywhere. The empty-parent fact is surfaced as a separate token scoped clearly to the parent, not mashed into the status label. Future syncing + empty-parent composes as syncing(N hash) empty-parent, no label explosion.

Code

const executionStatusStr =
  headInfo.executionStatus === ExecutionStatus.PayloadSeparated ? "valid" : headInfo.executionStatus.toLowerCase();

let emptyParentSuffix = "";
if (headInfo.parentBlockHash !== null) {
  const parentVariant = forkChoice.getBlockHexAndBlockHash(headInfo.parentRoot, headInfo.parentBlockHash);
  if (parentVariant?.payloadStatus === PayloadStatus.EMPTY) {
    emptyParentSuffix = " empty-parent";
  }
}
// …
return [`exec-block: ${status}(${num} ${hashShort})${emptyParentSuffix}`];

PR title + body updated to match. Lint clean.

Simplify PR #19 back to the notifier behavior Nico asked for:
- keep a single exec-block row shaped like pre-Gloas output
- normalize PayloadSeparated -> valid instead of surfacing an internal status string
- drop the empty-parent annotation and the unrelated envelope/import test drift
- add a focused notifier unit test covering pre-Gloas, Gloas PayloadSeparated, and Gloas FULL output

🤖 Generated with AI assistance
@lodekeeper lodekeeper changed the title fix(notifier): render gloas exec-block with empty-parent annotation fix(notifier): keep gloas exec-block pre-gloas-shaped Apr 22, 2026
@lodekeeper lodekeeper changed the title fix(notifier): keep gloas exec-block pre-gloas-shaped feat(notifier): extend gloas exec-block with full/empty parent variant Apr 22, 2026
@lodekeeper lodekeeper force-pushed the feat/notifier-prev-slot-payload branch from 9f1b2c3 to ab55d4b Compare April 22, 2026 04:52
@lodekeeper lodekeeper changed the title feat(notifier): extend gloas exec-block with full/empty parent variant fix(notifier): keep gloas exec-block pre-gloas-shaped Apr 22, 2026
@lodekeeper
Copy link
Copy Markdown
Owner Author

Closing this per Nico's request; treating it as done.

@lodekeeper lodekeeper closed this Apr 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants