@aztec/slasher 0.0.1-commit.86469d5 → 0.0.1-commit.8655d4a
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -76
- package/dest/config.d.ts +1 -1
- package/dest/config.d.ts.map +1 -1
- package/dest/config.js +41 -29
- package/dest/factory/create_facade.d.ts +3 -3
- package/dest/factory/create_facade.d.ts.map +1 -1
- package/dest/factory/create_facade.js +25 -2
- package/dest/factory/create_implementation.d.ts +6 -7
- package/dest/factory/create_implementation.d.ts.map +1 -1
- package/dest/factory/create_implementation.js +8 -56
- package/dest/factory/get_settings.d.ts +4 -4
- package/dest/factory/get_settings.d.ts.map +1 -1
- package/dest/factory/get_settings.js +3 -3
- package/dest/factory/index.d.ts +2 -2
- package/dest/factory/index.d.ts.map +1 -1
- package/dest/factory/index.js +1 -1
- package/dest/generated/slasher-defaults.d.ts +8 -6
- package/dest/generated/slasher-defaults.d.ts.map +1 -1
- package/dest/generated/slasher-defaults.js +7 -5
- package/dest/index.d.ts +6 -4
- package/dest/index.d.ts.map +1 -1
- package/dest/index.js +5 -3
- package/dest/null_slasher_client.d.ts +3 -4
- package/dest/null_slasher_client.d.ts.map +1 -1
- package/dest/null_slasher_client.js +1 -4
- package/dest/slash_offenses_collector.d.ts +10 -9
- package/dest/slash_offenses_collector.d.ts.map +1 -1
- package/dest/slash_offenses_collector.js +50 -34
- package/dest/slasher_client.d.ts +112 -0
- package/dest/slasher_client.d.ts.map +1 -0
- package/dest/{tally_slasher_client.js → slasher_client.js} +45 -45
- package/dest/slasher_client_facade.d.ts +6 -8
- package/dest/slasher_client_facade.d.ts.map +1 -1
- package/dest/slasher_client_facade.js +6 -9
- package/dest/slasher_client_interface.d.ts +7 -21
- package/dest/slasher_client_interface.d.ts.map +1 -1
- package/dest/slasher_client_interface.js +1 -4
- package/dest/stores/offenses_store.d.ts +12 -12
- package/dest/stores/offenses_store.d.ts.map +1 -1
- package/dest/stores/offenses_store.js +61 -38
- package/dest/watcher.d.ts +8 -1
- package/dest/watcher.d.ts.map +1 -1
- package/dest/watcher.js +1 -0
- package/dest/watchers/attestations_block_watcher.d.ts +26 -13
- package/dest/watchers/attestations_block_watcher.d.ts.map +1 -1
- package/dest/watchers/attestations_block_watcher.js +76 -61
- package/dest/watchers/attested_invalid_proposal_watcher.d.ts +42 -0
- package/dest/watchers/attested_invalid_proposal_watcher.d.ts.map +1 -0
- package/dest/watchers/attested_invalid_proposal_watcher.js +117 -0
- package/dest/watchers/broadcasted_invalid_checkpoint_proposal_watcher.d.ts +38 -0
- package/dest/watchers/broadcasted_invalid_checkpoint_proposal_watcher.d.ts.map +1 -0
- package/dest/watchers/broadcasted_invalid_checkpoint_proposal_watcher.js +138 -0
- package/dest/watchers/checkpoint_equivocation_watcher.d.ts +30 -0
- package/dest/watchers/checkpoint_equivocation_watcher.d.ts.map +1 -0
- package/dest/watchers/checkpoint_equivocation_watcher.js +69 -0
- package/dest/watchers/data_withholding_watcher.d.ts +63 -0
- package/dest/watchers/data_withholding_watcher.d.ts.map +1 -0
- package/dest/watchers/data_withholding_watcher.js +193 -0
- package/package.json +10 -10
- package/src/config.ts +48 -29
- package/src/factory/create_facade.ts +32 -4
- package/src/factory/create_implementation.ts +24 -105
- package/src/factory/get_settings.ts +8 -8
- package/src/factory/index.ts +1 -1
- package/src/generated/slasher-defaults.ts +7 -5
- package/src/index.ts +5 -3
- package/src/null_slasher_client.ts +2 -6
- package/src/slash_offenses_collector.ts +70 -36
- package/src/{tally_slasher_client.ts → slasher_client.ts} +63 -54
- package/src/slasher_client_facade.ts +6 -11
- package/src/slasher_client_interface.ts +6 -21
- package/src/stores/offenses_store.ts +73 -47
- package/src/watcher.ts +8 -0
- package/src/watchers/attestations_block_watcher.ts +88 -82
- package/src/watchers/attested_invalid_proposal_watcher.ts +168 -0
- package/src/watchers/broadcasted_invalid_checkpoint_proposal_watcher.ts +192 -0
- package/src/watchers/checkpoint_equivocation_watcher.ts +96 -0
- package/src/watchers/data_withholding_watcher.ts +225 -0
- package/dest/empire_slasher_client.d.ts +0 -190
- package/dest/empire_slasher_client.d.ts.map +0 -1
- package/dest/empire_slasher_client.js +0 -564
- package/dest/stores/payloads_store.d.ts +0 -29
- package/dest/stores/payloads_store.d.ts.map +0 -1
- package/dest/stores/payloads_store.js +0 -128
- package/dest/tally_slasher_client.d.ts +0 -125
- package/dest/tally_slasher_client.d.ts.map +0 -1
- package/dest/watchers/epoch_prune_watcher.d.ts +0 -38
- package/dest/watchers/epoch_prune_watcher.d.ts.map +0 -1
- package/dest/watchers/epoch_prune_watcher.js +0 -158
- package/src/empire_slasher_client.ts +0 -649
- package/src/stores/payloads_store.ts +0 -149
- package/src/watchers/epoch_prune_watcher.ts +0 -221
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { EpochCacheInterface } from '@aztec/epoch-cache';
|
|
2
|
+
import { SlotNumber } from '@aztec/foundation/branded-types';
|
|
3
|
+
import { merge, pick } from '@aztec/foundation/collection';
|
|
4
|
+
import type { EthAddress } from '@aztec/foundation/eth-address';
|
|
5
|
+
import { FifoSet } from '@aztec/foundation/fifo-set';
|
|
6
|
+
import { type Logger, createLogger } from '@aztec/foundation/log';
|
|
7
|
+
import { RunningPromise } from '@aztec/foundation/running-promise';
|
|
8
|
+
import type { L2BlockSource } from '@aztec/stdlib/block';
|
|
9
|
+
import type { P2PClient, SlasherConfig } from '@aztec/stdlib/interfaces/server';
|
|
10
|
+
import type { BlockProposal, CheckpointProposalCore } from '@aztec/stdlib/p2p';
|
|
11
|
+
import { OffenseType, getOffenseTypeName } from '@aztec/stdlib/slashing';
|
|
12
|
+
|
|
13
|
+
import EventEmitter from 'node:events';
|
|
14
|
+
|
|
15
|
+
import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher, type WatcherEmitter } from '../watcher.js';
|
|
16
|
+
|
|
17
|
+
const BroadcastedInvalidCheckpointProposalWatcherConfigKeys = [
|
|
18
|
+
'slashBroadcastedInvalidCheckpointProposalPenalty',
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
const SCAN_SLOT_LAG = 1;
|
|
22
|
+
const DEFAULT_SCAN_SLOT_LOOKBACK = 4;
|
|
23
|
+
|
|
24
|
+
type BroadcastedInvalidCheckpointProposalWatcherConfig = Pick<
|
|
25
|
+
SlasherConfig,
|
|
26
|
+
(typeof BroadcastedInvalidCheckpointProposalWatcherConfigKeys)[number]
|
|
27
|
+
>;
|
|
28
|
+
|
|
29
|
+
type ProposalsForSlot = Awaited<ReturnType<P2PClient['getProposalsForSlot']>>;
|
|
30
|
+
type P2PProposalsForSlotSource = Pick<P2PClient, 'getProposalsForSlot'>;
|
|
31
|
+
|
|
32
|
+
type SignedBlockProposal = {
|
|
33
|
+
proposal: BlockProposal;
|
|
34
|
+
signer: EthAddress;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Detects truncated-checkpoint proposal offenses from retained signed P2P proposals. */
|
|
38
|
+
export class BroadcastedInvalidCheckpointProposalWatcher
|
|
39
|
+
extends (EventEmitter as new () => WatcherEmitter)
|
|
40
|
+
implements Watcher
|
|
41
|
+
{
|
|
42
|
+
private readonly log: Logger = createLogger('broadcasted-invalid-checkpoint-proposal-watcher');
|
|
43
|
+
private readonly runningPromise: RunningPromise;
|
|
44
|
+
private readonly emittedOffenses: FifoSet<string>;
|
|
45
|
+
private readonly scanSlotLookback: number;
|
|
46
|
+
private config: BroadcastedInvalidCheckpointProposalWatcherConfig;
|
|
47
|
+
private lastScannedSlot: SlotNumber | undefined;
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
private readonly p2pClient: P2PProposalsForSlotSource,
|
|
51
|
+
private readonly l2BlockSource: Pick<L2BlockSource, 'getSyncedL2SlotNumber'>,
|
|
52
|
+
private readonly epochCache: Pick<EpochCacheInterface, 'getSlotNow' | 'getL1Constants'>,
|
|
53
|
+
config: BroadcastedInvalidCheckpointProposalWatcherConfig,
|
|
54
|
+
scanSlotLookback = DEFAULT_SCAN_SLOT_LOOKBACK,
|
|
55
|
+
) {
|
|
56
|
+
super();
|
|
57
|
+
const constants = epochCache.getL1Constants();
|
|
58
|
+
this.config = pick(config, ...BroadcastedInvalidCheckpointProposalWatcherConfigKeys);
|
|
59
|
+
this.scanSlotLookback = Math.max(1, scanSlotLookback);
|
|
60
|
+
|
|
61
|
+
// Bound emitted offenses to the number of slots we rescan. This watcher currently tracks one offense type,
|
|
62
|
+
// and at most one offense of that type can be emitted per slot.
|
|
63
|
+
const offenseTypes = 1;
|
|
64
|
+
this.emittedOffenses = FifoSet.withLimit<string>(offenseTypes * this.scanSlotLookback);
|
|
65
|
+
|
|
66
|
+
const intervalMs = Math.max(1000, (constants.ethereumSlotDuration * 1000) / 4);
|
|
67
|
+
this.runningPromise = new RunningPromise(() => this.scan(), this.log, intervalMs);
|
|
68
|
+
this.log.info('BroadcastedInvalidCheckpointProposalWatcher initialized', {
|
|
69
|
+
scanSlotLookback: this.scanSlotLookback,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public updateConfig(config: Partial<BroadcastedInvalidCheckpointProposalWatcherConfig>): void {
|
|
74
|
+
this.config = merge(this.config, pick(config, ...BroadcastedInvalidCheckpointProposalWatcherConfigKeys));
|
|
75
|
+
this.log.verbose('BroadcastedInvalidCheckpointProposalWatcher config updated', this.config);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
public start(): Promise<void> {
|
|
79
|
+
this.runningPromise.start();
|
|
80
|
+
return Promise.resolve();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public stop(): Promise<void> {
|
|
84
|
+
return this.runningPromise.stop();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Scans newly closed slots, plus a small lookback for late-arriving proposals. Anchors
|
|
89
|
+
* `currentSlot` at the archiver's last synced L2 slot.
|
|
90
|
+
*/
|
|
91
|
+
public async scan(): Promise<void> {
|
|
92
|
+
const currentSlot = (await this.l2BlockSource.getSyncedL2SlotNumber()) ?? this.epochCache.getSlotNow();
|
|
93
|
+
if (currentSlot <= SlotNumber(SCAN_SLOT_LAG)) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const newestSlotToConsider = SlotNumber(currentSlot - 1 - SCAN_SLOT_LAG);
|
|
98
|
+
const oldestLookbackSlot = SlotNumber(Math.max(0, newestSlotToConsider - this.scanSlotLookback + 1));
|
|
99
|
+
const oldestUnscannedSlot =
|
|
100
|
+
this.lastScannedSlot === undefined ? oldestLookbackSlot : SlotNumber(this.lastScannedSlot + 1);
|
|
101
|
+
const oldestSlot = SlotNumber(Math.min(oldestLookbackSlot, oldestUnscannedSlot));
|
|
102
|
+
for (let slot = oldestSlot; slot <= newestSlotToConsider; slot++) {
|
|
103
|
+
await this.scanSlot(SlotNumber(slot));
|
|
104
|
+
}
|
|
105
|
+
this.lastScannedSlot = newestSlotToConsider;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Scans a single slot. Public for tests. */
|
|
109
|
+
public async scanSlot(slot: SlotNumber): Promise<void> {
|
|
110
|
+
const proposals = await this.p2pClient.getProposalsForSlot(slot);
|
|
111
|
+
const slashArgs = this.getSlashArgsForProposals(slot, proposals).filter(args => this.markAsNewOffense(args));
|
|
112
|
+
if (slashArgs.length === 0) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
this.log.info(`Detected broadcasted invalid checkpoint proposal offense`, {
|
|
117
|
+
slot,
|
|
118
|
+
offenses: slashArgs.map(args => ({
|
|
119
|
+
validator: args.validator.toString(),
|
|
120
|
+
amount: args.amount,
|
|
121
|
+
offenseType: getOffenseTypeName(args.offenseType),
|
|
122
|
+
epochOrSlot: args.epochOrSlot,
|
|
123
|
+
})),
|
|
124
|
+
});
|
|
125
|
+
this.emit(WANT_TO_SLASH_EVENT, slashArgs);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private getSlashArgsForProposals(slot: SlotNumber, proposals: ProposalsForSlot): WantToSlashArgs[] {
|
|
129
|
+
const offenders = this.findOffenders(proposals.blockProposals, proposals.checkpointProposals);
|
|
130
|
+
// we expect one proposer per slot today.
|
|
131
|
+
return [...offenders.values()].map(validator => ({
|
|
132
|
+
validator,
|
|
133
|
+
amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
|
|
134
|
+
offenseType: OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL,
|
|
135
|
+
epochOrSlot: BigInt(slot),
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private findOffenders(blockProposals: BlockProposal[], checkpointProposals: CheckpointProposalCore[]) {
|
|
140
|
+
const blocksBySigner = this.getSignedBlocksBySigner(blockProposals);
|
|
141
|
+
const offenders = new Map<string, EthAddress>();
|
|
142
|
+
|
|
143
|
+
for (const checkpoint of checkpointProposals) {
|
|
144
|
+
const checkpointSigner = checkpoint.getSender();
|
|
145
|
+
if (!checkpointSigner) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const signerKey = checkpointSigner.toString();
|
|
150
|
+
const signerBlocks = blocksBySigner.get(signerKey) ?? [];
|
|
151
|
+
const terminalBlocks = signerBlocks.filter(
|
|
152
|
+
({ proposal }) => proposal.slotNumber === checkpoint.slotNumber && proposal.archive.equals(checkpoint.archive),
|
|
153
|
+
);
|
|
154
|
+
if (terminalBlocks.length === 0) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const hasTruncatedHigherBlock = terminalBlocks.some(terminalBlock =>
|
|
159
|
+
signerBlocks.some(
|
|
160
|
+
({ proposal }) =>
|
|
161
|
+
proposal.slotNumber === checkpoint.slotNumber &&
|
|
162
|
+
proposal.indexWithinCheckpoint > terminalBlock.proposal.indexWithinCheckpoint,
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
if (hasTruncatedHigherBlock) {
|
|
166
|
+
offenders.set(signerKey, checkpointSigner);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return offenders;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private getSignedBlocksBySigner(blockProposals: BlockProposal[]): Map<string, SignedBlockProposal[]> {
|
|
174
|
+
const blocksBySigner = new Map<string, SignedBlockProposal[]>();
|
|
175
|
+
for (const proposal of blockProposals) {
|
|
176
|
+
const signer = proposal.getSender();
|
|
177
|
+
if (!signer) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const signerKey = signer.toString();
|
|
181
|
+
const signerBlocks = blocksBySigner.get(signerKey) ?? [];
|
|
182
|
+
signerBlocks.push({ proposal, signer });
|
|
183
|
+
blocksBySigner.set(signerKey, signerBlocks);
|
|
184
|
+
}
|
|
185
|
+
return blocksBySigner;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private markAsNewOffense(args: WantToSlashArgs): boolean {
|
|
189
|
+
const key = `${args.validator.toString()}-${args.offenseType}-${args.epochOrSlot}`;
|
|
190
|
+
return this.emittedOffenses.addIfAbsent(key);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { EpochCacheInterface } from '@aztec/epoch-cache';
|
|
2
|
+
import { merge, pick } from '@aztec/foundation/collection';
|
|
3
|
+
import { type Logger, createLogger } from '@aztec/foundation/log';
|
|
4
|
+
import {
|
|
5
|
+
type CheckpointEquivocationDetectedEvent,
|
|
6
|
+
type L2BlockSourceEventEmitter,
|
|
7
|
+
L2BlockSourceEvents,
|
|
8
|
+
} from '@aztec/stdlib/block';
|
|
9
|
+
import type { SlasherConfig } from '@aztec/stdlib/interfaces/server';
|
|
10
|
+
import { OffenseType, getOffenseTypeName } from '@aztec/stdlib/slashing';
|
|
11
|
+
|
|
12
|
+
import EventEmitter from 'node:events';
|
|
13
|
+
|
|
14
|
+
import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher, type WatcherEmitter } from '../watcher.js';
|
|
15
|
+
|
|
16
|
+
const CheckpointEquivocationWatcherConfigKeys = ['slashDuplicateProposalPenalty'] as const;
|
|
17
|
+
|
|
18
|
+
type CheckpointEquivocationWatcherConfig = Pick<
|
|
19
|
+
SlasherConfig,
|
|
20
|
+
(typeof CheckpointEquivocationWatcherConfigKeys)[number]
|
|
21
|
+
>;
|
|
22
|
+
|
|
23
|
+
type EquivocationEventSource = Pick<L2BlockSourceEventEmitter, 'events'>;
|
|
24
|
+
type ProposerLookup = Pick<EpochCacheInterface, 'getProposerAttesterAddressInSlot'>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Slashes the slot proposer for DUPLICATE_PROPOSAL when the archiver detects that a
|
|
28
|
+
* locally-stored proposed checkpoint disagrees with the L1-confirmed checkpoint at the
|
|
29
|
+
* same slot. Both are signed by the slot proposer (the proposed one by accepting it via
|
|
30
|
+
* P2P or building it locally; the L1 one by submission), so the proposer equivocated.
|
|
31
|
+
*/
|
|
32
|
+
export class CheckpointEquivocationWatcher extends (EventEmitter as new () => WatcherEmitter) implements Watcher {
|
|
33
|
+
private readonly log: Logger = createLogger('checkpoint-equivocation-watcher');
|
|
34
|
+
private readonly handler: (args: CheckpointEquivocationDetectedEvent) => void;
|
|
35
|
+
private config: CheckpointEquivocationWatcherConfig;
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
private readonly l2BlockSource: EquivocationEventSource,
|
|
39
|
+
private readonly epochCache: ProposerLookup,
|
|
40
|
+
config: CheckpointEquivocationWatcherConfig,
|
|
41
|
+
) {
|
|
42
|
+
super();
|
|
43
|
+
this.config = pick(config, ...CheckpointEquivocationWatcherConfigKeys);
|
|
44
|
+
this.handler = event => {
|
|
45
|
+
this.onEquivocationDetected(event).catch(err =>
|
|
46
|
+
this.log.error('Failed to handle checkpoint equivocation event', err),
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
this.log.info('CheckpointEquivocationWatcher initialized');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public updateConfig(config: Partial<CheckpointEquivocationWatcherConfig>): void {
|
|
53
|
+
this.config = merge(this.config, pick(config, ...CheckpointEquivocationWatcherConfigKeys));
|
|
54
|
+
this.log.verbose('CheckpointEquivocationWatcher config updated', this.config);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public start(): Promise<void> {
|
|
58
|
+
this.l2BlockSource.events.on(L2BlockSourceEvents.CheckpointEquivocationDetected, this.handler);
|
|
59
|
+
return Promise.resolve();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public stop(): Promise<void> {
|
|
63
|
+
this.l2BlockSource.events.off(L2BlockSourceEvents.CheckpointEquivocationDetected, this.handler);
|
|
64
|
+
return Promise.resolve();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Public for tests. */
|
|
68
|
+
public async onEquivocationDetected(event: CheckpointEquivocationDetectedEvent): Promise<void> {
|
|
69
|
+
const proposer = await this.epochCache.getProposerAttesterAddressInSlot(event.slotNumber);
|
|
70
|
+
if (!proposer) {
|
|
71
|
+
this.log.warn(`Cannot attribute checkpoint equivocation: no proposer for slot ${event.slotNumber}`, {
|
|
72
|
+
slotNumber: event.slotNumber,
|
|
73
|
+
checkpointNumber: event.checkpointNumber,
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const slashArgs: WantToSlashArgs = {
|
|
79
|
+
validator: proposer,
|
|
80
|
+
amount: this.config.slashDuplicateProposalPenalty,
|
|
81
|
+
offenseType: OffenseType.DUPLICATE_PROPOSAL,
|
|
82
|
+
epochOrSlot: BigInt(event.slotNumber),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
this.log.info(`Detected checkpoint equivocation offense`, {
|
|
86
|
+
slotNumber: event.slotNumber,
|
|
87
|
+
checkpointNumber: event.checkpointNumber,
|
|
88
|
+
amount: slashArgs.amount,
|
|
89
|
+
offenseType: getOffenseTypeName(slashArgs.offenseType),
|
|
90
|
+
l1ArchiveRoot: event.l1ArchiveRoot.toString(),
|
|
91
|
+
proposedArchiveRoot: event.proposedArchiveRoot.toString(),
|
|
92
|
+
validator: proposer.toString(),
|
|
93
|
+
});
|
|
94
|
+
this.emit(WANT_TO_SLASH_EVENT, [slashArgs]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { EpochCache } from '@aztec/epoch-cache';
|
|
2
|
+
import { CheckpointProposalHash, SlotNumber } from '@aztec/foundation/branded-types';
|
|
3
|
+
import { compactArray, merge, pick } from '@aztec/foundation/collection';
|
|
4
|
+
import type { EthAddress } from '@aztec/foundation/eth-address';
|
|
5
|
+
import { type Logger, createLogger } from '@aztec/foundation/log';
|
|
6
|
+
import { RunningPromise } from '@aztec/foundation/promise';
|
|
7
|
+
import type { L2BlockSource } from '@aztec/stdlib/block';
|
|
8
|
+
import { getAttestationInfoFromPublishedCheckpoint } from '@aztec/stdlib/block';
|
|
9
|
+
import type { CheckpointReexecutionTracker, PublishedCheckpoint } from '@aztec/stdlib/checkpoint';
|
|
10
|
+
import type { ITxProvider, P2PApi, SlasherConfig } from '@aztec/stdlib/interfaces/server';
|
|
11
|
+
import { ConsensusPayload, type CoordinationSignatureContext } from '@aztec/stdlib/p2p';
|
|
12
|
+
import { OffenseType, getOffenseTypeName } from '@aztec/stdlib/slashing';
|
|
13
|
+
import type { TxHash } from '@aztec/stdlib/tx';
|
|
14
|
+
|
|
15
|
+
import EventEmitter from 'node:events';
|
|
16
|
+
|
|
17
|
+
import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher, type WatcherEmitter } from '../watcher.js';
|
|
18
|
+
|
|
19
|
+
const DataWithholdingWatcherConfigKeys = ['slashDataWithholdingPenalty', 'slashDataWithholdingToleranceSlots'] as const;
|
|
20
|
+
|
|
21
|
+
type DataWithholdingWatcherConfig = Pick<SlasherConfig, (typeof DataWithholdingWatcherConfigKeys)[number]>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Detects data-withholding offenses by probing the local mempool for the txs in published
|
|
25
|
+
* checkpoints once they are old enough that an honest node should have collected them.
|
|
26
|
+
*
|
|
27
|
+
* Per AZIP-7: once `slashDataWithholdingToleranceSlots` full slots have elapsed after the
|
|
28
|
+
* checkpoint's slot — i.e. at `slotStart(checkpoint.slot + slashDataWithholdingToleranceSlots
|
|
29
|
+
* + 1)` — if any tx from the checkpoint's blocks is still missing locally, the checkpoint's
|
|
30
|
+
* attesters are considered at fault for not making the data available, and we emit a slash
|
|
31
|
+
* for them.
|
|
32
|
+
*
|
|
33
|
+
* The watcher ticks at quarter-eth-slot cadence (matching the Sentinel template). On boot it
|
|
34
|
+
* floors processing at the current slot — restart-time gaps are accepted and not back-filled,
|
|
35
|
+
* matching the Sentinel approach.
|
|
36
|
+
*/
|
|
37
|
+
export class DataWithholdingWatcher extends (EventEmitter as new () => WatcherEmitter) implements Watcher {
|
|
38
|
+
private runningPromise: RunningPromise;
|
|
39
|
+
private initialSlot: SlotNumber | undefined;
|
|
40
|
+
private lastCheckedSlot: SlotNumber | undefined;
|
|
41
|
+
private config: DataWithholdingWatcherConfig;
|
|
42
|
+
|
|
43
|
+
constructor(
|
|
44
|
+
private readonly epochCache: EpochCache,
|
|
45
|
+
private readonly l2BlockSource: Pick<L2BlockSource, 'getCheckpoint' | 'getSyncedL2SlotNumber'>,
|
|
46
|
+
private readonly txProvider: Pick<ITxProvider, 'hasTxs'>,
|
|
47
|
+
private readonly p2p: Pick<P2PApi, 'getCheckpointAttestationsForSlot'>,
|
|
48
|
+
private readonly reexecutionTracker: Pick<CheckpointReexecutionTracker, 'getTxsCollectedRecord'>,
|
|
49
|
+
private readonly signatureContext: CoordinationSignatureContext,
|
|
50
|
+
config: DataWithholdingWatcherConfig,
|
|
51
|
+
private readonly log: Logger = createLogger('data-withholding-watcher'),
|
|
52
|
+
) {
|
|
53
|
+
super();
|
|
54
|
+
this.config = pick(config, ...DataWithholdingWatcherConfigKeys);
|
|
55
|
+
const interval = (epochCache.getL1Constants().ethereumSlotDuration * 1000) / 4;
|
|
56
|
+
this.runningPromise = new RunningPromise(this.work.bind(this), log, interval);
|
|
57
|
+
this.log.verbose(`DataWithholdingWatcher initialized`, this.config);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public async start(): Promise<void> {
|
|
61
|
+
// Floor processing at the archiver's synced slot rather than the wallclock — restart-time
|
|
62
|
+
// gaps before the archiver catches up are accepted and not back-filled. Falls back to the
|
|
63
|
+
// wallclock if the archiver isn't ready yet (cold start).
|
|
64
|
+
const syncedSlot = await this.l2BlockSource.getSyncedL2SlotNumber();
|
|
65
|
+
this.initialSlot = syncedSlot ?? this.epochCache.getSlotNow();
|
|
66
|
+
this.log.info(`Starting data-withholding watcher with initial slot ${this.initialSlot}`);
|
|
67
|
+
this.runningPromise.start();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public stop(): Promise<void> {
|
|
71
|
+
return this.runningPromise.stop();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public updateConfig(config: Partial<SlasherConfig>): void {
|
|
75
|
+
this.config = merge(this.config, pick(config, ...DataWithholdingWatcherConfigKeys));
|
|
76
|
+
this.log.verbose('DataWithholdingWatcher config updated', this.config);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Runs every tick. Walks newly-eligible slots and probes their checkpoints for data
|
|
81
|
+
* availability; emits a DATA_WITHHOLDING slash for any checkpoint whose txs are missing.
|
|
82
|
+
*/
|
|
83
|
+
public async work(): Promise<void> {
|
|
84
|
+
if (this.initialSlot === undefined) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// tolerance is the number of full slots that must elapse after the checkpoint's slot
|
|
89
|
+
// before we declare its data missing. For checkpoint slot S, we therefore process S
|
|
90
|
+
// only once we are in slot `S + tolerance + 1` or later. Drive this off the archiver's
|
|
91
|
+
// synced slot rather than the wallclock so we don't make claims about slots we haven't
|
|
92
|
+
// fully ingested yet (archiver may lag behind L1).
|
|
93
|
+
const tolerance = this.config.slashDataWithholdingToleranceSlots;
|
|
94
|
+
const currentSlot = (await this.l2BlockSource.getSyncedL2SlotNumber()) ?? this.epochCache.getSlotNow();
|
|
95
|
+
if (currentSlot <= tolerance) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const targetSlot = SlotNumber(currentSlot - tolerance - 1);
|
|
100
|
+
if (targetSlot <= this.initialSlot) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const startSlot = this.lastCheckedSlot === undefined ? this.initialSlot : this.lastCheckedSlot;
|
|
105
|
+
for (let slot = SlotNumber(startSlot + 1); slot <= targetSlot; slot = SlotNumber(slot + 1)) {
|
|
106
|
+
try {
|
|
107
|
+
await this.processSlot(slot);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
this.log.error(`Error processing slot ${slot} for data-withholding check`, err, { slot });
|
|
110
|
+
}
|
|
111
|
+
this.lastCheckedSlot = slot;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Probes the checkpoint at the given slot, if any, and emits a slash on missing txs. */
|
|
116
|
+
private async processSlot(slot: SlotNumber): Promise<void> {
|
|
117
|
+
const published = await this.l2BlockSource.getCheckpoint({ slot });
|
|
118
|
+
if (!published) {
|
|
119
|
+
this.log.trace(`No published checkpoint at slot ${slot}`, { slot });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const checkpointNumber = published.checkpoint.number;
|
|
124
|
+
|
|
125
|
+
// Per-block tx-collection records (true | false | undefined) for every block in this
|
|
126
|
+
// published checkpoint. Captured by the validator's proposal handler at the moment of
|
|
127
|
+
// tx collection (i.e. by the *re-execution* deadline). Used as a positive short-circuit
|
|
128
|
+
// only: a `true` for every block means we know the data was available locally, so this
|
|
129
|
+
// checkpoint cannot be a data-withholding offense. A `false` does *not* trigger a slash
|
|
130
|
+
// on its own — the re-execution deadline is much earlier than the data-withholding
|
|
131
|
+
// tolerance window, so missing txs at that earlier deadline may still arrive in time.
|
|
132
|
+
// Anything other than all-true falls through to the mempool probe, which respects the
|
|
133
|
+
// tolerance window.
|
|
134
|
+
const collectionRecords = published.checkpoint.blocks.map((block, idx) =>
|
|
135
|
+
this.reexecutionTracker.getTxsCollectedRecord(block.header.getSlot(), idx),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (collectionRecords.every(r => r === true)) {
|
|
139
|
+
this.log.trace(`All blocks for checkpoint at slot ${slot} were collected locally; skipping`, {
|
|
140
|
+
slot,
|
|
141
|
+
checkpointNumber,
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const txHashes: TxHash[] = published.checkpoint.blocks.flatMap(block =>
|
|
147
|
+
block.body.txEffects.map(txEffect => txEffect.txHash),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
if (txHashes.length === 0) {
|
|
151
|
+
this.log.trace(`Checkpoint at slot ${slot} has no txs`, { slot });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const availability = await this.txProvider.hasTxs(txHashes);
|
|
156
|
+
const missingTxs = txHashes.filter((_, i) => !availability[i]);
|
|
157
|
+
if (missingTxs.length === 0) {
|
|
158
|
+
this.log.trace(`All ${txHashes.length} txs available for checkpoint at slot ${slot}`, { slot });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const attesters = await this.extractAttesters(published);
|
|
163
|
+
|
|
164
|
+
if (attesters.length === 0) {
|
|
165
|
+
this.log.warn(`Detected data withholding at slot ${slot} but no recoverable attesters`, {
|
|
166
|
+
slot,
|
|
167
|
+
checkpointNumber,
|
|
168
|
+
missingTxs: missingTxs.map(h => h.toString()),
|
|
169
|
+
records: collectionRecords,
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
this.log.info(`Detected data withholding offense at slot ${slot}`, {
|
|
175
|
+
slot,
|
|
176
|
+
checkpointNumber,
|
|
177
|
+
amount: this.config.slashDataWithholdingPenalty,
|
|
178
|
+
offenseType: getOffenseTypeName(OffenseType.DATA_WITHHOLDING),
|
|
179
|
+
missingTxs: missingTxs.map(h => h.toString()),
|
|
180
|
+
records: collectionRecords,
|
|
181
|
+
attesters: attesters.map(a => a.toString()),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const args: WantToSlashArgs[] = attesters.map(validator => ({
|
|
185
|
+
validator,
|
|
186
|
+
amount: this.config.slashDataWithholdingPenalty,
|
|
187
|
+
offenseType: OffenseType.DATA_WITHHOLDING,
|
|
188
|
+
epochOrSlot: BigInt(slot),
|
|
189
|
+
}));
|
|
190
|
+
this.emit(WANT_TO_SLASH_EVENT, args);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Returns the union of:
|
|
195
|
+
* 1. attesters whose signatures landed in the published checkpoint on L1, and
|
|
196
|
+
* 2. attesters we observed signing the same proposal on p2p (the proposer publishes as
|
|
197
|
+
* soon as it has hit committee quorum, so honest peer attestations that arrive after
|
|
198
|
+
* that point are dropped — but they still vouched for the data and
|
|
199
|
+
* should be slashed for withholding it).
|
|
200
|
+
*
|
|
201
|
+
*
|
|
202
|
+
* Exposed as protected so tests can substitute a deterministic recovery without having
|
|
203
|
+
* to construct real secp256k1 signatures.
|
|
204
|
+
*/
|
|
205
|
+
protected async extractAttesters(published: PublishedCheckpoint): Promise<EthAddress[]> {
|
|
206
|
+
const fromL1 = getAttestationInfoFromPublishedCheckpoint(published, this.signatureContext)
|
|
207
|
+
.filter(info => info.status === 'recovered-from-signature')
|
|
208
|
+
.map(info => info.address);
|
|
209
|
+
|
|
210
|
+
const slot = published.checkpoint.header.slotNumber;
|
|
211
|
+
const proposalPayloadHash = CheckpointProposalHash.fromBuffer(
|
|
212
|
+
ConsensusPayload.fromCheckpoint(published.checkpoint, this.signatureContext).getPayloadHash(),
|
|
213
|
+
);
|
|
214
|
+
const fromP2p = await this.p2p
|
|
215
|
+
.getCheckpointAttestationsForSlot(slot, proposalPayloadHash)
|
|
216
|
+
.then(attestations => attestations.map(a => a.getSender()));
|
|
217
|
+
|
|
218
|
+
// Dedupe
|
|
219
|
+
const all = new Map<string, EthAddress>();
|
|
220
|
+
for (const addr of compactArray([...fromL1, ...fromP2p])) {
|
|
221
|
+
all.set(addr.toString(), addr);
|
|
222
|
+
}
|
|
223
|
+
return [...all.values()];
|
|
224
|
+
}
|
|
225
|
+
}
|