@aztec/validator-client 0.0.1-commit.0c875d939 → 0.0.1-commit.0ec55a70b
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 +41 -2
- package/dest/checkpoint_builder.d.ts +19 -6
- package/dest/checkpoint_builder.d.ts.map +1 -1
- package/dest/checkpoint_builder.js +115 -39
- package/dest/config.d.ts +1 -1
- package/dest/config.d.ts.map +1 -1
- package/dest/config.js +29 -7
- package/dest/duties/validation_service.d.ts +11 -12
- package/dest/duties/validation_service.d.ts.map +1 -1
- package/dest/duties/validation_service.js +27 -45
- package/dest/factory.d.ts +7 -4
- package/dest/factory.d.ts.map +1 -1
- package/dest/factory.js +11 -5
- package/dest/index.d.ts +2 -3
- package/dest/index.d.ts.map +1 -1
- package/dest/index.js +1 -2
- package/dest/key_store/ha_key_store.js +1 -1
- package/dest/metrics.d.ts +14 -2
- package/dest/metrics.d.ts.map +1 -1
- package/dest/metrics.js +24 -0
- package/dest/proposal_handler.d.ts +108 -0
- package/dest/proposal_handler.d.ts.map +1 -0
- package/dest/proposal_handler.js +986 -0
- package/dest/validator.d.ts +24 -20
- package/dest/validator.d.ts.map +1 -1
- package/dest/validator.js +108 -213
- package/package.json +19 -19
- package/src/checkpoint_builder.ts +135 -39
- package/src/config.ts +29 -6
- package/src/duties/validation_service.ts +46 -53
- package/src/factory.ts +15 -3
- package/src/index.ts +1 -2
- package/src/key_store/ha_key_store.ts +1 -1
- package/src/metrics.ts +37 -1
- package/src/proposal_handler.ts +1052 -0
- package/src/validator.ts +153 -241
- package/dest/block_proposal_handler.d.ts +0 -63
- package/dest/block_proposal_handler.d.ts.map +0 -1
- package/dest/block_proposal_handler.js +0 -546
- package/dest/tx_validator/index.d.ts +0 -3
- package/dest/tx_validator/index.d.ts.map +0 -1
- package/dest/tx_validator/index.js +0 -2
- package/dest/tx_validator/nullifier_cache.d.ts +0 -14
- package/dest/tx_validator/nullifier_cache.d.ts.map +0 -1
- package/dest/tx_validator/nullifier_cache.js +0 -24
- package/dest/tx_validator/tx_validator_factory.d.ts +0 -19
- package/dest/tx_validator/tx_validator_factory.d.ts.map +0 -1
- package/dest/tx_validator/tx_validator_factory.js +0 -54
- package/src/block_proposal_handler.ts +0 -556
- package/src/tx_validator/index.ts +0 -2
- package/src/tx_validator/nullifier_cache.ts +0 -30
- package/src/tx_validator/tx_validator_factory.ts +0 -154
package/src/validator.ts
CHANGED
|
@@ -1,20 +1,11 @@
|
|
|
1
1
|
import type { BlobClientInterface } from '@aztec/blob-client/client';
|
|
2
2
|
import { type Blob, getBlobsPerL1Block } from '@aztec/blob-lib';
|
|
3
3
|
import type { EpochCache } from '@aztec/epoch-cache';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
BlockNumber,
|
|
7
|
-
CheckpointNumber,
|
|
8
|
-
EpochNumber,
|
|
9
|
-
IndexWithinCheckpoint,
|
|
10
|
-
SlotNumber,
|
|
11
|
-
} from '@aztec/foundation/branded-types';
|
|
4
|
+
import { CheckpointNumber, EpochNumber, IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types';
|
|
12
5
|
import { Fr } from '@aztec/foundation/curves/bn254';
|
|
13
|
-
import { TimeoutError } from '@aztec/foundation/error';
|
|
14
6
|
import type { EthAddress } from '@aztec/foundation/eth-address';
|
|
15
7
|
import type { Signature } from '@aztec/foundation/eth-signature';
|
|
16
8
|
import { type LogData, type Logger, createLogger } from '@aztec/foundation/log';
|
|
17
|
-
import { retryUntil } from '@aztec/foundation/retry';
|
|
18
9
|
import { RunningPromise } from '@aztec/foundation/running-promise';
|
|
19
10
|
import { sleep } from '@aztec/foundation/sleep';
|
|
20
11
|
import { DateProvider } from '@aztec/foundation/timer';
|
|
@@ -23,16 +14,15 @@ import type { DuplicateAttestationInfo, DuplicateProposalInfo, P2P, PeerId } fro
|
|
|
23
14
|
import { AuthRequest, AuthResponse, BlockProposalValidator, ReqRespSubProtocol } from '@aztec/p2p';
|
|
24
15
|
import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher';
|
|
25
16
|
import type { AztecAddress } from '@aztec/stdlib/aztec-address';
|
|
26
|
-
import type { CommitteeAttestationsAndSigners,
|
|
27
|
-
import { getEpochAtSlot
|
|
17
|
+
import type { CommitteeAttestationsAndSigners, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
|
|
18
|
+
import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
|
|
28
19
|
import type {
|
|
29
|
-
CreateCheckpointProposalLastBlockData,
|
|
30
20
|
ITxProvider,
|
|
31
21
|
Validator,
|
|
32
22
|
ValidatorClientFullConfig,
|
|
33
23
|
WorldStateSynchronizer,
|
|
34
24
|
} from '@aztec/stdlib/interfaces/server';
|
|
35
|
-
import {
|
|
25
|
+
import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
|
|
36
26
|
import {
|
|
37
27
|
type BlockProposal,
|
|
38
28
|
type BlockProposalOptions,
|
|
@@ -40,24 +30,30 @@ import {
|
|
|
40
30
|
CheckpointProposal,
|
|
41
31
|
type CheckpointProposalCore,
|
|
42
32
|
type CheckpointProposalOptions,
|
|
33
|
+
type CoordinationSignatureContext,
|
|
43
34
|
} from '@aztec/stdlib/p2p';
|
|
44
35
|
import type { CheckpointHeader } from '@aztec/stdlib/rollup';
|
|
45
|
-
import type { BlockHeader,
|
|
36
|
+
import type { BlockHeader, Tx } from '@aztec/stdlib/tx';
|
|
46
37
|
import { AttestationTimeoutError } from '@aztec/stdlib/validators';
|
|
47
38
|
import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/telemetry-client';
|
|
48
|
-
import {
|
|
49
|
-
|
|
39
|
+
import {
|
|
40
|
+
createHASigner,
|
|
41
|
+
createLocalSignerWithProtection,
|
|
42
|
+
createSignerFromSharedDb,
|
|
43
|
+
} from '@aztec/validator-ha-signer/factory';
|
|
44
|
+
import { DutyType, type SigningContext, type SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types';
|
|
45
|
+
import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer';
|
|
50
46
|
|
|
51
47
|
import { EventEmitter } from 'events';
|
|
52
48
|
import type { TypedDataDefinition } from 'viem';
|
|
53
49
|
|
|
54
|
-
import { BlockProposalHandler, type BlockProposalValidationFailureReason } from './block_proposal_handler.js';
|
|
55
50
|
import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
|
|
56
51
|
import { ValidationService } from './duties/validation_service.js';
|
|
57
52
|
import { HAKeyStore } from './key_store/ha_key_store.js';
|
|
58
53
|
import type { ExtendedValidatorKeyStore } from './key_store/interface.js';
|
|
59
54
|
import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
|
|
60
55
|
import { ValidatorMetrics } from './metrics.js';
|
|
56
|
+
import { type BlockProposalValidationFailureReason, ProposalHandler } from './proposal_handler.js';
|
|
61
57
|
|
|
62
58
|
// We maintain a set of proposers who have proposed invalid blocks.
|
|
63
59
|
// Just cap the set to avoid unbounded growth.
|
|
@@ -77,7 +73,6 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
77
73
|
private validationService: ValidationService;
|
|
78
74
|
private metrics: ValidatorMetrics;
|
|
79
75
|
private log: Logger;
|
|
80
|
-
|
|
81
76
|
// Whether it has already registered handlers on the p2p client
|
|
82
77
|
private hasRegisteredHandlers = false;
|
|
83
78
|
|
|
@@ -89,6 +84,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
89
84
|
|
|
90
85
|
private lastEpochForCommitteeUpdateLoop: EpochNumber | undefined;
|
|
91
86
|
private epochCacheUpdateLoop: RunningPromise;
|
|
87
|
+
/** Tracks the last epoch in which each attester successfully submitted at least one attestation. */
|
|
88
|
+
private lastAttestedEpochByAttester: Map<string, EpochNumber> = new Map();
|
|
92
89
|
|
|
93
90
|
private proposersOfInvalidBlocks: Set<string> = new Set();
|
|
94
91
|
|
|
@@ -99,13 +96,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
99
96
|
private keyStore: ExtendedValidatorKeyStore,
|
|
100
97
|
private epochCache: EpochCache,
|
|
101
98
|
private p2pClient: P2P,
|
|
102
|
-
private
|
|
99
|
+
private proposalHandler: ProposalHandler,
|
|
103
100
|
private blockSource: L2BlockSource,
|
|
104
101
|
private checkpointsBuilder: FullNodeCheckpointsBuilder,
|
|
105
102
|
private worldState: WorldStateSynchronizer,
|
|
106
103
|
private l1ToL2MessageSource: L1ToL2MessageSource,
|
|
107
104
|
private config: ValidatorClientFullConfig,
|
|
108
105
|
private blobClient: BlobClientInterface,
|
|
106
|
+
private slashingProtectionSigner: ValidatorHASigner,
|
|
109
107
|
private dateProvider: DateProvider = new DateProvider(),
|
|
110
108
|
telemetry: TelemetryClient = getTelemetryClient(),
|
|
111
109
|
log = createLogger('validator'),
|
|
@@ -118,7 +116,11 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
118
116
|
this.tracer = telemetry.getTracer('Validator');
|
|
119
117
|
this.metrics = new ValidatorMetrics(telemetry);
|
|
120
118
|
|
|
121
|
-
this.validationService = new ValidationService(
|
|
119
|
+
this.validationService = new ValidationService(
|
|
120
|
+
keyStore,
|
|
121
|
+
this.getSignatureContext(),
|
|
122
|
+
this.log.createChild('validation-service'),
|
|
123
|
+
);
|
|
122
124
|
|
|
123
125
|
// Refresh epoch cache every second to trigger alert if participation in committee changes
|
|
124
126
|
this.epochCacheUpdateLoop = new RunningPromise(this.handleEpochCommitteeUpdate.bind(this), this.log, 1000);
|
|
@@ -159,6 +161,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
159
161
|
this.log.trace(`No committee found for slot`);
|
|
160
162
|
return;
|
|
161
163
|
}
|
|
164
|
+
this.metrics.setCurrentEpoch(epoch);
|
|
162
165
|
if (epoch !== this.lastEpochForCommitteeUpdateLoop) {
|
|
163
166
|
const me = this.getValidatorAddresses();
|
|
164
167
|
const committeeSet = new Set(committee.map(v => v.toString()));
|
|
@@ -192,12 +195,19 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
192
195
|
blobClient: BlobClientInterface,
|
|
193
196
|
dateProvider: DateProvider = new DateProvider(),
|
|
194
197
|
telemetry: TelemetryClient = getTelemetryClient(),
|
|
198
|
+
slashingProtectionDb?: SlashingProtectionDatabase,
|
|
195
199
|
) {
|
|
196
200
|
const metrics = new ValidatorMetrics(telemetry);
|
|
197
201
|
const blockProposalValidator = new BlockProposalValidator(epochCache, {
|
|
198
202
|
txsPermitted: !config.disableTransactions,
|
|
203
|
+
maxTxsPerBlock: config.validateMaxTxsPerBlock,
|
|
204
|
+
maxBlocksPerCheckpoint: config.maxBlocksPerCheckpoint,
|
|
205
|
+
signatureContext: {
|
|
206
|
+
chainId: config.l1ChainId,
|
|
207
|
+
rollupAddress: config.l1Contracts.rollupAddress,
|
|
208
|
+
},
|
|
199
209
|
});
|
|
200
|
-
const
|
|
210
|
+
const proposalHandler = new ProposalHandler(
|
|
201
211
|
checkpointsBuilder,
|
|
202
212
|
worldState,
|
|
203
213
|
blockSource,
|
|
@@ -206,33 +216,54 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
206
216
|
blockProposalValidator,
|
|
207
217
|
epochCache,
|
|
208
218
|
config,
|
|
219
|
+
blobClient,
|
|
209
220
|
metrics,
|
|
210
221
|
dateProvider,
|
|
211
222
|
telemetry,
|
|
223
|
+
undefined,
|
|
212
224
|
);
|
|
213
225
|
|
|
214
|
-
|
|
215
|
-
|
|
226
|
+
const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager);
|
|
227
|
+
let slashingProtectionSigner: ValidatorHASigner;
|
|
228
|
+
if (slashingProtectionDb) {
|
|
229
|
+
// Shared database mode: use a pre-existing database (e.g. for testing HA setups).
|
|
230
|
+
({ signer: slashingProtectionSigner } = createSignerFromSharedDb(slashingProtectionDb, config, {
|
|
231
|
+
telemetryClient: telemetry,
|
|
232
|
+
dateProvider,
|
|
233
|
+
}));
|
|
234
|
+
} else if (config.haSigningEnabled) {
|
|
235
|
+
// Multi-node HA mode: use PostgreSQL-backed distributed locking.
|
|
216
236
|
// If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration
|
|
217
237
|
const haConfig = {
|
|
218
238
|
...config,
|
|
219
239
|
maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000,
|
|
220
240
|
};
|
|
221
|
-
|
|
222
|
-
|
|
241
|
+
({ signer: slashingProtectionSigner } = await createHASigner(haConfig, {
|
|
242
|
+
telemetryClient: telemetry,
|
|
243
|
+
dateProvider,
|
|
244
|
+
}));
|
|
245
|
+
} else {
|
|
246
|
+
// Single-node mode: use LMDB-backed local signing protection.
|
|
247
|
+
// This prevents double-signing if the node crashes and restarts mid-proposal.
|
|
248
|
+
({ signer: slashingProtectionSigner } = await createLocalSignerWithProtection(config, {
|
|
249
|
+
telemetryClient: telemetry,
|
|
250
|
+
dateProvider,
|
|
251
|
+
}));
|
|
223
252
|
}
|
|
253
|
+
const validatorKeyStore: ExtendedValidatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, slashingProtectionSigner);
|
|
224
254
|
|
|
225
255
|
const validator = new ValidatorClient(
|
|
226
256
|
validatorKeyStore,
|
|
227
257
|
epochCache,
|
|
228
258
|
p2pClient,
|
|
229
|
-
|
|
259
|
+
proposalHandler,
|
|
230
260
|
blockSource,
|
|
231
261
|
checkpointsBuilder,
|
|
232
262
|
worldState,
|
|
233
263
|
l1ToL2MessageSource,
|
|
234
264
|
config,
|
|
235
265
|
blobClient,
|
|
266
|
+
slashingProtectionSigner,
|
|
236
267
|
dateProvider,
|
|
237
268
|
telemetry,
|
|
238
269
|
);
|
|
@@ -246,14 +277,21 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
246
277
|
.filter(addr => !this.config.disabledValidators.some(disabled => disabled.equals(addr)));
|
|
247
278
|
}
|
|
248
279
|
|
|
249
|
-
public
|
|
250
|
-
return this.
|
|
280
|
+
public getProposalHandler() {
|
|
281
|
+
return this.proposalHandler;
|
|
251
282
|
}
|
|
252
283
|
|
|
253
284
|
public signWithAddress(addr: EthAddress, msg: TypedDataDefinition, context: SigningContext) {
|
|
254
285
|
return this.keyStore.signTypedDataWithAddress(addr, msg, context);
|
|
255
286
|
}
|
|
256
287
|
|
|
288
|
+
private getSignatureContext(): CoordinationSignatureContext {
|
|
289
|
+
return {
|
|
290
|
+
chainId: this.config.l1ChainId,
|
|
291
|
+
rollupAddress: this.config.l1Contracts.rollupAddress,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
257
295
|
public getCoinbaseForAttestor(attestor: EthAddress): EthAddress {
|
|
258
296
|
return this.keyStore.getCoinbaseAddress(attestor);
|
|
259
297
|
}
|
|
@@ -270,6 +308,16 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
270
308
|
this.config = { ...this.config, ...config };
|
|
271
309
|
}
|
|
272
310
|
|
|
311
|
+
public reloadKeystore(newManager: KeystoreManager): void {
|
|
312
|
+
const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager);
|
|
313
|
+
this.keyStore = new HAKeyStore(newAdapter, this.slashingProtectionSigner);
|
|
314
|
+
this.validationService = new ValidationService(
|
|
315
|
+
this.keyStore,
|
|
316
|
+
this.getSignatureContext(),
|
|
317
|
+
this.log.createChild('validation-service'),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
273
321
|
public async start() {
|
|
274
322
|
if (this.epochCacheUpdateLoop.isRunning()) {
|
|
275
323
|
this.log.warn(`Validator client already started`);
|
|
@@ -314,7 +362,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
314
362
|
checkpoint: CheckpointProposalCore,
|
|
315
363
|
proposalSender: PeerId,
|
|
316
364
|
): Promise<CheckpointAttestation[] | undefined> => this.attestToCheckpointProposal(checkpoint, proposalSender);
|
|
317
|
-
this.p2pClient.
|
|
365
|
+
this.p2pClient.registerValidatorCheckpointProposalHandler(checkpointHandler);
|
|
318
366
|
|
|
319
367
|
// Duplicate proposal handler - triggers slashing for equivocation
|
|
320
368
|
this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => {
|
|
@@ -353,13 +401,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
353
401
|
return false;
|
|
354
402
|
}
|
|
355
403
|
|
|
356
|
-
//
|
|
404
|
+
// Log self-proposals from HA peers (same validator key on different nodes)
|
|
357
405
|
if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
|
|
358
|
-
this.log.
|
|
406
|
+
this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, {
|
|
359
407
|
proposer: proposer.toString(),
|
|
360
408
|
slotNumber,
|
|
361
409
|
});
|
|
362
|
-
return false;
|
|
363
410
|
}
|
|
364
411
|
|
|
365
412
|
// Check if we're in the committee (for metrics purposes)
|
|
@@ -375,25 +422,25 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
375
422
|
|
|
376
423
|
// Reexecute txs if we are part of the committee, or if slashing is enabled, or if we are configured to always reexecute.
|
|
377
424
|
// In fisherman mode, we always reexecute to validate proposals.
|
|
378
|
-
const {
|
|
379
|
-
this.config;
|
|
425
|
+
const { slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } = this.config;
|
|
380
426
|
const shouldReexecute =
|
|
381
427
|
fishermanMode ||
|
|
382
|
-
|
|
383
|
-
|
|
428
|
+
slashBroadcastedInvalidBlockPenalty > 0n ||
|
|
429
|
+
partOfCommittee ||
|
|
384
430
|
alwaysReexecuteBlockProposals ||
|
|
385
431
|
this.blobClient.canUpload();
|
|
386
432
|
|
|
387
|
-
const validationResult = await this.
|
|
433
|
+
const validationResult = await this.proposalHandler.handleBlockProposal(
|
|
388
434
|
proposal,
|
|
389
435
|
proposalSender,
|
|
390
436
|
!!shouldReexecute && !escapeHatchOpen,
|
|
391
437
|
);
|
|
392
438
|
|
|
393
439
|
if (!validationResult.isValid) {
|
|
394
|
-
this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo);
|
|
395
|
-
|
|
396
440
|
const reason = validationResult.reason || 'unknown';
|
|
441
|
+
|
|
442
|
+
this.log.warn(`Block proposal validation failed: ${reason}`, proposalInfo);
|
|
443
|
+
|
|
397
444
|
// Classify failure reason: bad proposal vs node issue
|
|
398
445
|
const badProposalReasons: BlockProposalValidationFailureReason[] = [
|
|
399
446
|
'invalid_proposal',
|
|
@@ -448,68 +495,51 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
448
495
|
proposal: CheckpointProposalCore,
|
|
449
496
|
_proposalSender: PeerId,
|
|
450
497
|
): Promise<CheckpointAttestation[] | undefined> {
|
|
451
|
-
const
|
|
498
|
+
const proposalSlotNumber = proposal.slotNumber;
|
|
452
499
|
const proposer = proposal.getSender();
|
|
453
500
|
|
|
454
501
|
// If escape hatch is open for this slot's epoch, do not attest.
|
|
455
|
-
if (await this.epochCache.isEscapeHatchOpenAtSlot(
|
|
456
|
-
this.log.warn(`Escape hatch open for slot ${
|
|
457
|
-
return undefined;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// Reject proposals with invalid signatures
|
|
461
|
-
if (!proposer) {
|
|
462
|
-
this.log.warn(`Received checkpoint proposal with invalid signature for slot ${slotNumber}`);
|
|
502
|
+
if (await this.epochCache.isEscapeHatchOpenAtSlot(proposalSlotNumber)) {
|
|
503
|
+
this.log.warn(`Escape hatch open for slot ${proposalSlotNumber}, skipping checkpoint attestation handling`);
|
|
463
504
|
return undefined;
|
|
464
505
|
}
|
|
465
506
|
|
|
466
507
|
// Ignore proposals from ourselves (may happen in HA setups)
|
|
467
|
-
if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
|
|
468
|
-
this.log.
|
|
508
|
+
if (proposer && this.getValidatorAddresses().some(addr => addr.equals(proposer))) {
|
|
509
|
+
this.log.debug(`Ignoring block proposal from self for slot ${proposalSlotNumber}`, {
|
|
469
510
|
proposer: proposer.toString(),
|
|
470
|
-
|
|
511
|
+
proposalSlotNumber,
|
|
471
512
|
});
|
|
472
513
|
return undefined;
|
|
473
514
|
}
|
|
474
515
|
|
|
475
|
-
//
|
|
476
|
-
|
|
477
|
-
this.log.warn(
|
|
478
|
-
`Received checkpoint proposal with invalid feeAssetPriceModifier ${proposal.feeAssetPriceModifier} for slot ${slotNumber}`,
|
|
479
|
-
);
|
|
480
|
-
return undefined;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
// Check that I have any address in current committee before attesting
|
|
484
|
-
const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses());
|
|
516
|
+
// Check that I have any address in the committee where this checkpoint will land before attesting
|
|
517
|
+
const inCommittee = await this.epochCache.filterInCommittee(proposalSlotNumber, this.getValidatorAddresses());
|
|
485
518
|
const partOfCommittee = inCommittee.length > 0;
|
|
486
519
|
|
|
487
520
|
const proposalInfo = {
|
|
488
|
-
|
|
521
|
+
proposalSlotNumber,
|
|
489
522
|
archive: proposal.archive.toString(),
|
|
490
|
-
proposer: proposer
|
|
491
|
-
txCount: proposal.txHashes.length,
|
|
523
|
+
proposer: proposer?.toString(),
|
|
492
524
|
};
|
|
493
|
-
this.log.info(`Received checkpoint proposal for slot ${
|
|
525
|
+
this.log.info(`Received checkpoint proposal for slot ${proposalSlotNumber}`, {
|
|
494
526
|
...proposalInfo,
|
|
495
|
-
txHashes: proposal.txHashes.map(t => t.toString()),
|
|
496
527
|
fishermanMode: this.config.fishermanMode || false,
|
|
497
528
|
});
|
|
498
529
|
|
|
499
|
-
// Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set)
|
|
530
|
+
// Validate the checkpoint proposal before attesting (unless skipCheckpointProposalValidation is set).
|
|
531
|
+
// Uses the cached result from the all-nodes callback if available (avoids double validation).
|
|
532
|
+
let checkpointNumber: CheckpointNumber;
|
|
500
533
|
if (this.config.skipCheckpointProposalValidation) {
|
|
501
|
-
this.log.warn(`Skipping checkpoint proposal validation for slot ${
|
|
534
|
+
this.log.warn(`Skipping checkpoint proposal validation for slot ${proposalSlotNumber}`, proposalInfo);
|
|
535
|
+
checkpointNumber = CheckpointNumber(0);
|
|
502
536
|
} else {
|
|
503
|
-
const validationResult = await this.
|
|
537
|
+
const validationResult = await this.proposalHandler.handleCheckpointProposal(proposal, proposalInfo);
|
|
504
538
|
if (!validationResult.isValid) {
|
|
505
539
|
this.log.warn(`Checkpoint proposal validation failed: ${validationResult.reason}`, proposalInfo);
|
|
506
540
|
return undefined;
|
|
507
541
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
// Upload blobs to filestore if we can (fire and forget)
|
|
511
|
-
if (this.blobClient.canUpload()) {
|
|
512
|
-
void this.uploadBlobsForCheckpoint(proposal, proposalInfo);
|
|
542
|
+
checkpointNumber = validationResult.checkpointNumber;
|
|
513
543
|
}
|
|
514
544
|
|
|
515
545
|
// Check that I have any address in current committee before attesting
|
|
@@ -520,14 +550,28 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
520
550
|
}
|
|
521
551
|
|
|
522
552
|
// Provided all of the above checks pass, we can attest to the proposal
|
|
523
|
-
this.log.info(
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
553
|
+
this.log.info(
|
|
554
|
+
`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${proposalSlotNumber}`,
|
|
555
|
+
{
|
|
556
|
+
...proposalInfo,
|
|
557
|
+
inCommittee: partOfCommittee,
|
|
558
|
+
fishermanMode: this.config.fishermanMode || false,
|
|
559
|
+
},
|
|
560
|
+
);
|
|
528
561
|
|
|
529
562
|
this.metrics.incSuccessfulAttestations(inCommittee.length);
|
|
530
563
|
|
|
564
|
+
// Track epoch participation per attester: count each (attester, epoch) pair at most once
|
|
565
|
+
const proposalEpoch = getEpochAtSlot(proposalSlotNumber, this.epochCache.getL1Constants());
|
|
566
|
+
for (const attester of inCommittee) {
|
|
567
|
+
const key = attester.toString();
|
|
568
|
+
const lastEpoch = this.lastAttestedEpochByAttester.get(key);
|
|
569
|
+
if (lastEpoch === undefined || proposalEpoch > lastEpoch) {
|
|
570
|
+
this.lastAttestedEpochByAttester.set(key, proposalEpoch);
|
|
571
|
+
this.metrics.incAttestedEpochCount(attester);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
531
575
|
// Determine which validators should attest
|
|
532
576
|
let attestors: EthAddress[];
|
|
533
577
|
if (partOfCommittee) {
|
|
@@ -546,14 +590,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
546
590
|
|
|
547
591
|
if (this.config.fishermanMode) {
|
|
548
592
|
// bail out early and don't save attestations to the pool in fisherman mode
|
|
549
|
-
this.log.info(`Creating checkpoint attestations for slot ${
|
|
593
|
+
this.log.info(`Creating checkpoint attestations for slot ${proposalSlotNumber}`, {
|
|
550
594
|
...proposalInfo,
|
|
551
595
|
attestors: attestors.map(a => a.toString()),
|
|
552
596
|
});
|
|
553
597
|
return undefined;
|
|
554
598
|
}
|
|
555
599
|
|
|
556
|
-
return await this.createCheckpointAttestationsFromProposal(proposal, attestors);
|
|
600
|
+
return await this.createCheckpointAttestationsFromProposal(proposal, attestors, checkpointNumber);
|
|
557
601
|
}
|
|
558
602
|
|
|
559
603
|
/**
|
|
@@ -580,13 +624,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
580
624
|
private async createCheckpointAttestationsFromProposal(
|
|
581
625
|
proposal: CheckpointProposalCore,
|
|
582
626
|
attestors: EthAddress[] = [],
|
|
627
|
+
checkpointNumber: CheckpointNumber,
|
|
583
628
|
): Promise<CheckpointAttestation[] | undefined> {
|
|
584
629
|
// Equivocation check: must happen right before signing to minimize the race window
|
|
585
630
|
if (!this.shouldAttestToSlot(proposal.slotNumber)) {
|
|
586
631
|
return undefined;
|
|
587
632
|
}
|
|
588
633
|
|
|
589
|
-
const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors);
|
|
634
|
+
const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors, checkpointNumber);
|
|
590
635
|
|
|
591
636
|
// Track the proposal we attested to (to prevent equivocation)
|
|
592
637
|
this.lastAttestedProposal = proposal;
|
|
@@ -595,158 +640,10 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
595
640
|
return attestations;
|
|
596
641
|
}
|
|
597
642
|
|
|
598
|
-
/**
|
|
599
|
-
* Validates a checkpoint proposal by building the full checkpoint and comparing it with the proposal.
|
|
600
|
-
* @returns Validation result with isValid flag and reason if invalid.
|
|
601
|
-
*/
|
|
602
|
-
private async validateCheckpointProposal(
|
|
603
|
-
proposal: CheckpointProposalCore,
|
|
604
|
-
proposalInfo: LogData,
|
|
605
|
-
): Promise<{ isValid: true } | { isValid: false; reason: string }> {
|
|
606
|
-
const slot = proposal.slotNumber;
|
|
607
|
-
|
|
608
|
-
// Timeout block syncing at the start of the next slot
|
|
609
|
-
const config = this.checkpointsBuilder.getConfig();
|
|
610
|
-
const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(slot + 1), config));
|
|
611
|
-
const timeoutSeconds = Math.max(1, nextSlotTimestampSeconds - Math.floor(this.dateProvider.now() / 1000));
|
|
612
|
-
|
|
613
|
-
// Wait for last block to sync by archive
|
|
614
|
-
let lastBlockHeader: BlockHeader | undefined;
|
|
615
|
-
try {
|
|
616
|
-
lastBlockHeader = await retryUntil(
|
|
617
|
-
async () => {
|
|
618
|
-
await this.blockSource.syncImmediate();
|
|
619
|
-
return this.blockSource.getBlockHeaderByArchive(proposal.archive);
|
|
620
|
-
},
|
|
621
|
-
`waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
|
|
622
|
-
timeoutSeconds,
|
|
623
|
-
0.5,
|
|
624
|
-
);
|
|
625
|
-
} catch (err) {
|
|
626
|
-
if (err instanceof TimeoutError) {
|
|
627
|
-
this.log.warn(`Timed out waiting for block with archive matching checkpoint proposal`, proposalInfo);
|
|
628
|
-
return { isValid: false, reason: 'last_block_not_found' };
|
|
629
|
-
}
|
|
630
|
-
this.log.error(`Error fetching last block for checkpoint proposal`, err, proposalInfo);
|
|
631
|
-
return { isValid: false, reason: 'block_fetch_error' };
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
if (!lastBlockHeader) {
|
|
635
|
-
this.log.warn(`Last block not found for checkpoint proposal`, proposalInfo);
|
|
636
|
-
return { isValid: false, reason: 'last_block_not_found' };
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// Get all full blocks for the slot and checkpoint
|
|
640
|
-
const blocks = await this.blockSource.getBlocksForSlot(slot);
|
|
641
|
-
if (blocks.length === 0) {
|
|
642
|
-
this.log.warn(`No blocks found for slot ${slot}`, proposalInfo);
|
|
643
|
-
return { isValid: false, reason: 'no_blocks_for_slot' };
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
this.log.debug(`Found ${blocks.length} blocks for slot ${slot}`, {
|
|
647
|
-
...proposalInfo,
|
|
648
|
-
blockNumbers: blocks.map(b => b.number),
|
|
649
|
-
});
|
|
650
|
-
|
|
651
|
-
// Get checkpoint constants from first block
|
|
652
|
-
const firstBlock = blocks[0];
|
|
653
|
-
const constants = this.extractCheckpointConstants(firstBlock);
|
|
654
|
-
const checkpointNumber = firstBlock.checkpointNumber;
|
|
655
|
-
|
|
656
|
-
// Get L1-to-L2 messages for this checkpoint
|
|
657
|
-
const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
|
|
658
|
-
|
|
659
|
-
// Compute the previous checkpoint out hashes for the epoch.
|
|
660
|
-
// TODO: There can be a more efficient way to get the previous checkpoint out hashes without having to fetch the
|
|
661
|
-
// actual checkpoints and the blocks/txs in them.
|
|
662
|
-
const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants());
|
|
663
|
-
const previousCheckpoints = (await this.blockSource.getCheckpointsForEpoch(epoch))
|
|
664
|
-
.filter(b => b.number < checkpointNumber)
|
|
665
|
-
.sort((a, b) => a.number - b.number);
|
|
666
|
-
const previousCheckpointOutHashes = previousCheckpoints.map(c => c.getCheckpointOutHash());
|
|
667
|
-
|
|
668
|
-
// Fork world state at the block before the first block
|
|
669
|
-
const parentBlockNumber = BlockNumber(firstBlock.number - 1);
|
|
670
|
-
const fork = await this.worldState.fork(parentBlockNumber);
|
|
671
|
-
|
|
672
|
-
try {
|
|
673
|
-
// Create checkpoint builder with all existing blocks
|
|
674
|
-
const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
|
|
675
|
-
checkpointNumber,
|
|
676
|
-
constants,
|
|
677
|
-
proposal.feeAssetPriceModifier,
|
|
678
|
-
l1ToL2Messages,
|
|
679
|
-
previousCheckpointOutHashes,
|
|
680
|
-
fork,
|
|
681
|
-
blocks,
|
|
682
|
-
this.log.getBindings(),
|
|
683
|
-
);
|
|
684
|
-
|
|
685
|
-
// Complete the checkpoint to get computed values
|
|
686
|
-
const computedCheckpoint = await checkpointBuilder.completeCheckpoint();
|
|
687
|
-
|
|
688
|
-
// Compare checkpoint header with proposal
|
|
689
|
-
if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
|
|
690
|
-
this.log.warn(`Checkpoint header mismatch`, {
|
|
691
|
-
...proposalInfo,
|
|
692
|
-
computed: computedCheckpoint.header.toInspect(),
|
|
693
|
-
proposal: proposal.checkpointHeader.toInspect(),
|
|
694
|
-
});
|
|
695
|
-
return { isValid: false, reason: 'checkpoint_header_mismatch' };
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
// Compare archive root with proposal
|
|
699
|
-
if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
|
|
700
|
-
this.log.warn(`Archive root mismatch`, {
|
|
701
|
-
...proposalInfo,
|
|
702
|
-
computed: computedCheckpoint.archive.root.toString(),
|
|
703
|
-
proposal: proposal.archive.toString(),
|
|
704
|
-
});
|
|
705
|
-
return { isValid: false, reason: 'archive_mismatch' };
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// Check that the accumulated epoch out hash matches the value in the proposal.
|
|
709
|
-
// The epoch out hash is the accumulated hash of all checkpoint out hashes in the epoch.
|
|
710
|
-
const checkpointOutHash = computedCheckpoint.getCheckpointOutHash();
|
|
711
|
-
const computedEpochOutHash = accumulateCheckpointOutHashes([...previousCheckpointOutHashes, checkpointOutHash]);
|
|
712
|
-
const proposalEpochOutHash = proposal.checkpointHeader.epochOutHash;
|
|
713
|
-
if (!computedEpochOutHash.equals(proposalEpochOutHash)) {
|
|
714
|
-
this.log.warn(`Epoch out hash mismatch`, {
|
|
715
|
-
proposalEpochOutHash: proposalEpochOutHash.toString(),
|
|
716
|
-
computedEpochOutHash: computedEpochOutHash.toString(),
|
|
717
|
-
checkpointOutHash: checkpointOutHash.toString(),
|
|
718
|
-
previousCheckpointOutHashes: previousCheckpointOutHashes.map(h => h.toString()),
|
|
719
|
-
...proposalInfo,
|
|
720
|
-
});
|
|
721
|
-
return { isValid: false, reason: 'out_hash_mismatch' };
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
|
|
725
|
-
return { isValid: true };
|
|
726
|
-
} finally {
|
|
727
|
-
await fork.close();
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
/**
|
|
732
|
-
* Extract checkpoint global variables from a block.
|
|
733
|
-
*/
|
|
734
|
-
private extractCheckpointConstants(block: L2Block): CheckpointGlobalVariables {
|
|
735
|
-
const gv = block.header.globalVariables;
|
|
736
|
-
return {
|
|
737
|
-
chainId: gv.chainId,
|
|
738
|
-
version: gv.version,
|
|
739
|
-
slotNumber: gv.slotNumber,
|
|
740
|
-
coinbase: gv.coinbase,
|
|
741
|
-
feeRecipient: gv.feeRecipient,
|
|
742
|
-
gasFees: gv.gasFees,
|
|
743
|
-
};
|
|
744
|
-
}
|
|
745
|
-
|
|
746
643
|
/**
|
|
747
644
|
* Uploads blobs for a checkpoint to the filestore (fire and forget).
|
|
748
645
|
*/
|
|
749
|
-
|
|
646
|
+
protected async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
|
|
750
647
|
try {
|
|
751
648
|
const lastBlockHeader = await this.blockSource.getBlockHeaderByArchive(proposal.archive);
|
|
752
649
|
if (!lastBlockHeader) {
|
|
@@ -761,7 +658,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
761
658
|
}
|
|
762
659
|
|
|
763
660
|
const blobFields = blocks.flatMap(b => b.toBlobFields());
|
|
764
|
-
const blobs: Blob[] = getBlobsPerL1Block(blobFields);
|
|
661
|
+
const blobs: Blob[] = await getBlobsPerL1Block(blobFields);
|
|
765
662
|
await this.blobClient.sendBlobsToFilestore(blobs);
|
|
766
663
|
this.log.debug(`Uploaded ${blobs.length} blobs to filestore for checkpoint at slot ${proposal.slotNumber}`, {
|
|
767
664
|
...proposalInfo,
|
|
@@ -847,6 +744,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
847
744
|
|
|
848
745
|
async createBlockProposal(
|
|
849
746
|
blockHeader: BlockHeader,
|
|
747
|
+
checkpointNumber: CheckpointNumber,
|
|
850
748
|
indexWithinCheckpoint: IndexWithinCheckpoint,
|
|
851
749
|
inHash: Fr,
|
|
852
750
|
archive: Fr,
|
|
@@ -873,6 +771,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
873
771
|
);
|
|
874
772
|
const newProposal = await this.validationService.createBlockProposal(
|
|
875
773
|
blockHeader,
|
|
774
|
+
checkpointNumber,
|
|
876
775
|
indexWithinCheckpoint,
|
|
877
776
|
inHash,
|
|
878
777
|
archive,
|
|
@@ -890,8 +789,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
890
789
|
async createCheckpointProposal(
|
|
891
790
|
checkpointHeader: CheckpointHeader,
|
|
892
791
|
archive: Fr,
|
|
792
|
+
checkpointNumber: CheckpointNumber,
|
|
893
793
|
feeAssetPriceModifier: bigint,
|
|
894
|
-
|
|
794
|
+
lastBlockProposal: BlockProposal | undefined,
|
|
895
795
|
proposerAddress: EthAddress | undefined,
|
|
896
796
|
options: CheckpointProposalOptions = {},
|
|
897
797
|
): Promise<CheckpointProposal> {
|
|
@@ -912,8 +812,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
912
812
|
const newProposal = await this.validationService.createCheckpointProposal(
|
|
913
813
|
checkpointHeader,
|
|
914
814
|
archive,
|
|
815
|
+
checkpointNumber,
|
|
915
816
|
feeAssetPriceModifier,
|
|
916
|
-
|
|
817
|
+
lastBlockProposal,
|
|
917
818
|
proposerAddress,
|
|
918
819
|
options,
|
|
919
820
|
);
|
|
@@ -929,16 +830,24 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
929
830
|
attestationsAndSigners: CommitteeAttestationsAndSigners,
|
|
930
831
|
proposer: EthAddress,
|
|
931
832
|
slot: SlotNumber,
|
|
932
|
-
|
|
833
|
+
checkpointNumber: CheckpointNumber,
|
|
933
834
|
): Promise<Signature> {
|
|
934
|
-
return await this.validationService.signAttestationsAndSigners(
|
|
835
|
+
return await this.validationService.signAttestationsAndSigners(
|
|
836
|
+
attestationsAndSigners,
|
|
837
|
+
proposer,
|
|
838
|
+
slot,
|
|
839
|
+
checkpointNumber,
|
|
840
|
+
);
|
|
935
841
|
}
|
|
936
842
|
|
|
937
|
-
async collectOwnAttestations(
|
|
843
|
+
async collectOwnAttestations(
|
|
844
|
+
proposal: CheckpointProposal,
|
|
845
|
+
checkpointNumber: CheckpointNumber,
|
|
846
|
+
): Promise<CheckpointAttestation[]> {
|
|
938
847
|
const slot = proposal.slotNumber;
|
|
939
848
|
const inCommittee = await this.epochCache.filterInCommittee(slot, this.getValidatorAddresses());
|
|
940
849
|
this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
|
|
941
|
-
const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee);
|
|
850
|
+
const attestations = await this.createCheckpointAttestationsFromProposal(proposal, inCommittee, checkpointNumber);
|
|
942
851
|
|
|
943
852
|
if (!attestations) {
|
|
944
853
|
return [];
|
|
@@ -957,6 +866,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
957
866
|
proposal: CheckpointProposal,
|
|
958
867
|
required: number,
|
|
959
868
|
deadline: Date,
|
|
869
|
+
checkpointNumber: CheckpointNumber,
|
|
960
870
|
): Promise<CheckpointAttestation[]> {
|
|
961
871
|
// Wait and poll the p2pClient's attestation pool for this checkpoint until we have enough attestations
|
|
962
872
|
const slot = proposal.slotNumber;
|
|
@@ -969,7 +879,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
969
879
|
throw new AttestationTimeoutError(0, required, slot);
|
|
970
880
|
}
|
|
971
881
|
|
|
972
|
-
await this.collectOwnAttestations(proposal);
|
|
882
|
+
await this.collectOwnAttestations(proposal, checkpointNumber);
|
|
973
883
|
|
|
974
884
|
const proposalId = proposal.archive.toString();
|
|
975
885
|
const myAddresses = this.getValidatorAddresses();
|
|
@@ -982,7 +892,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
|
|
|
982
892
|
attestation => {
|
|
983
893
|
if (!attestation.archive.equals(proposal.archive)) {
|
|
984
894
|
this.log.warn(
|
|
985
|
-
`Received attestation for slot ${slot} with mismatched archive from ${attestation
|
|
895
|
+
`Received attestation for slot ${slot} with mismatched archive from ${attestation
|
|
896
|
+
.getSender()
|
|
897
|
+
?.toString()}`,
|
|
986
898
|
{ attestationArchive: attestation.archive.toString(), proposalArchive: proposal.archive.toString() },
|
|
987
899
|
);
|
|
988
900
|
return false;
|