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
1 change: 1 addition & 0 deletions yarn-project/foundation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"./eth-signature": "./dest/eth-signature/index.js",
"./queue": "./dest/queue/index.js",
"./fifo": "./dest/fifo/index.js",
"./fifo-set": "./dest/fifo_set/index.js",
"./fs": "./dest/fs/index.js",
"./buffer": "./dest/buffer/index.js",
"./json-rpc": "./dest/json-rpc/index.js",
Expand Down
62 changes: 62 additions & 0 deletions yarn-project/foundation/src/fifo_set/fifo_set.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { FifoSet } from './fifo_set.js';

describe('FifoSet', () => {
it('keeps entries up to the limit', () => {
const set = FifoSet.withLimit<string>(3);

set.add('a');
set.add('b');
set.add('c');

expect([...set]).toEqual(['a', 'b', 'c']);
expect(set.size).toBe(3);
expect(set.limit).toBe(3);
});

it('evicts the oldest entry when adding past the limit', () => {
const set = FifoSet.withLimit<string>(2);

set.add('a');
set.add('b');
set.add('c');

expect([...set]).toEqual(['b', 'c']);
});

it('compacts initial values to the newest entries', () => {
const set = FifoSet.withLimit(2, ['a', 'b', 'c']);

expect([...set]).toEqual(['b', 'c']);
});

it('does not evict when adding a duplicate', () => {
const set = FifoSet.withLimit(2, ['a', 'b']);

set.add('a');

expect([...set]).toEqual(['a', 'b']);
});

it('adds absent values and reports whether the set changed', () => {
const set = FifoSet.withLimit(2, ['a']);

expect(set.addIfAbsent('a')).toBe(false);
expect(set.addIfAbsent('b')).toBe(true);
expect(set.addIfAbsent('c')).toBe(true);
expect([...set]).toEqual(['b', 'c']);
});

it('can evict undefined values', () => {
const set = FifoSet.withLimit<string | undefined>(1, [undefined, 'a']);

expect([...set]).toEqual(['a']);

set.add(undefined);

expect([...set]).toEqual([undefined]);
});

it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])('throws for invalid limit %s', limit => {
expect(() => FifoSet.withLimit(limit)).toThrow('FifoSet limit must be a positive safe integer');
});
});
52 changes: 52 additions & 0 deletions yarn-project/foundation/src/fifo_set/fifo_set.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/** A Set capped to a fixed number of entries, evicting the oldest inserted value when full. */
export class FifoSet<T> extends Set<T> {
private _limit: number | undefined;

private constructor(values?: Iterable<T>) {
super(values);
}

/** Creates a bounded set with a positive integer limit. */
static withLimit<T>(limit: number, values?: Iterable<T>): FifoSet<T> {
if (!Number.isSafeInteger(limit) || limit <= 0) {
throw new TypeError(`FifoSet limit must be a positive safe integer: ${limit}`);
}

const set = new FifoSet(values);
set._limit = limit;
set.compact();

return set;
}

override add(value: T): this {
super.add(value);
this.compact();
return this;
}

/** Maximum number of entries retained by this set. */
public get limit(): number {
return this._limit ?? Infinity;
}

/** Evicts oldest entries until the set is within its limit. */
public compact(): void {
while (this.size > this.limit) {
const head = this.values().next();
if (head.done) {
return;
}
this.delete(head.value);
}
}

/** Adds a value only if it is absent, returning whether the set changed. */
public addIfAbsent(value: T): boolean {
if (this.has(value)) {
return false;
}
this.add(value);
return true;
}
}
1 change: 1 addition & 0 deletions yarn-project/foundation/src/fifo_set/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './fifo_set.js';
16 changes: 4 additions & 12 deletions yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types';
import { FifoSet } from '@aztec/foundation/fifo-set';
import type { Logger } from '@aztec/foundation/log';
import type { DateProvider } from '@aztec/foundation/timer';
import type { AztecAsyncKVStore, AztecAsyncMap } from '@aztec/kv-store';
Expand Down Expand Up @@ -82,7 +83,7 @@ export class TxPoolV2Impl {
#evictionManager: EvictionManager;
#dateProvider: DateProvider;
#instrumentation: TxPoolV2Instrumentation;
#evictedTxHashes: Set<string> = new Set();
#evictedTxHashes: FifoSet<string>;
#log: Logger;
#callbacks: TxPoolV2Callbacks;

Expand All @@ -105,6 +106,7 @@ export class TxPoolV2Impl {
this.#checkAllowedSetupCalls = deps.checkAllowedSetupCalls;

this.#config = { ...DEFAULT_TX_POOL_V2_CONFIG, ...config };
this.#evictedTxHashes = FifoSet.withLimit<string>(this.#config.evictedTxCacheSize);
this.#archive = new TxArchive(archiveStore, this.#config.archivedTxLimit, log);
this.#deletedPool = new DeletedPool(store, this.#txsDB, log);
this.#dateProvider = dateProvider;
Expand Down Expand Up @@ -903,21 +905,11 @@ export class TxPoolV2Impl {
this.#instrumentation.recordEvictions(txHashes.length, reason);
for (const txHashStr of txHashes) {
this.#log.debug(`Evicting tx ${txHashStr}`, { txHash: txHashStr, reason });
this.#addToEvictedCache(txHashStr);
this.#evictedTxHashes.add(txHashStr);
}
await this.#deleteTxsBatch(txHashes);
}

/** Adds a tx hash to the bounded evicted cache, evicting the oldest entry if at capacity. */
#addToEvictedCache(txHashStr: string): void {
if (this.#evictedTxHashes.size >= this.#config.evictedTxCacheSize) {
// FIFO eviction: remove the first (oldest) entry
const oldest = this.#evictedTxHashes.values().next().value!;
this.#evictedTxHashes.delete(oldest);
}
this.#evictedTxHashes.add(txHashStr);
}

// ============================================================================
// PRIVATE HELPERS - Validation & Conflict Resolution
// ============================================================================
Expand Down
22 changes: 5 additions & 17 deletions yarn-project/p2p/src/services/tx_file_store/tx_file_store.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { FifoSet } from '@aztec/foundation/fifo-set';
import { type Logger, createLogger } from '@aztec/foundation/log';
import { RunningPromise } from '@aztec/foundation/promise';
import { makeBackoff, retry } from '@aztec/foundation/retry';
Expand All @@ -10,6 +11,8 @@ import type { TxPoolV2 } from '../../mem_pools/index.js';
import type { TxFileStoreConfig } from './config.js';
import { TxFileStoreInstrumentation } from './instrumentation.js';

const MAX_RECENT_UPLOADS = 1000;

/**
* Uploads validated transactions to a file store as a fallback retrieval mechanism.
* Listens to TxPool txs-added events and uploads txs asynchronously with bounded concurrency.
Expand All @@ -21,9 +24,7 @@ export class TxFileStore {
private readonly handleTxsAdded: (args: { txs: Tx[]; source?: string }) => void;

/** Recently uploaded tx hashes for deduplication. */
private recentUploads: Set<string> = new Set();
private recentUploadsOrder: string[] = [];
private readonly maxRecentUploads = 1000;
private recentUploads = FifoSet.withLimit<string>(MAX_RECENT_UPLOADS);

private constructor(
private readonly fileStore: FileStore,
Expand Down Expand Up @@ -127,24 +128,11 @@ export class TxFileStore {
const path = `${this.basePath}/txs/${txHash}.bin`;
const timer = new Timer();

if (this.recentUploads.has(txHash)) {
if (!this.recentUploads.addIfAbsent(txHash)) {
return;
}

try {
this.recentUploads.add(txHash);
this.recentUploadsOrder.push(txHash);

if (this.recentUploadsOrder.length > this.maxRecentUploads) {
// delete old entries in recentUploads
for (const txHashToRemove of this.recentUploadsOrder.splice(
0,
this.recentUploadsOrder.length - this.maxRecentUploads,
)) {
this.recentUploads.delete(txHashToRemove);
}
}

await retry(
() => this.fileStore.save(path, tx.toBuffer(), { compress: true }),
`Uploading tx ${txHash}`,
Expand Down
10 changes: 7 additions & 3 deletions yarn-project/slasher/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,9 @@ List of all slashable offenses in the system:
**Time Unit**: Slot-based offense.

### BROADCASTED_INVALID_CHECKPOINT_PROPOSAL
**Description**: A proposer broadcast a checkpoint proposal that terminates before a higher-index block proposal signed by the same proposer in the same slot.
**Detection**: BroadcastedInvalidCheckpointProposalWatcher scans retained P2P proposals and compares checkpoint archive roots to signed block proposals from the same slot and signer.
**Target**: Proposer who broadcast the truncated checkpoint proposal.
**Description**: A proposer broadcast an invalid checkpoint proposal, either one that terminates before a higher-index block proposal signed by the same proposer in the same slot, one whose signed header does not match deterministic validator recomputation, or one with a malformed fee asset price modifier.
**Detection**: BroadcastedInvalidCheckpointProposalWatcher scans retained P2P proposal evidence and compares checkpoint archive roots to signed block proposals from the same slot and signer. ValidatorClient also validates checkpoint proposals during the all-nodes callback and emits this offense when checkpoint header recomputation fails or the signed fee asset price modifier is malformed.
**Target**: Proposer who broadcast the invalid checkpoint proposal.
**Time Unit**: Slot-based offense.

## Configuration
Expand All @@ -157,6 +157,10 @@ Considerations:

These settings are configured locally on each validator node:

Block and checkpoint validation settings are expected to be the same across all validators. Slashing relies on
validators making the same deterministic validity decisions for block and checkpoint proposals; operators should not run
with divergent validation limits.

- `slashGracePeriodL2Slots`: Number of initial L2 slots where slashing is disabled
- `slashOffenseExpirationRounds`: Number of rounds after which pending offenses expire
- `slashValidatorsAlways`: Array of validator addresses that should always be slashed
Expand Down
23 changes: 5 additions & 18 deletions yarn-project/slasher/src/watchers/attestations_block_watcher.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { EpochCache } from '@aztec/epoch-cache';
import { SlotNumber } from '@aztec/foundation/branded-types';
import { merge, pick } from '@aztec/foundation/collection';
import { FifoSet } from '@aztec/foundation/fifo-set';
import { type Logger, createLogger } from '@aztec/foundation/log';
import {
type InvalidCheckpointDetectedEvent,
type L2BlockSourceEventEmitter,
L2BlockSourceEvents,
type ValidateCheckpointNegativeResult,
} from '@aztec/stdlib/block';
import type { CheckpointInfo } from '@aztec/stdlib/checkpoint';
import { OffenseType } from '@aztec/stdlib/slashing';

import EventEmitter from 'node:events';
Expand All @@ -20,6 +20,7 @@ const AttestationsBlockWatcherConfigKeys = [
'slashAttestDescendantOfInvalidPenalty',
'slashProposeInvalidAttestationsPenalty',
] as const;
const MAX_INVALID_CHECKPOINTS = 100;

type AttestationsBlockWatcherConfig = Pick<SlasherConfig, (typeof AttestationsBlockWatcherConfigKeys)[number]>;

Expand All @@ -32,11 +33,8 @@ type AttestationsBlockWatcherConfig = Pick<SlasherConfig, (typeof AttestationsBl
export class AttestationsBlockWatcher extends (EventEmitter as new () => WatcherEmitter) implements Watcher {
private log: Logger = createLogger('attestations-block-watcher');

// Only keep track of the last N invalid checkpoints
private maxInvalidCheckpoints = 100;

// All invalid archive roots seen
private invalidArchiveRoots: Set<string> = new Set();
// Recently seen invalid archive roots.
private invalidArchiveRoots = FifoSet.withLimit<string>(MAX_INVALID_CHECKPOINTS);

private config: AttestationsBlockWatcherConfig;

Expand Down Expand Up @@ -98,8 +96,7 @@ export class AttestationsBlockWatcher extends (EventEmitter as new () => Watcher
reason: validationResult.valid === false ? validationResult.reason : 'unknown',
});

// Store the invalid checkpoint
this.addInvalidCheckpoint(event.validationResult.checkpoint);
this.invalidArchiveRoots.add(checkpoint.archive.toString());

// Slash the proposer of the invalid checkpoint
this.slashProposer(event.validationResult);
Expand Down Expand Up @@ -181,14 +178,4 @@ export class AttestationsBlockWatcher extends (EventEmitter as new () => Watcher
}
}
}

private addInvalidCheckpoint(checkpoint: CheckpointInfo) {
this.invalidArchiveRoots.add(checkpoint.archive.toString());

// Prune old entries if we exceed the maximum
if (this.invalidArchiveRoots.size > this.maxInvalidCheckpoints) {
const oldestKey = this.invalidArchiveRoots.keys().next().value!;
this.invalidArchiveRoots.delete(oldestKey);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { EpochCacheInterface } from '@aztec/epoch-cache';
import { SlotNumber } from '@aztec/foundation/branded-types';
import { merge, pick } from '@aztec/foundation/collection';
import type { EthAddress } from '@aztec/foundation/eth-address';
import { FifoSet } from '@aztec/foundation/fifo-set';
import { type Logger, createLogger } from '@aztec/foundation/log';
import { RunningPromise } from '@aztec/foundation/running-promise';
import type { P2PClient, SlasherConfig } from '@aztec/stdlib/interfaces/server';
Expand Down Expand Up @@ -39,7 +40,7 @@ export class BroadcastedInvalidCheckpointProposalWatcher
{
private readonly log: Logger = createLogger('broadcasted-invalid-checkpoint-proposal-watcher');
private readonly runningPromise: RunningPromise;
private readonly emittedOffenses = new Set<string>();
private readonly emittedOffenses: FifoSet<string>;
private readonly scanSlotLookback: number;
private config: BroadcastedInvalidCheckpointProposalWatcherConfig;
private lastScannedSlot: SlotNumber | undefined;
Expand All @@ -54,6 +55,12 @@ export class BroadcastedInvalidCheckpointProposalWatcher
const constants = epochCache.getL1Constants();
this.config = pick(config, ...BroadcastedInvalidCheckpointProposalWatcherConfigKeys);
this.scanSlotLookback = Math.max(1, scanSlotLookback);

// Bound emitted offenses to the number of slots we rescan. This watcher currently tracks one offense type,
// and at most one offense of that type can be emitted per slot.
const offenseTypes = 1;
this.emittedOffenses = FifoSet.withLimit<string>(offenseTypes * this.scanSlotLookback);

const intervalMs = Math.max(1000, (constants.ethereumSlotDuration * 1000) / 4);
this.runningPromise = new RunningPromise(() => this.scan(), this.log, intervalMs);
this.log.info('BroadcastedInvalidCheckpointProposalWatcher initialized', {
Expand Down Expand Up @@ -182,10 +189,6 @@ export class BroadcastedInvalidCheckpointProposalWatcher

private markAsNewOffense(args: WantToSlashArgs): boolean {
const key = `${args.validator.toString()}-${args.offenseType}-${args.epochOrSlot}`;
if (this.emittedOffenses.has(key)) {
return false;
}
this.emittedOffenses.add(key);
return true;
return this.emittedOffenses.addIfAbsent(key);
}
}
2 changes: 2 additions & 0 deletions yarn-project/stdlib/src/interfaces/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export type ValidatorClientFullConfig = ValidatorClientConfig &
Pick<
SlasherConfig,
| 'slashBroadcastedInvalidBlockPenalty'
| 'slashBroadcastedInvalidCheckpointProposalPenalty'
| 'slashDuplicateProposalPenalty'
| 'slashDuplicateAttestationPenalty'
| 'slashAttestInvalidCheckpointProposalPenalty'
Expand Down Expand Up @@ -124,6 +125,7 @@ export const ValidatorClientFullConfigSchema = zodFor<Omit<ValidatorClientFullCo
broadcastInvalidBlockProposal: z.boolean().optional(),
maxBlocksPerCheckpoint: z.number().positive().optional(),
slashBroadcastedInvalidBlockPenalty: schemas.BigInt,
slashBroadcastedInvalidCheckpointProposalPenalty: schemas.BigInt,
slashDuplicateProposalPenalty: schemas.BigInt,
slashDuplicateAttestationPenalty: schemas.BigInt,
slashAttestInvalidCheckpointProposalPenalty: schemas.BigInt,
Expand Down
2 changes: 1 addition & 1 deletion yarn-project/stdlib/src/slashing/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export enum OffenseType {
DUPLICATE_ATTESTATION = 9,
/** A committee member attested to a checkpoint proposal in a slot with an invalid block proposal */
ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL = 10,
/** A proposer broadcast a checkpoint proposal truncated before a higher-index block proposal in the same slot */
/** A proposer broadcast an invalid checkpoint proposal, detected by retained evidence or deterministic recomputation */
BROADCASTED_INVALID_CHECKPOINT_PROPOSAL = 11,
}

Expand Down
Loading
Loading