Skip to content
Merged
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
2 changes: 2 additions & 0 deletions yarn-project/ethereum/src/contracts/empire_base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface IEmpireBase {
signerAddress: Hex,
signer: (msg: TypedDataDefinition) => Promise<Hex>,
): Promise<L1TxRequest>;
/** Checks if a payload was ever submitted to governance via submitRoundWinner. */
hasPayloadBeenProposed(payload: Hex, fromBlock: bigint): Promise<boolean>;
}

export function encodeSignal(payload: Hex): Hex {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ export class EmpireSlashingProposerContract extends EventEmitter implements IEmp
};
}

/** Checks if a payload was ever submitted to governance via submitRoundWinner. */
public async hasPayloadBeenProposed(payload: Hex, fromBlock: bigint): Promise<boolean> {
const events = await this.proposer.getEvents.PayloadSubmitted({ payload }, { fromBlock, strict: true });
return events.length > 0;
}

public listenToSubmittablePayloads(callback: (args: { payload: `0x${string}`; round: bigint }) => unknown) {
return this.proposer.watchEvent.PayloadSubmittable(
{},
Expand Down
6 changes: 6 additions & 0 deletions yarn-project/ethereum/src/contracts/governance_proposer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ export class GovernanceProposerContract implements IEmpireBase {
};
}

/** Checks if a payload was ever submitted to governance via submitRoundWinner. */
public async hasPayloadBeenProposed(payload: Hex, fromBlock: bigint): Promise<boolean> {
const events = await this.proposer.getEvents.PayloadSubmitted({ payload }, { fromBlock, strict: true });
return events.length > 0;
}

public async submitRoundWinner(
round: bigint,
l1TxUtils: L1TxUtils,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ describe('SequencerPublisher', () => {

rollup = mock<RollupContract>();
rollup.validateHeader.mockReturnValue(Promise.resolve());
rollup.getL1StartBlock.mockResolvedValue(1n);
(rollup as any).address = mockRollupAddress;
forwardSpy = jest.spyOn(Multicall3, 'forward');

Expand Down Expand Up @@ -418,4 +419,119 @@ describe('SequencerPublisher', () => {
),
).toEqual(false);
});

it('stops signalling when payload was previously proposed', async () => {
const { govPayload } = mockGovernancePayload();
governanceProposerContract.hasPayloadBeenProposed.mockResolvedValue(true);

expect(
await publisher.enqueueGovernanceCastSignal(
govPayload,
SlotNumber(2),
1n,
EthAddress.fromString(testHarnessAttesterAccount.address),
msg => testHarnessAttesterAccount.signTypedData(msg),
),
).toEqual(false);
});

it('continues signalling when payload was NOT proposed', async () => {
const { govPayload } = mockGovernancePayload();
governanceProposerContract.hasPayloadBeenProposed.mockResolvedValue(false);

expect(
await publisher.enqueueGovernanceCastSignal(
govPayload,
SlotNumber(2),
1n,
EthAddress.fromString(testHarnessAttesterAccount.address),
msg => testHarnessAttesterAccount.signTypedData(msg),
),
).toEqual(true);
});

it('caches proposed result and prevents repeated L1 calls', async () => {
const { govPayload } = mockGovernancePayload();
governanceProposerContract.hasPayloadBeenProposed.mockResolvedValue(true);

await publisher.enqueueGovernanceCastSignal(
govPayload,
SlotNumber(2),
1n,
EthAddress.fromString(testHarnessAttesterAccount.address),
msg => testHarnessAttesterAccount.signTypedData(msg),
);

await publisher.enqueueGovernanceCastSignal(
govPayload,
SlotNumber(3),
2n,
EthAddress.fromString(testHarnessAttesterAccount.address),
msg => testHarnessAttesterAccount.signTypedData(msg),
);

expect(governanceProposerContract.hasPayloadBeenProposed).toHaveBeenCalledTimes(1);
});

it('retries on transient RPC failure and succeeds', async () => {
const { govPayload } = mockGovernancePayload();
governanceProposerContract.hasPayloadBeenProposed
.mockRejectedValueOnce(new Error('RPC error'))
.mockRejectedValueOnce(new Error('RPC error'))
.mockResolvedValueOnce(false);

expect(
await publisher.enqueueGovernanceCastSignal(
govPayload,
SlotNumber(2),
1n,
EthAddress.fromString(testHarnessAttesterAccount.address),
msg => testHarnessAttesterAccount.signTypedData(msg),
),
).toEqual(true);
});

it('fails closed on persistent RPC failure', async () => {
const { govPayload } = mockGovernancePayload();
governanceProposerContract.hasPayloadBeenProposed.mockRejectedValue(new Error('RPC error'));

expect(
await publisher.enqueueGovernanceCastSignal(
govPayload,
SlotNumber(2),
1n,
EthAddress.fromString(testHarnessAttesterAccount.address),
msg => testHarnessAttesterAccount.signTypedData(msg),
),
).toEqual(false);
});

it('does not cache false result and re-checks on subsequent calls', async () => {
const { govPayload } = mockGovernancePayload();
governanceProposerContract.hasPayloadBeenProposed.mockResolvedValueOnce(false).mockResolvedValueOnce(true);

// First call: not proposed, signalling proceeds
expect(
await publisher.enqueueGovernanceCastSignal(
govPayload,
SlotNumber(2),
1n,
EthAddress.fromString(testHarnessAttesterAccount.address),
msg => testHarnessAttesterAccount.signTypedData(msg),
),
).toEqual(true);

// Second call: now proposed, signalling stops
expect(
await publisher.enqueueGovernanceCastSignal(
govPayload,
SlotNumber(3),
2n,
EthAddress.fromString(testHarnessAttesterAccount.address),
msg => testHarnessAttesterAccount.signTypedData(msg),
),
).toEqual(false);

expect(governanceProposerContract.hasPayloadBeenProposed).toHaveBeenCalledTimes(2);
});
});
28 changes: 28 additions & 0 deletions yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type { Fr } from '@aztec/foundation/curves/bn254';
import { EthAddress } from '@aztec/foundation/eth-address';
import { Signature, type ViemSignature } from '@aztec/foundation/eth-signature';
import { type Logger, createLogger } from '@aztec/foundation/log';
import { makeBackoff, retry } from '@aztec/foundation/retry';
import { bufferToHex } from '@aztec/foundation/string';
import { DateProvider, Timer } from '@aztec/foundation/timer';
import { EmpireBaseAbi, ErrorsAbi, RollupAbi } from '@aztec/l1-artifacts';
Expand Down Expand Up @@ -112,6 +113,7 @@ export class SequencerPublisher {
protected lastActions: Partial<Record<Action, SlotNumber>> = {};

private isPayloadEmptyCache: Map<string, boolean> = new Map<string, boolean>();
private payloadProposedCache: Set<string> = new Set<string>();

protected log: Logger;
protected ethereumSlotDuration: bigint;
Expand Down Expand Up @@ -691,6 +693,32 @@ export class SequencerPublisher {
return false;
}

// Check if payload was already submitted to governance
const cacheKey = payload.toString();
if (!this.payloadProposedCache.has(cacheKey)) {
try {
const l1StartBlock = await this.rollupContract.getL1StartBlock();
const proposed = await retry(
() => base.hasPayloadBeenProposed(payload.toString(), l1StartBlock),
'Check if payload was proposed',
makeBackoff([0, 1, 2]),
this.log,
true,
);
if (proposed) {
this.payloadProposedCache.add(cacheKey);
}
} catch (err) {
this.log.warn(`Failed to check if payload ${payload} was proposed after retries, skipping signal`, err);
return false;
}
}

if (this.payloadProposedCache.has(cacheKey)) {
this.log.info(`Payload ${payload} was already proposed to governance, stopping signals`);
return false;
}

const cachedLastVote = this.lastActions[signalType];
this.lastActions[signalType] = slotNumber;
const action = signalType;
Expand Down
Loading