Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 152 additions & 25 deletions yarn-project/ethereum/src/contracts/governance.test.ts

Large diffs are not rendered by default.

83 changes: 63 additions & 20 deletions yarn-project/ethereum/src/contracts/governance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,29 @@ export type L1GovernanceContractAddresses = Pick<
'governanceAddress' | 'rollupAddress' | 'registryAddress' | 'governanceProposerAddress'
>;

// NOTE: Must be kept in sync with DataStructures.ProposalState in l1-contracts
// NOTE: Must be kept in sync with IGovernance.ProposalState in l1-contracts
export enum ProposalState {
Pending,
Active,
Queued,
Executable,
Rejected,
Executed,
Droppable,
Dropped,
Expired,
}

/**
* Outcome of {@link ReadOnlyGovernanceContract.getPayloadProposalStatus} for a queried payload.
* - `'live'`: a proposal referencing the payload is still progressing (Pending/Active/Queued/
* Executable) or is `Droppable`, so signalling for it again is redundant or premature.
* - `'executed'`: no live proposal references the payload, but one was already executed within the
* bounded lookback (or a prior sweep observed the execution and memoized it).
* - `'none'`: no live or executed proposal references the payload within the bounded lookback.
*/
export type PayloadProposalStatus = 'live' | 'executed' | 'none';

