Skip to content

fix(governance): show Executing while a Core proposal's L1 round-trip finishes - #117

Closed
douglance wants to merge 1 commit into
mainfrom
gov/executing-status
Closed

fix(governance): show Executing while a Core proposal's L1 round-trip finishes#117
douglance wants to merge 1 commit into
mainfrom
gov/executing-status

Conversation

@douglance

Copy link
Copy Markdown
Contributor

A Core (Constitutional) AIP reads Executed on its proposal page roughly 9–10 days before anything it proposes takes effect. This inserts Executing into that window:

Queued → Executing → Executed

where Executed now means the retryable was redeemed (config/governors.tsfinalStage: "RETRYABLE_EXECUTED"), not merely dispatched.

Why the current status is wrong

An OpenZeppelin governor's state() only covers the L2 half. governor.execute()GovernorTimelockControl → L2 timelock executeBatch, and for a Core proposal what that executes is an ArbSys.sendTxToL1 withdrawal — so state 7 means "the L2 half finished and a message is in flight". Still ahead: the L2→L1 challenge window (~6.4d), the L1 timelock (3d), then the retryable back to L2 that actually applies the change.

Verified on-chain against AIP: Security Council Election Process Improvements:

  • state() on 0xf07DeD9dC292157749B6Fd268E37DF6EA38395B9 returns 7; the indexer agrees ("state": "EXECUTED").
  • Snapshot ≈ L1 block 25,646,648 (2026-07-30). 14d vote → ~Aug 13, 8d Constitutional L2 timelock → executable ~Aug 21.
  • It is at stage 5 of 7 as of this PR, and reads Executed on every surface.

How it decides

roundTripFromStages (lib/proposal-round-trip.ts) requires the governor's own finalStage to be present and settled.

It deliberately does not use areAllStagesComplete or getLifecyclePhase. A checkpoint persists only the stages tracking reached, so one saved on a stage boundary has every stage COMPLETED — and both of those helpers then report a mid-flight proposal as finished. lib/proposal-round-trip.test.ts pins this: it asserts gov-tracker's helpers return true / "executed" on exactly the input where this returns pending. That's the regression most likely to be "fixed" back later.

The answer comes from the gov-tracker checkpoint cache with no network calls, so settled history resolves for free and only the 0–2 proposals inside the round-trip window request tracking — which is what keeps this inside MAX_CONCURRENT_TRACKING = 2. An unknown withholds behind the existing isStateUnverified skeleton, but only while an answer is reachable; with no creation tx hash there is no cache key and no way to track, so it falls back to the governor state rather than spinning forever.

Verification

Ran against the live proposal with a real browser (dev server + Playwright):

Case Result
Core, mid round-trip (the proposal above) Executing on the description tab, no Lifecycle-tab visit needed
Core, finished (Frozen ETH Court Order) Executed
Treasury, finished (Continued Funding) Executed
Proposals list 9 Executed + 1 Active, 0 skeletons, 13 RPC POSTs — unchanged

The Lifecycle subheader independently corroborates the hop: "Current state: Executing · Waiting out the L2→L1 challenge period", with stages 1–4 green — matching the on-chain timeline exactly.

Gate, all four commands CI runs, on this branch's main base: eslint . clean, tsc --noEmit clean, vitest run 1299 passed (24 new), next build succeeds.

Trade-offs, stated rather than left to be found

Filter vs badge disagree. Executing is display-only — proposal.state stays the raw governor value, leaving filtering, sorting, reconciliation and the schema untouched. So filtering Status → Executed still returns a row whose badge reads Executing. Correct (it is Executed on-chain), but visibly inconsistent. The alternative was widening the state vocabulary through the data layer, which touches IN_FLIGHT_PROPOSAL_STATES, three copies of the valid-state list, and the RPC reconciliation path that would otherwise clobber the value.

