fix(governance): show Executing while a Core proposal's L1 round-trip finishes - #117
Closed
douglance wants to merge 1 commit into
Closed
fix(governance): show Executing while a Core proposal's L1 round-trip finishes#117douglance wants to merge 1 commit into
douglance wants to merge 1 commit into
Conversation
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
… 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.
douglance
force-pushed
the
gov/executing-status
branch
from
August 24, 2026 18:21
cf646de to
388cc34
Compare
douglance
marked this pull request as draft
August 25, 2026 13:55
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A Core (Constitutional) AIP reads Executed on its proposal page roughly 9–10 days before anything it proposes takes effect. This inserts
Executinginto that window:where
Executednow means the retryable was redeemed (config/governors.ts→finalStage: "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 timelockexecuteBatch, and for a Core proposal what that executes is anArbSys.sendTxToL1withdrawal — so state7means "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()on0xf07DeD9dC292157749B6Fd268E37DF6EA38395B9returns7; the indexer agrees ("state": "EXECUTED").Executedon every surface.How it decides
roundTripFromStages(lib/proposal-round-trip.ts) requires the governor's ownfinalStageto be present and settled.It deliberately does not use
areAllStagesCompleteorgetLifecyclePhase. A checkpoint persists only the stages tracking reached, so one saved on a stage boundary has every stageCOMPLETED— and both of those helpers then report a mid-flight proposal as finished.lib/proposal-round-trip.test.tspins this: it asserts gov-tracker's helpers returntrue/"executed"on exactly the input where this returnspending. 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 existingisStateUnverifiedskeleton, 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):
ExecutedExecutedExecuted+ 1Active, 0 skeletons, 13 RPC POSTs — unchangedThe 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
mainbase:eslint .clean,tsc --noEmitclean,vitest run1299 passed (24 new),next buildsucceeds.Trade-offs, stated rather than left to be found
Filter vs badge disagree.
Executingis display-only —proposal.statestays the raw governor value, leaving filtering, sorting, reconciliation and the schema untouched. So filtering Status →Executedstill returns a row whose badge readsExecuting. Correct (it isExecutedon-chain), but visibly inconsistent. The alternative was widening the state vocabulary through the data layer, which touchesIN_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
Executedfor 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 settrue— that's where it would go.Review pointers, most-risk-first
roundTripFromStages(lib/proposal-round-trip.ts) — the whole judgement, ~30 lines.canResolveblock (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.LifecycleCellresolves twice, on purpose — the tracker'scurrentStatecan beExecutedwhile the indexer still saysQueued. Collapsing the two would regress that case.Deletions
Removes
getEffectiveDisplayState,getTotalStages,getCurrentStageNumberandisProposalFullyExecutedfromlib/lifecycle-utils.ts(−91 lines). TheStage x/7path was effectively dead:shouldTrackLifecycleinLifecycleCellexcludesexecuted, so the table showed plainExecutedlike everywhere else. None had tests, which is partly why nobody noticed.getScheduledVoteEndBlocknarrows to a newVoteWindowSourcesince it only ever read the two block fields — hence four call sites inlib/proposal-utils.test.tsdropping an unusedstate.Base
Branched off
mainrather than onto the #102→#111 stack, so a user-visible governance fix isn't gated behind it.components/proposal/ProposalStages.tsxis the one file that differs betweenmainandci/e2e(c80b73fextractedselectRelevantStageTypes); 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.