/** Vote tallies on a single proposal. Both fields are mutated by `Governance.vote`. */
export interface Ballot {
yea: bigint;
Expand Down Expand Up @@ -119,6 +130,17 @@ const TERMINAL_PROPOSAL_STATES: ReadonlySet<ProposalState> = new Set([
ProposalState.Expired,
]);

// Set of `ProposalState` values in which a proposal is still progressing towards execution.
// `Droppable` is deliberately excluded: it is neither live (it cannot progress on its own) nor
// terminal (it can resume its lifecycle if the governanceProposer is restored), so it is handled
// separately and is never memoized as immutable.
const LIVE_PROPOSAL_STATES: ReadonlySet<ProposalState> = new Set([
ProposalState.Pending,
ProposalState.Active,
ProposalState.Queued,
ProposalState.Executable,
]);

// Hard upper bound on the wall-clock lifetime of any Governance proposal, in seconds.
// Each proposal stores its own snapshot of `ProposalConfiguration` at creation time and progresses
// through Pending -> Active -> Queued -> Executable using those frozen durations
Expand Down Expand Up @@ -173,6 +195,14 @@ export class ReadOnlyGovernanceContract {
*/
private readonly originalPayloadCache: Map<Hex, Hex | undefined> = new Map();

/**
* Payloads (lowercased hex) observed as the subject of an `Executed` Governance proposal. Execution
* is immutable on-chain, so this verdict never expires within a process. It restores the
* `'executed'` classification for payloads whose executed proposal has since aged past the bounded
* lookback below. Lost on restart, at which point the bounded-lookback limitation applies again.
*/
private readonly executedPayloads: Set<string> = new Set();

constructor(
address: Hex,
public readonly client: ViemClient,
Expand Down Expand Up @@ -263,55 +293,68 @@ export class ReadOnlyGovernanceContract {
}

/**
* Checks whether the given original payload is currently the subject of a live (non-terminal)
* Governance proposal. Returns true only if a proposal references this payload and is still in
* Pending, Active, Queued, or Executable state. Terminal proposals (Executed, Rejected, Dropped,
* Expired) are ignored, because once a proposal reaches a terminal state the same original
* payload may legitimately be re-signaled and re-submitted via the GovernanceProposer (each round
* is independent and there is no payload-level uniqueness check on-chain).
* Classifies the given original payload against the Governance proposal history. Distinguishes a
* payload that is still the subject of a live proposal (`'live'`) from one whose proposal was
* already executed (`'executed'`) from one that has no relevant proposal (`'none'`), so callers can
* stop re-signalling an executed payload while still re-signalling one whose proposal was merely
* rejected/dropped/expired (each GovernanceProposer round is independent and there is no
* payload-level uniqueness check on-chain).
*
* A proposal matches the payload either directly (its stored `payload` equals the target, as for
* `proposeWithLock` proposals) or via its `GSEPayload` wrapper unwrapping to the target. `'live'`
* (including `Droppable`) takes precedence over `'executed'`, so a payload re-submitted while a
* prior execution is still in the lookback window reads as `'live'`.
*
* Implemented as a bounded view-call sweep over `Governance.proposals` rather than an event scan,
* because `eth_getLogs` over the full deployment history of a long-lived rollup exceeds typical
* RPC block-range caps. The number of proposals (`proposalCount`) is small in practice, and we
* walk newest -> oldest with a hard early-stop on the protocol-wide proposal lifetime cap.
* walk newest -> oldest with a hard early-stop on the protocol-wide proposal lifetime cap. This
* makes the `'executed'` verdict a *bounded lookback* rather than permanent suppression: a proposal
* executed longer ago than the lifetime cap is only reported once it has been observed and
* memoized in-process (memo is lost on restart).
*/
public async hasActiveProposalWithPayload(payload: Hex): Promise<boolean> {
public async getPayloadProposalStatus(payload: Hex): Promise<PayloadProposalStatus> {
const target = payload.toLowerCase() as Hex;

const proposalCount = await this.getProposalCount();
if (proposalCount === 0n) {
return false;
return this.executedPayloads.has(target) ? 'executed' : 'none';
}

// Anything created before this cutoff is guaranteed terminal regardless of its frozen config.
const block = await this.client.getBlock();
const hardCutoff = block.timestamp - MAX_PROPOSAL_LIFETIME_SECONDS;

const target = payload.toLowerCase() as Hex;

// Proposals are append-only with monotonically non-decreasing creation timestamps, so iterating
// from newest -> oldest lets us early-stop as soon as we cross the lifetime cutoff.
for (let id = proposalCount - 1n; id >= 0n; id--) {
const proposal = await this.getProposal(id);

// Hard early-stop: every older proposal is also older than the cutoff and therefore terminal.
if (proposal.creation < hardCutoff) {
return false;
break;
}

const proposalPayload = proposal.payload.toString().toLowerCase();
const original = await this.getOriginalPayload(proposal.payload);
if (original === undefined || original.toLowerCase() !== target) {
const matches = proposalPayload === target || (original !== undefined && original.toLowerCase() === target);
if (!matches) {
continue;
}

// The wrapper unwraps to our payload. Only treat this as "already proposed" if the proposal
// is still live -- terminal states allow re-proposing the same payload in a later round.
if (TERMINAL_PROPOSAL_STATES.has(proposal.state)) {
continue;
// A live or Droppable proposal blocks re-signalling and wins over any executed one.
if (LIVE_PROPOSAL_STATES.has(proposal.state) || proposal.state === ProposalState.Droppable) {
return 'live';
}

if (proposal.state === ProposalState.Executed) {
this.executedPayloads.add(target);
}

return true;
// Rejected/Dropped/Expired proposals allow re-proposing the same payload; keep scanning.
}

return false;
return this.executedPayloads.has(target) ? 'executed' : 'none';
}

/**
Expand Down
26 changes: 11 additions & 15 deletions yarn-project/ethereum/src/contracts/governance_proposer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import type { L1TxRequest, L1TxUtils } from '../l1_tx_utils/index.js';
import type { ViemClient } from '../types.js';
import { type IEmpireBase, encodeSignal, encodeSignalWithSignature, signSignalWithSig } from './empire_base.js';
import { ReadOnlyGovernanceContract, extractProposalIdFromLogs } from './governance.js';
import { type PayloadProposalStatus, ReadOnlyGovernanceContract, extractProposalIdFromLogs } from './governance.js';

export class GovernanceProposerContract implements IEmpireBase {
private readonly proposer: GetContractReturnType<typeof GovernanceProposerAbi, ViemClient>;
Expand All @@ -38,16 +38,16 @@ export class GovernanceProposerContract implements IEmpireBase {
this.proposer = getContract({ address, abi: GovernanceProposerAbi, client });
}

public get address() {
public get address(): EthAddress {
return EthAddress.fromString(this.proposer.address);
}

public async getRollupAddress() {
public async getRollupAddress(): Promise<EthAddress> {
return EthAddress.fromString(await this.proposer.read.getInstance());
}

@memoize
public async getRegistryAddress() {
public async getRegistryAddress(): Promise<EthAddress> {
return EthAddress.fromString(await this.proposer.read.REGISTRY());
}

Expand All @@ -59,10 +59,6 @@ export class GovernanceProposerContract implements IEmpireBase {
return this.proposer.read.ROUND_SIZE();
}

public getInstance() {
return this.proposer.read.getInstance();
}

public computeRound(slot: SlotNumber): Promise<bigint> {
return this.proposer.read.computeRound([BigInt(slot)]);
}
Expand Down Expand Up @@ -107,7 +103,7 @@ export class GovernanceProposerContract implements IEmpireBase {
signer,
payload,
slot,
await this.getInstance(),
(await this.getRollupAddress()).toString(),
this.address.toString(),
chainId,
);
Expand All @@ -130,15 +126,15 @@ export class GovernanceProposerContract implements IEmpireBase {
}

/**
* Returns true iff the given original payload is currently the subject of a live (non-terminal)
* Governance proposal. Delegates to `ReadOnlyGovernanceContract.hasActiveProposalWithPayload`, which
* implements the actual sweep against the Governance contract -- this method exists only as a
* convenience wrapper so callers that already hold a GovernanceProposer reference don't have to
* Classifies the given original payload against the Governance proposal history (`'live'` /
* `'executed'` / `'none'`). Delegates to `ReadOnlyGovernanceContract.getPayloadProposalStatus`,
* which implements the actual sweep against the Governance contract -- this method exists only as
* a convenience wrapper so callers that already hold a GovernanceProposer reference don't have to
* resolve the Governance address themselves.
*/
public async hasActiveProposalWithPayload(payload: Hex): Promise<boolean> {
public async getPayloadProposalStatus(payload: Hex): Promise<PayloadProposalStatus> {
const governance = await this.getGovernance();
return governance.hasActiveProposalWithPayload(payload);
return governance.getPayloadProposalStatus(payload);
}

/**
Expand Down
1 change: 1 addition & 0 deletions yarn-project/foundation/src/config/env_var.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export type EnvVar =
| 'ETHEREUM_ALLOW_NO_DEBUG_HOSTS'
| 'FEE_RECIPIENT'
| 'FORCE_COLOR'
| 'GOVERNANCE_PROPOSER_FORCE_PAYLOAD_VOTE'
| 'GOVERNANCE_PROPOSER_PAYLOAD_ADDRESS'
| 'KEY_STORE_DIRECTORY'
| 'L1_CHAIN_ID'
Expand Down
7 changes: 7 additions & 0 deletions yarn-project/sequencer-client/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const DefaultSequencerConfig = {
injectUnrecoverableSignatureAttestation: false,
injectYParityAttestation: false,
fishermanMode: false,
governanceProposerForcePayloadVote: false,
shuffleAttestationOrdering: false,
skipPushProposedBlocksToArchiver: false,
skipPublishingCheckpointsPercent: 0,
Expand Down Expand Up @@ -161,6 +162,12 @@ export const sequencerConfigMappings: ConfigMappingsType<SequencerConfig> = {
description: 'The address of the payload for the governanceProposer',
parseEnv: (val: string) => EthAddress.fromString(val),
},
governanceProposerForcePayloadVote: {
env: 'GOVERNANCE_PROPOSER_FORCE_PAYLOAD_VOTE',
description:
'Keep signalling the configured governance payload even if a proposal referencing it was already executed.',
...booleanConfigHelper(DefaultSequencerConfig.governanceProposerForcePayloadVote),
},
l1PublishingTime: {
env: 'SEQ_L1_PUBLISHING_TIME_ALLOWANCE_IN_SLOT',
description: 'How much time in seconds to allow in the slot for publishing the L1 transaction.',
Expand Down
Loading
Loading