List views still read Executed for a mid-flight proposal. Resolving a row needs a creation tx hash for the cache key; indexer rows carry none, and the bundled cache is built ahead of time so it structurally never contains the newest proposals — which are exactly the ones that can be mid-flight. The only other source is a 10M-block log scan, fine for one detail page and not for a list. An earlier pass withheld those rows and left two permanent spinners; it now falls back instead, so the list is unchanged rather than worse. The detail page is the reported bug and is fixed.

Not done: periodic re-tracking. The badge follows the checkpoint every 30s, so a pass started elsewhere in the session moves it, but a hop completing while the page sits open needs a reload. Given the hops take days that seemed proportionate. isBackgroundRefreshing (lib/proposal-tracker-manager.ts) is threaded through the session type but never set true — that's where it would go.

Review pointers, most-risk-first

  1. roundTripFromStages (lib/proposal-round-trip.ts) — the whole judgement, ~30 lines.
  2. The canResolve block (hooks/use-proposal-display-state.ts) — withhold only while an answer is pending, never when it's unavailable. I got this wrong first; it's what keeps the list from regressing.
  3. LifecycleCell resolves twice, on purpose — the tracker's currentState can be Executed while the indexer still says Queued. Collapsing the two would regress that case.

Deletions

Removes getEffectiveDisplayState, getTotalStages, getCurrentStageNumber and isProposalFullyExecuted from lib/lifecycle-utils.ts (−91 lines). The Stage x/7 path was effectively dead: shouldTrackLifecycle in LifecycleCell excludes executed, so the table showed plain Executed like everywhere else. None had tests, which is partly why nobody noticed. getScheduledVoteEndBlock narrows to a new VoteWindowSource since it only ever read the two block fields — hence four call sites in lib/proposal-utils.test.ts dropping an unused state.

Base

Branched off main rather than onto the #102#111 stack, so a user-visible governance fix isn't gated behind it. components/proposal/ProposalStages.tsx is the one file that differs between main and ci/e2e (c80b73f extracted selectRelevantStageTypes); that refactor is deliberately not carried here, and the two edits sit in disjoint regions of the file, so expect one trivial conflict there whenever the stack merges.

@vercel

vercel Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tally-zero Ready Ready Preview Aug 24, 2026 6:22pm

Request Review

… finishes

An OpenZeppelin governor's state() only covers the L2 half of an Arbitrum Core
proposal. governor.execute() runs the L2 timelock, and what that executes is an
ArbSys.sendTxToL1 withdrawal, so state 7 arrives when the message is dispatched
rather than when the change is live. The L2 to L1 challenge window, the 3-day L1
timelock and the retryable redemption all still lie ahead, roughly ten more days.
"[Constitutional] AIP: Security Council Election Process Improvements" reads
Executed today while it sits at stage 5 of 7.

roundTripFromStages answers the question from gov-tracker stages by requiring
the governor's own finalStage to be present and settled — RETRYABLE_EXECUTED for
Core, the hop that actually applies the change. It deliberately avoids
areAllStagesComplete and getLifecyclePhase: a checkpoint persists only the
stages tracking reached, so one saved on a stage boundary has every stage
COMPLETED and both of those helpers then report a mid-flight proposal as
finished. The test pins that, asserting they return true and "executed" on
exactly the input where this returns pending.

The proposal page badge and the Lifecycle subheader read it. The Lifecycle tab
already holds the stage list; the badge needs its own tracking request because
that tab mounts lazily, but trackerManager keys sessions by proposal and
governor so the two share one crawl. Nothing else changes: Executing is
display-only, proposal.state stays the raw governor value, so filtering,
sorting, state reconciliation and the schema are untouched.

Scope is deliberately the proposal page. List views keep reading Executed for a
mid-flight proposal — resolving a row there needs a creation tx hash that
indexer rows do not carry, and the bundled cache is built ahead of time so it
never holds the newest proposals, which are the only ones that can be in flight.
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