@aztec/validator-client 0.75.0-commit.c03ba01a2a4122e43e90d5133ba017e54b90e9d2
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/dest/config.js +31 -0
- package/dest/duties/validation_service.js +35 -0
- package/dest/errors/index.js +1 -0
- package/dest/errors/validator.error.js +45 -0
- package/dest/factory.js +11 -0
- package/dest/index.js +3 -0
- package/dest/key_store/index.js +2 -0
- package/dest/key_store/interface.js +4 -0
- package/dest/key_store/local_key_store.js +32 -0
- package/dest/metrics.js +35 -0
- package/dest/validator.js +246 -0
- package/package.json +96 -0
- package/src/config.ts +56 -0
- package/src/duties/validation_service.ts +51 -0
- package/src/errors/index.ts +1 -0
- package/src/errors/validator.error.ts +55 -0
- package/src/factory.ts +28 -0
- package/src/index.ts +3 -0
- package/src/key_store/index.ts +2 -0
- package/src/key_store/interface.ts +26 -0
- package/src/key_store/local_key_store.ts +46 -0
- package/src/metrics.ts +50 -0
- package/src/validator.ts +361 -0
package/dest/config.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NULL_KEY } from '@aztec/ethereum';
|
|
2
|
+
import { booleanConfigHelper, getConfigFromMappings, numberConfigHelper } from '@aztec/foundation/config';
|
|
3
|
+
export const validatorClientConfigMappings = {
|
|
4
|
+
validatorPrivateKey: {
|
|
5
|
+
env: 'VALIDATOR_PRIVATE_KEY',
|
|
6
|
+
parseEnv: (val)=>val ? `0x${val.replace('0x', '')}` : NULL_KEY,
|
|
7
|
+
description: 'The private key of the validator participating in attestation duties'
|
|
8
|
+
},
|
|
9
|
+
disableValidator: {
|
|
10
|
+
env: 'VALIDATOR_DISABLED',
|
|
11
|
+
description: 'Do not run the validator',
|
|
12
|
+
...booleanConfigHelper()
|
|
13
|
+
},
|
|
14
|
+
attestationPollingIntervalMs: {
|
|
15
|
+
env: 'VALIDATOR_ATTESTATIONS_POLLING_INTERVAL_MS',
|
|
16
|
+
description: 'Interval between polling for new attestations',
|
|
17
|
+
...numberConfigHelper(200)
|
|
18
|
+
},
|
|
19
|
+
validatorReexecute: {
|
|
20
|
+
env: 'VALIDATOR_REEXECUTE',
|
|
21
|
+
description: 'Re-execute transactions before attesting',
|
|
22
|
+
...booleanConfigHelper(true)
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Returns the prover configuration from the environment variables.
|
|
27
|
+
* Note: If an environment variable is not set, the default value is used.
|
|
28
|
+
* @returns The validator configuration.
|
|
29
|
+
*/ export function getProverEnvVars() {
|
|
30
|
+
return getConfigFromMappings(validatorClientConfigMappings);
|
|
31
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { BlockAttestation, BlockProposal, ConsensusPayload, SignatureDomainSeparator } from '@aztec/circuit-types';
|
|
2
|
+
import { Buffer32 } from '@aztec/foundation/buffer';
|
|
3
|
+
import { keccak256 } from '@aztec/foundation/crypto';
|
|
4
|
+
export class ValidationService {
|
|
5
|
+
keyStore;
|
|
6
|
+
constructor(keyStore){
|
|
7
|
+
this.keyStore = keyStore;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Create a block proposal with the given header, archive, and transactions
|
|
11
|
+
*
|
|
12
|
+
* @param header - The block header
|
|
13
|
+
* @param archive - The archive of the current block
|
|
14
|
+
* @param txs - TxHash[] ordered list of transactions
|
|
15
|
+
*
|
|
16
|
+
* @returns A block proposal signing the above information (not the current implementation!!!)
|
|
17
|
+
*/ createBlockProposal(header, archive, txs) {
|
|
18
|
+
const payloadSigner = (payload)=>this.keyStore.signMessage(payload);
|
|
19
|
+
return BlockProposal.createProposalFromSigner(new ConsensusPayload(header, archive, txs), payloadSigner);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Attest to the given block proposal constructed by the current sequencer
|
|
23
|
+
*
|
|
24
|
+
* NOTE: This is just a blind signing.
|
|
25
|
+
* We assume that the proposal is valid and DA guarantees have been checked previously.
|
|
26
|
+
*
|
|
27
|
+
* @param proposal - The proposal to attest to
|
|
28
|
+
* @returns attestation
|
|
29
|
+
*/ async attestToProposal(proposal) {
|
|
30
|
+
// TODO(https://github.com/AztecProtocol/aztec-packages/issues/7961): check that the current validator is correct
|
|
31
|
+
const buf = Buffer32.fromBuffer(keccak256(await proposal.payload.getPayloadToSign(SignatureDomainSeparator.blockAttestation)));
|
|
32
|
+
const sig = await this.keyStore.signMessage(buf);
|
|
33
|
+
return new BlockAttestation(proposal.payload, sig);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './validator.error.js';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export class ValidatorError extends Error {
|
|
2
|
+
constructor(message){
|
|
3
|
+
super(`Validator Error: ${message}`);
|
|
4
|
+
}
|
|
5
|
+
}
|
|
6
|
+
export class InvalidValidatorPrivateKeyError extends ValidatorError {
|
|
7
|
+
constructor(){
|
|
8
|
+
super('Invalid validator private key provided');
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class AttestationTimeoutError extends ValidatorError {
|
|
12
|
+
constructor(numberOfRequiredAttestations, slot){
|
|
13
|
+
super(`Timeout waiting for ${numberOfRequiredAttestations} attestations for slot, ${slot}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class TransactionsNotAvailableError extends ValidatorError {
|
|
17
|
+
constructor(txHashes){
|
|
18
|
+
super(`Transactions not available: ${txHashes.join(', ')}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class FailedToReExecuteTransactionsError extends ValidatorError {
|
|
22
|
+
constructor(txHashes){
|
|
23
|
+
super(`Failed to re-execute transactions: ${txHashes.join(', ')}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class ReExStateMismatchError extends ValidatorError {
|
|
27
|
+
constructor(){
|
|
28
|
+
super('Re-execution state mismatch');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class ReExFailedTxsError extends ValidatorError {
|
|
32
|
+
constructor(numFailedTxs){
|
|
33
|
+
super(`Re-execution failed to process ${numFailedTxs} txs`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export class ReExTimeoutError extends ValidatorError {
|
|
37
|
+
constructor(){
|
|
38
|
+
super('Re-execution timed out or failed to process all txs in the proposal');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export class BlockBuilderNotProvidedError extends ValidatorError {
|
|
42
|
+
constructor(){
|
|
43
|
+
super('Block builder not provided');
|
|
44
|
+
}
|
|
45
|
+
}
|
package/dest/factory.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { generatePrivateKey } from 'viem/accounts';
|
|
2
|
+
import { ValidatorClient } from './validator.js';
|
|
3
|
+
export function createValidatorClient(config, deps) {
|
|
4
|
+
if (config.disableValidator) {
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
if (config.validatorPrivateKey === undefined || config.validatorPrivateKey === '') {
|
|
8
|
+
config.validatorPrivateKey = generatePrivateKey();
|
|
9
|
+
}
|
|
10
|
+
return ValidatorClient.new(config, deps.epochCache, deps.p2pClient, deps.dateProvider, deps.telemetry);
|
|
11
|
+
}
|
package/dest/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Secp256k1Signer } from '@aztec/foundation/crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Local Key Store
|
|
4
|
+
*
|
|
5
|
+
* An implementation of the Key store using an in memory private key.
|
|
6
|
+
*/ export class LocalKeyStore {
|
|
7
|
+
signer;
|
|
8
|
+
constructor(privateKey){
|
|
9
|
+
this.signer = new Secp256k1Signer(privateKey);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Get the address of the signer
|
|
13
|
+
*
|
|
14
|
+
* @returns the address
|
|
15
|
+
*/ getAddress() {
|
|
16
|
+
return this.signer.address;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Sign a message with the keystore private key
|
|
20
|
+
*
|
|
21
|
+
* @param messageBuffer - The message buffer to sign
|
|
22
|
+
* @return signature
|
|
23
|
+
*/ sign(digest) {
|
|
24
|
+
const signature = this.signer.sign(digest);
|
|
25
|
+
return Promise.resolve(signature);
|
|
26
|
+
}
|
|
27
|
+
signMessage(message) {
|
|
28
|
+
// Sign message adds eth sign prefix and hashes before signing
|
|
29
|
+
const signature = this.signer.signMessage(message);
|
|
30
|
+
return Promise.resolve(signature);
|
|
31
|
+
}
|
|
32
|
+
}
|
package/dest/metrics.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Attributes, Metrics, ValueType } from '@aztec/telemetry-client';
|
|
2
|
+
export class ValidatorMetrics {
|
|
3
|
+
reExecutionTime;
|
|
4
|
+
failedReexecutionCounter;
|
|
5
|
+
constructor(telemetryClient){
|
|
6
|
+
const meter = telemetryClient.getMeter('Validator');
|
|
7
|
+
this.failedReexecutionCounter = meter.createUpDownCounter(Metrics.VALIDATOR_FAILED_REEXECUTION_COUNT, {
|
|
8
|
+
description: 'The number of failed re-executions',
|
|
9
|
+
unit: 'count',
|
|
10
|
+
valueType: ValueType.INT
|
|
11
|
+
});
|
|
12
|
+
this.reExecutionTime = meter.createGauge(Metrics.VALIDATOR_RE_EXECUTION_TIME, {
|
|
13
|
+
description: 'The time taken to re-execute a transaction',
|
|
14
|
+
unit: 'ms',
|
|
15
|
+
valueType: ValueType.DOUBLE
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
reExecutionTimer() {
|
|
19
|
+
const start = performance.now();
|
|
20
|
+
return ()=>{
|
|
21
|
+
const end = performance.now();
|
|
22
|
+
this.recordReExecutionTime(end - start);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
recordReExecutionTime(time) {
|
|
26
|
+
this.reExecutionTime.record(time);
|
|
27
|
+
}
|
|
28
|
+
async recordFailedReexecution(proposal) {
|
|
29
|
+
this.failedReexecutionCounter.add(1, {
|
|
30
|
+
[Attributes.STATUS]: 'failed',
|
|
31
|
+
[Attributes.BLOCK_NUMBER]: proposal.payload.header.globalVariables.blockNumber.toString(),
|
|
32
|
+
[Attributes.BLOCK_PROPOSER]: (await proposal.getSender())?.toString()
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { Buffer32 } from '@aztec/foundation/buffer';
|
|
2
|
+
import { createLogger } from '@aztec/foundation/log';
|
|
3
|
+
import { RunningPromise } from '@aztec/foundation/running-promise';
|
|
4
|
+
import { sleep } from '@aztec/foundation/sleep';
|
|
5
|
+
import { DateProvider } from '@aztec/foundation/timer';
|
|
6
|
+
import { BlockProposalValidator } from '@aztec/p2p/msg_validators';
|
|
7
|
+
import { WithTracer, getTelemetryClient } from '@aztec/telemetry-client';
|
|
8
|
+
import { ValidationService } from './duties/validation_service.js';
|
|
9
|
+
import { AttestationTimeoutError, BlockBuilderNotProvidedError, InvalidValidatorPrivateKeyError, ReExFailedTxsError, ReExStateMismatchError, ReExTimeoutError, TransactionsNotAvailableError } from './errors/validator.error.js';
|
|
10
|
+
import { LocalKeyStore } from './key_store/local_key_store.js';
|
|
11
|
+
import { ValidatorMetrics } from './metrics.js';
|
|
12
|
+
/**
|
|
13
|
+
* Validator Client
|
|
14
|
+
*/ export class ValidatorClient extends WithTracer {
|
|
15
|
+
keyStore;
|
|
16
|
+
epochCache;
|
|
17
|
+
p2pClient;
|
|
18
|
+
config;
|
|
19
|
+
dateProvider;
|
|
20
|
+
log;
|
|
21
|
+
validationService;
|
|
22
|
+
metrics;
|
|
23
|
+
// Used to check if we are sending the same proposal twice
|
|
24
|
+
previousProposal;
|
|
25
|
+
// Callback registered to: sequencer.buildBlock
|
|
26
|
+
blockBuilder;
|
|
27
|
+
epochCacheUpdateLoop;
|
|
28
|
+
blockProposalValidator;
|
|
29
|
+
constructor(keyStore, epochCache, p2pClient, config, dateProvider = new DateProvider(), telemetry = getTelemetryClient(), log = createLogger('validator')){
|
|
30
|
+
// Instantiate tracer
|
|
31
|
+
super(telemetry, 'Validator'), this.keyStore = keyStore, this.epochCache = epochCache, this.p2pClient = p2pClient, this.config = config, this.dateProvider = dateProvider, this.log = log, this.blockBuilder = undefined;
|
|
32
|
+
this.metrics = new ValidatorMetrics(telemetry);
|
|
33
|
+
this.validationService = new ValidationService(keyStore);
|
|
34
|
+
this.blockProposalValidator = new BlockProposalValidator(epochCache);
|
|
35
|
+
// Refresh epoch cache every second to trigger commiteeChanged event
|
|
36
|
+
this.epochCacheUpdateLoop = new RunningPromise(()=>this.epochCache.getCommittee().then(()=>{}).catch((err)=>log.error('Error updating validator committee', err)), log, 1000);
|
|
37
|
+
// Listen to commiteeChanged event to alert operator when their validator has entered the committee
|
|
38
|
+
this.epochCache.on('committeeChanged', (newCommittee, epochNumber)=>{
|
|
39
|
+
const me = this.keyStore.getAddress();
|
|
40
|
+
if (newCommittee.some((addr)=>addr.equals(me))) {
|
|
41
|
+
this.log.info(`Validator ${me.toString()} is on the validator committee for epoch ${epochNumber}`);
|
|
42
|
+
} else {
|
|
43
|
+
this.log.verbose(`Validator ${me.toString()} not on the validator committee for epoch ${epochNumber}`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
this.log.verbose(`Initialized validator with address ${this.keyStore.getAddress().toString()}`);
|
|
47
|
+
}
|
|
48
|
+
static new(config, epochCache, p2pClient, dateProvider = new DateProvider(), telemetry = getTelemetryClient()) {
|
|
49
|
+
if (!config.validatorPrivateKey) {
|
|
50
|
+
throw new InvalidValidatorPrivateKeyError();
|
|
51
|
+
}
|
|
52
|
+
const privateKey = validatePrivateKey(config.validatorPrivateKey);
|
|
53
|
+
const localKeyStore = new LocalKeyStore(privateKey);
|
|
54
|
+
const validator = new ValidatorClient(localKeyStore, epochCache, p2pClient, config, dateProvider, telemetry);
|
|
55
|
+
validator.registerBlockProposalHandler();
|
|
56
|
+
return validator;
|
|
57
|
+
}
|
|
58
|
+
async start() {
|
|
59
|
+
// Sync the committee from the smart contract
|
|
60
|
+
// https://github.com/AztecProtocol/aztec-packages/issues/7962
|
|
61
|
+
const me = this.keyStore.getAddress();
|
|
62
|
+
const inCommittee = await this.epochCache.isInCommittee(me);
|
|
63
|
+
if (inCommittee) {
|
|
64
|
+
this.log.info(`Started validator with address ${me.toString()} in current validator committee`);
|
|
65
|
+
} else {
|
|
66
|
+
this.log.info(`Started validator with address ${me.toString()}`);
|
|
67
|
+
}
|
|
68
|
+
this.epochCacheUpdateLoop.start();
|
|
69
|
+
return Promise.resolve();
|
|
70
|
+
}
|
|
71
|
+
async stop() {
|
|
72
|
+
await this.epochCacheUpdateLoop.stop();
|
|
73
|
+
}
|
|
74
|
+
registerBlockProposalHandler() {
|
|
75
|
+
const handler = (block)=>{
|
|
76
|
+
return this.attestToProposal(block);
|
|
77
|
+
};
|
|
78
|
+
this.p2pClient.registerBlockProposalHandler(handler);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Register a callback function for building a block
|
|
82
|
+
*
|
|
83
|
+
* We reuse the sequencer's block building functionality for re-execution
|
|
84
|
+
*/ registerBlockBuilder(blockBuilder) {
|
|
85
|
+
this.blockBuilder = blockBuilder;
|
|
86
|
+
}
|
|
87
|
+
async attestToProposal(proposal) {
|
|
88
|
+
const slotNumber = proposal.slotNumber.toNumber();
|
|
89
|
+
const proposalInfo = {
|
|
90
|
+
slotNumber,
|
|
91
|
+
blockNumber: proposal.payload.header.globalVariables.blockNumber.toNumber(),
|
|
92
|
+
archive: proposal.payload.archive.toString(),
|
|
93
|
+
txCount: proposal.payload.txHashes.length,
|
|
94
|
+
txHashes: proposal.payload.txHashes.map((txHash)=>txHash.toString())
|
|
95
|
+
};
|
|
96
|
+
this.log.verbose(`Received request to attest for slot ${slotNumber}`);
|
|
97
|
+
// Check that I am in the committee
|
|
98
|
+
if (!await this.epochCache.isInCommittee(this.keyStore.getAddress())) {
|
|
99
|
+
this.log.verbose(`Not in the committee, skipping attestation`);
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
// Check that the proposal is from the current proposer, or the next proposer.
|
|
103
|
+
const invalidProposal = await this.blockProposalValidator.validate(proposal);
|
|
104
|
+
if (invalidProposal) {
|
|
105
|
+
this.log.verbose(`Proposal is not valid, skipping attestation`);
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
// Check that all of the tranasctions in the proposal are available in the tx pool before attesting
|
|
109
|
+
this.log.verbose(`Processing attestation for slot ${slotNumber}`, proposalInfo);
|
|
110
|
+
try {
|
|
111
|
+
await this.ensureTransactionsAreAvailable(proposal);
|
|
112
|
+
if (this.config.validatorReexecute) {
|
|
113
|
+
this.log.verbose(`Re-executing transactions in the proposal before attesting`);
|
|
114
|
+
await this.reExecuteTransactions(proposal);
|
|
115
|
+
}
|
|
116
|
+
} catch (error) {
|
|
117
|
+
// If the transactions are not available, then we should not attempt to attest
|
|
118
|
+
if (error instanceof TransactionsNotAvailableError) {
|
|
119
|
+
this.log.error(`Transactions not available, skipping attestation`, error, proposalInfo);
|
|
120
|
+
} else {
|
|
121
|
+
// This branch most commonly be hit if the transactions are available, but the re-execution fails
|
|
122
|
+
// Catch all error handler
|
|
123
|
+
this.log.error(`Failed to attest to proposal`, error, proposalInfo);
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
// Provided all of the above checks pass, we can attest to the proposal
|
|
128
|
+
this.log.info(`Attesting to proposal for slot ${slotNumber}`, proposalInfo);
|
|
129
|
+
// If the above function does not throw an error, then we can attest to the proposal
|
|
130
|
+
return this.validationService.attestToProposal(proposal);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Re-execute the transactions in the proposal and check that the state updates match the header state
|
|
134
|
+
* @param proposal - The proposal to re-execute
|
|
135
|
+
*/ async reExecuteTransactions(proposal) {
|
|
136
|
+
const { header, txHashes } = proposal.payload;
|
|
137
|
+
const txs = (await Promise.all(txHashes.map((tx)=>this.p2pClient.getTxByHash(tx)))).filter((tx)=>tx !== undefined);
|
|
138
|
+
// If we cannot request all of the transactions, then we should fail
|
|
139
|
+
if (txs.length !== txHashes.length) {
|
|
140
|
+
throw new TransactionsNotAvailableError(txHashes);
|
|
141
|
+
}
|
|
142
|
+
// Assertion: This check will fail if re-execution is not enabled
|
|
143
|
+
if (this.blockBuilder === undefined) {
|
|
144
|
+
throw new BlockBuilderNotProvidedError();
|
|
145
|
+
}
|
|
146
|
+
// Use the sequencer's block building logic to re-execute the transactions
|
|
147
|
+
const stopTimer = this.metrics.reExecutionTimer();
|
|
148
|
+
const { block, numFailedTxs } = await this.blockBuilder(txs, header.globalVariables, {
|
|
149
|
+
validateOnly: true
|
|
150
|
+
});
|
|
151
|
+
stopTimer();
|
|
152
|
+
this.log.verbose(`Transaction re-execution complete`);
|
|
153
|
+
if (numFailedTxs > 0) {
|
|
154
|
+
await this.metrics.recordFailedReexecution(proposal);
|
|
155
|
+
throw new ReExFailedTxsError(numFailedTxs);
|
|
156
|
+
}
|
|
157
|
+
if (block.body.txEffects.length !== txHashes.length) {
|
|
158
|
+
await this.metrics.recordFailedReexecution(proposal);
|
|
159
|
+
throw new ReExTimeoutError();
|
|
160
|
+
}
|
|
161
|
+
// This function will throw an error if state updates do not match
|
|
162
|
+
if (!block.archive.root.equals(proposal.archive)) {
|
|
163
|
+
await this.metrics.recordFailedReexecution(proposal);
|
|
164
|
+
throw new ReExStateMismatchError();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Ensure that all of the transactions in the proposal are available in the tx pool before attesting
|
|
169
|
+
*
|
|
170
|
+
* 1. Check if the local tx pool contains all of the transactions in the proposal
|
|
171
|
+
* 2. If any transactions are not in the local tx pool, request them from the network
|
|
172
|
+
* 3. If we cannot retrieve them from the network, throw an error
|
|
173
|
+
* @param proposal - The proposal to attest to
|
|
174
|
+
*/ async ensureTransactionsAreAvailable(proposal) {
|
|
175
|
+
const txHashes = proposal.payload.txHashes;
|
|
176
|
+
const transactionStatuses = await Promise.all(txHashes.map((txHash)=>this.p2pClient.getTxStatus(txHash)));
|
|
177
|
+
const missingTxs = txHashes.filter((_, index)=>![
|
|
178
|
+
'pending',
|
|
179
|
+
'mined'
|
|
180
|
+
].includes(transactionStatuses[index] ?? ''));
|
|
181
|
+
if (missingTxs.length === 0) {
|
|
182
|
+
return; // All transactions are available
|
|
183
|
+
}
|
|
184
|
+
this.log.verbose(`Missing ${missingTxs.length} transactions in the tx pool, requesting from the network`);
|
|
185
|
+
const requestedTxs = await this.p2pClient.requestTxs(missingTxs);
|
|
186
|
+
if (requestedTxs.some((tx)=>tx === undefined)) {
|
|
187
|
+
throw new TransactionsNotAvailableError(missingTxs);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async createBlockProposal(header, archive, txs) {
|
|
191
|
+
if (this.previousProposal?.slotNumber.equals(header.globalVariables.slotNumber)) {
|
|
192
|
+
this.log.verbose(`Already made a proposal for the same slot, skipping proposal`);
|
|
193
|
+
return Promise.resolve(undefined);
|
|
194
|
+
}
|
|
195
|
+
const newProposal = await this.validationService.createBlockProposal(header, archive, txs);
|
|
196
|
+
this.previousProposal = newProposal;
|
|
197
|
+
return newProposal;
|
|
198
|
+
}
|
|
199
|
+
broadcastBlockProposal(proposal) {
|
|
200
|
+
this.p2pClient.broadcastProposal(proposal);
|
|
201
|
+
}
|
|
202
|
+
// TODO(https://github.com/AztecProtocol/aztec-packages/issues/7962)
|
|
203
|
+
async collectAttestations(proposal, required, deadline) {
|
|
204
|
+
// Wait and poll the p2pClient's attestation pool for this block until we have enough attestations
|
|
205
|
+
const slot = proposal.payload.header.globalVariables.slotNumber.toBigInt();
|
|
206
|
+
this.log.debug(`Collecting ${required} attestations for slot ${slot} with deadline ${deadline.toISOString()}`);
|
|
207
|
+
if (+deadline < this.dateProvider.now()) {
|
|
208
|
+
this.log.error(`Deadline ${deadline.toISOString()} for collecting ${required} attestations for slot ${slot} is in the past`);
|
|
209
|
+
throw new AttestationTimeoutError(required, slot);
|
|
210
|
+
}
|
|
211
|
+
const proposalId = proposal.archive.toString();
|
|
212
|
+
const myAttestation = await this.validationService.attestToProposal(proposal);
|
|
213
|
+
let attestations = [];
|
|
214
|
+
while(true){
|
|
215
|
+
const collectedAttestations = [
|
|
216
|
+
myAttestation,
|
|
217
|
+
...await this.p2pClient.getAttestationsForSlot(slot, proposalId)
|
|
218
|
+
];
|
|
219
|
+
const oldSenders = await Promise.all(attestations.map((attestation)=>attestation.getSender()));
|
|
220
|
+
for (const collected of collectedAttestations){
|
|
221
|
+
const collectedSender = await collected.getSender();
|
|
222
|
+
if (!oldSenders.some((sender)=>sender.equals(collectedSender))) {
|
|
223
|
+
this.log.debug(`Received attestation for slot ${slot} from ${collectedSender.toString()}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
attestations = collectedAttestations;
|
|
227
|
+
if (attestations.length >= required) {
|
|
228
|
+
this.log.verbose(`Collected all ${required} attestations for slot ${slot}`);
|
|
229
|
+
return attestations;
|
|
230
|
+
}
|
|
231
|
+
if (+deadline < this.dateProvider.now()) {
|
|
232
|
+
this.log.error(`Timeout ${deadline.toISOString()} waiting for ${required} attestations for slot ${slot}`);
|
|
233
|
+
throw new AttestationTimeoutError(required, slot);
|
|
234
|
+
}
|
|
235
|
+
this.log.debug(`Collected ${attestations.length} attestations so far`);
|
|
236
|
+
await sleep(this.config.attestationPollingIntervalMs);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function validatePrivateKey(privateKey) {
|
|
241
|
+
try {
|
|
242
|
+
return Buffer32.fromString(privateKey);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
throw new InvalidValidatorPrivateKeyError();
|
|
245
|
+
}
|
|
246
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aztec/validator-client",
|
|
3
|
+
"version": "0.75.0-commit.c03ba01a2a4122e43e90d5133ba017e54b90e9d2",
|
|
4
|
+
"main": "dest/index.js",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./dest/index.js",
|
|
8
|
+
"./config": "./dest/config.js",
|
|
9
|
+
"./errors": "./dest/errors/index.js"
|
|
10
|
+
},
|
|
11
|
+
"bin": "./dest/bin/index.js",
|
|
12
|
+
"typedocOptions": {
|
|
13
|
+
"entryPoints": [
|
|
14
|
+
"./src/index.ts"
|
|
15
|
+
],
|
|
16
|
+
"name": "Aztec validator",
|
|
17
|
+
"tsconfig": "./tsconfig.json"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"start": "node --no-warnings ./dest/bin",
|
|
21
|
+
"build": "yarn clean && tsc -b",
|
|
22
|
+
"build:dev": "tsc -b --watch",
|
|
23
|
+
"clean": "rm -rf ./dest .tsbuildinfo",
|
|
24
|
+
"formatting": "run -T prettier --check ./src && run -T eslint ./src",
|
|
25
|
+
"formatting:fix": "run -T eslint --fix ./src && run -T prettier -w ./src",
|
|
26
|
+
"test": "NODE_NO_WARNINGS=1 node --experimental-vm-modules ../node_modules/.bin/jest --passWithNoTests --maxWorkers=${JEST_MAX_WORKERS:-8}"
|
|
27
|
+
},
|
|
28
|
+
"inherits": [
|
|
29
|
+
"../package.common.json"
|
|
30
|
+
],
|
|
31
|
+
"jest": {
|
|
32
|
+
"moduleNameMapper": {
|
|
33
|
+
"^(\\.{1,2}/.*)\\.[cm]?js$": "$1"
|
|
34
|
+
},
|
|
35
|
+
"testRegex": "./src/.*\\.test\\.(js|mjs|ts)$",
|
|
36
|
+
"rootDir": "./src",
|
|
37
|
+
"transform": {
|
|
38
|
+
"^.+\\.tsx?$": [
|
|
39
|
+
"@swc/jest",
|
|
40
|
+
{
|
|
41
|
+
"jsc": {
|
|
42
|
+
"parser": {
|
|
43
|
+
"syntax": "typescript",
|
|
44
|
+
"decorators": true
|
|
45
|
+
},
|
|
46
|
+
"transform": {
|
|
47
|
+
"decoratorVersion": "2022-03"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
"extensionsToTreatAsEsm": [
|
|
54
|
+
".ts"
|
|
55
|
+
],
|
|
56
|
+
"reporters": [
|
|
57
|
+
"default"
|
|
58
|
+
],
|
|
59
|
+
"testTimeout": 30000,
|
|
60
|
+
"setupFiles": [
|
|
61
|
+
"../../foundation/src/jest/setup.mjs"
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"@aztec/circuit-types": "0.75.0-commit.c03ba01a2a4122e43e90d5133ba017e54b90e9d2",
|
|
66
|
+
"@aztec/circuits.js": "0.75.0-commit.c03ba01a2a4122e43e90d5133ba017e54b90e9d2",
|
|
67
|
+
"@aztec/epoch-cache": "0.75.0-commit.c03ba01a2a4122e43e90d5133ba017e54b90e9d2",
|
|
68
|
+
"@aztec/ethereum": "0.75.0-commit.c03ba01a2a4122e43e90d5133ba017e54b90e9d2",
|
|
69
|
+
"@aztec/foundation": "0.75.0-commit.c03ba01a2a4122e43e90d5133ba017e54b90e9d2",
|
|
70
|
+
"@aztec/p2p": "0.75.0-commit.c03ba01a2a4122e43e90d5133ba017e54b90e9d2",
|
|
71
|
+
"@aztec/telemetry-client": "0.75.0-commit.c03ba01a2a4122e43e90d5133ba017e54b90e9d2",
|
|
72
|
+
"@aztec/types": "0.75.0-commit.c03ba01a2a4122e43e90d5133ba017e54b90e9d2",
|
|
73
|
+
"koa": "^2.14.2",
|
|
74
|
+
"koa-router": "^12.0.0",
|
|
75
|
+
"tslib": "^2.4.0",
|
|
76
|
+
"viem": "2.22.8"
|
|
77
|
+
},
|
|
78
|
+
"devDependencies": {
|
|
79
|
+
"@jest/globals": "^29.5.0",
|
|
80
|
+
"@types/jest": "^29.5.0",
|
|
81
|
+
"@types/node": "^18.7.23",
|
|
82
|
+
"jest": "^29.5.0",
|
|
83
|
+
"jest-mock-extended": "^3.0.7",
|
|
84
|
+
"ts-node": "^10.9.1",
|
|
85
|
+
"typescript": "^5.0.4"
|
|
86
|
+
},
|
|
87
|
+
"files": [
|
|
88
|
+
"dest",
|
|
89
|
+
"src",
|
|
90
|
+
"!*.test.*"
|
|
91
|
+
],
|
|
92
|
+
"types": "./dest/index.d.ts",
|
|
93
|
+
"engines": {
|
|
94
|
+
"node": ">=18"
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { NULL_KEY } from '@aztec/ethereum';
|
|
2
|
+
import {
|
|
3
|
+
type ConfigMappingsType,
|
|
4
|
+
booleanConfigHelper,
|
|
5
|
+
getConfigFromMappings,
|
|
6
|
+
numberConfigHelper,
|
|
7
|
+
} from '@aztec/foundation/config';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The Validator Configuration
|
|
11
|
+
*/
|
|
12
|
+
export interface ValidatorClientConfig {
|
|
13
|
+
/** The private key of the validator participating in attestation duties */
|
|
14
|
+
validatorPrivateKey: string;
|
|
15
|
+
|
|
16
|
+
/** Do not run the validator */
|
|
17
|
+
disableValidator: boolean;
|
|
18
|
+
|
|
19
|
+
/** Interval between polling for new attestations from peers */
|
|
20
|
+
attestationPollingIntervalMs: number;
|
|
21
|
+
|
|
22
|
+
/** Re-execute transactions before attesting */
|
|
23
|
+
validatorReexecute: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const validatorClientConfigMappings: ConfigMappingsType<ValidatorClientConfig> = {
|
|
27
|
+
validatorPrivateKey: {
|
|
28
|
+
env: 'VALIDATOR_PRIVATE_KEY',
|
|
29
|
+
parseEnv: (val: string) => (val ? `0x${val.replace('0x', '')}` : NULL_KEY),
|
|
30
|
+
description: 'The private key of the validator participating in attestation duties',
|
|
31
|
+
},
|
|
32
|
+
disableValidator: {
|
|
33
|
+
env: 'VALIDATOR_DISABLED',
|
|
34
|
+
description: 'Do not run the validator',
|
|
35
|
+
...booleanConfigHelper(),
|
|
36
|
+
},
|
|
37
|
+
attestationPollingIntervalMs: {
|
|
38
|
+
env: 'VALIDATOR_ATTESTATIONS_POLLING_INTERVAL_MS',
|
|
39
|
+
description: 'Interval between polling for new attestations',
|
|
40
|
+
...numberConfigHelper(200),
|
|
41
|
+
},
|
|
42
|
+
validatorReexecute: {
|
|
43
|
+
env: 'VALIDATOR_REEXECUTE',
|
|
44
|
+
description: 'Re-execute transactions before attesting',
|
|
45
|
+
...booleanConfigHelper(true),
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Returns the prover configuration from the environment variables.
|
|
51
|
+
* Note: If an environment variable is not set, the default value is used.
|
|
52
|
+
* @returns The validator configuration.
|
|
53
|
+
*/
|
|
54
|
+
export function getProverEnvVars(): ValidatorClientConfig {
|
|
55
|
+
return getConfigFromMappings<ValidatorClientConfig>(validatorClientConfigMappings);
|
|
56
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BlockAttestation,
|
|
3
|
+
BlockProposal,
|
|
4
|
+
ConsensusPayload,
|
|
5
|
+
SignatureDomainSeparator,
|
|
6
|
+
type TxHash,
|
|
7
|
+
} from '@aztec/circuit-types';
|
|
8
|
+
import { type BlockHeader } from '@aztec/circuits.js';
|
|
9
|
+
import { Buffer32 } from '@aztec/foundation/buffer';
|
|
10
|
+
import { keccak256 } from '@aztec/foundation/crypto';
|
|
11
|
+
import { type Fr } from '@aztec/foundation/fields';
|
|
12
|
+
|
|
13
|
+
import { type ValidatorKeyStore } from '../key_store/interface.js';
|
|
14
|
+
|
|
15
|
+
export class ValidationService {
|
|
16
|
+
constructor(private keyStore: ValidatorKeyStore) {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a block proposal with the given header, archive, and transactions
|
|
20
|
+
*
|
|
21
|
+
* @param header - The block header
|
|
22
|
+
* @param archive - The archive of the current block
|
|
23
|
+
* @param txs - TxHash[] ordered list of transactions
|
|
24
|
+
*
|
|
25
|
+
* @returns A block proposal signing the above information (not the current implementation!!!)
|
|
26
|
+
*/
|
|
27
|
+
createBlockProposal(header: BlockHeader, archive: Fr, txs: TxHash[]): Promise<BlockProposal> {
|
|
28
|
+
const payloadSigner = (payload: Buffer32) => this.keyStore.signMessage(payload);
|
|
29
|
+
|
|
30
|
+
return BlockProposal.createProposalFromSigner(new ConsensusPayload(header, archive, txs), payloadSigner);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Attest to the given block proposal constructed by the current sequencer
|
|
35
|
+
*
|
|
36
|
+
* NOTE: This is just a blind signing.
|
|
37
|
+
* We assume that the proposal is valid and DA guarantees have been checked previously.
|
|
38
|
+
*
|
|
39
|
+
* @param proposal - The proposal to attest to
|
|
40
|
+
* @returns attestation
|
|
41
|
+
*/
|
|
42
|
+
async attestToProposal(proposal: BlockProposal): Promise<BlockAttestation> {
|
|
43
|
+
// TODO(https://github.com/AztecProtocol/aztec-packages/issues/7961): check that the current validator is correct
|
|
44
|
+
|
|
45
|
+
const buf = Buffer32.fromBuffer(
|
|
46
|
+
keccak256(await proposal.payload.getPayloadToSign(SignatureDomainSeparator.blockAttestation)),
|
|
47
|
+
);
|
|
48
|
+
const sig = await this.keyStore.signMessage(buf);
|
|
49
|
+
return new BlockAttestation(proposal.payload, sig);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './validator.error.js';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type TxHash } from '@aztec/circuit-types/tx_hash';
|
|
2
|
+
|
|
3
|
+
export class ValidatorError extends Error {
|
|
4
|
+
constructor(message: string) {
|
|
5
|
+
super(`Validator Error: ${message}`);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class InvalidValidatorPrivateKeyError extends ValidatorError {
|
|
10
|
+
constructor() {
|
|
11
|
+
super('Invalid validator private key provided');
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class AttestationTimeoutError extends ValidatorError {
|
|
16
|
+
constructor(numberOfRequiredAttestations: number, slot: bigint) {
|
|
17
|
+
super(`Timeout waiting for ${numberOfRequiredAttestations} attestations for slot, ${slot}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class TransactionsNotAvailableError extends ValidatorError {
|
|
22
|
+
constructor(txHashes: TxHash[]) {
|
|
23
|
+
super(`Transactions not available: ${txHashes.join(', ')}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class FailedToReExecuteTransactionsError extends ValidatorError {
|
|
28
|
+
constructor(txHashes: TxHash[]) {
|
|
29
|
+
super(`Failed to re-execute transactions: ${txHashes.join(', ')}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class ReExStateMismatchError extends ValidatorError {
|
|
34
|
+
constructor() {
|
|
35
|
+
super('Re-execution state mismatch');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ReExFailedTxsError extends ValidatorError {
|
|
40
|
+
constructor(numFailedTxs: number) {
|
|
41
|
+
super(`Re-execution failed to process ${numFailedTxs} txs`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class ReExTimeoutError extends ValidatorError {
|
|
46
|
+
constructor() {
|
|
47
|
+
super('Re-execution timed out or failed to process all txs in the proposal');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class BlockBuilderNotProvidedError extends ValidatorError {
|
|
52
|
+
constructor() {
|
|
53
|
+
super('Block builder not provided');
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/factory.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type EpochCache } from '@aztec/epoch-cache';
|
|
2
|
+
import { type DateProvider } from '@aztec/foundation/timer';
|
|
3
|
+
import { type P2P } from '@aztec/p2p';
|
|
4
|
+
import { type TelemetryClient } from '@aztec/telemetry-client';
|
|
5
|
+
|
|
6
|
+
import { generatePrivateKey } from 'viem/accounts';
|
|
7
|
+
|
|
8
|
+
import { type ValidatorClientConfig } from './config.js';
|
|
9
|
+
import { ValidatorClient } from './validator.js';
|
|
10
|
+
|
|
11
|
+
export function createValidatorClient(
|
|
12
|
+
config: ValidatorClientConfig,
|
|
13
|
+
deps: {
|
|
14
|
+
p2pClient: P2P;
|
|
15
|
+
telemetry: TelemetryClient;
|
|
16
|
+
dateProvider: DateProvider;
|
|
17
|
+
epochCache: EpochCache;
|
|
18
|
+
},
|
|
19
|
+
) {
|
|
20
|
+
if (config.disableValidator) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
if (config.validatorPrivateKey === undefined || config.validatorPrivateKey === '') {
|
|
24
|
+
config.validatorPrivateKey = generatePrivateKey();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return ValidatorClient.new(config, deps.epochCache, deps.p2pClient, deps.dateProvider, deps.telemetry);
|
|
28
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type EthAddress } from '@aztec/circuits.js';
|
|
2
|
+
import { type Buffer32 } from '@aztec/foundation/buffer';
|
|
3
|
+
import { type Signature } from '@aztec/foundation/eth-signature';
|
|
4
|
+
|
|
5
|
+
/** Key Store
|
|
6
|
+
*
|
|
7
|
+
* A keystore interface that can be replaced with a local keystore / remote signer service
|
|
8
|
+
*/
|
|
9
|
+
export interface ValidatorKeyStore {
|
|
10
|
+
/**
|
|
11
|
+
* Get the address of the signer
|
|
12
|
+
*
|
|
13
|
+
* @returns the address
|
|
14
|
+
*/
|
|
15
|
+
getAddress(): EthAddress;
|
|
16
|
+
|
|
17
|
+
sign(message: Buffer32): Promise<Signature>;
|
|
18
|
+
/**
|
|
19
|
+
* Flavor of sign message that followed EIP-712 eth signed message prefix
|
|
20
|
+
* Note: this is only required when we are using ecdsa signatures over secp256k1
|
|
21
|
+
*
|
|
22
|
+
* @param message - The message to sign.
|
|
23
|
+
* @returns The signature.
|
|
24
|
+
*/
|
|
25
|
+
signMessage(message: Buffer32): Promise<Signature>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type Buffer32 } from '@aztec/foundation/buffer';
|
|
2
|
+
import { Secp256k1Signer } from '@aztec/foundation/crypto';
|
|
3
|
+
import { type EthAddress } from '@aztec/foundation/eth-address';
|
|
4
|
+
import { type Signature } from '@aztec/foundation/eth-signature';
|
|
5
|
+
|
|
6
|
+
import { type ValidatorKeyStore } from './interface.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Local Key Store
|
|
10
|
+
*
|
|
11
|
+
* An implementation of the Key store using an in memory private key.
|
|
12
|
+
*/
|
|
13
|
+
export class LocalKeyStore implements ValidatorKeyStore {
|
|
14
|
+
private signer: Secp256k1Signer;
|
|
15
|
+
|
|
16
|
+
constructor(privateKey: Buffer32) {
|
|
17
|
+
this.signer = new Secp256k1Signer(privateKey);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get the address of the signer
|
|
22
|
+
*
|
|
23
|
+
* @returns the address
|
|
24
|
+
*/
|
|
25
|
+
public getAddress(): EthAddress {
|
|
26
|
+
return this.signer.address;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sign a message with the keystore private key
|
|
31
|
+
*
|
|
32
|
+
* @param messageBuffer - The message buffer to sign
|
|
33
|
+
* @return signature
|
|
34
|
+
*/
|
|
35
|
+
public sign(digest: Buffer32): Promise<Signature> {
|
|
36
|
+
const signature = this.signer.sign(digest);
|
|
37
|
+
|
|
38
|
+
return Promise.resolve(signature);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public signMessage(message: Buffer32): Promise<Signature> {
|
|
42
|
+
// Sign message adds eth sign prefix and hashes before signing
|
|
43
|
+
const signature = this.signer.signMessage(message);
|
|
44
|
+
return Promise.resolve(signature);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type BlockProposal } from '@aztec/circuit-types';
|
|
2
|
+
import {
|
|
3
|
+
Attributes,
|
|
4
|
+
type Gauge,
|
|
5
|
+
Metrics,
|
|
6
|
+
type TelemetryClient,
|
|
7
|
+
type UpDownCounter,
|
|
8
|
+
ValueType,
|
|
9
|
+
} from '@aztec/telemetry-client';
|
|
10
|
+
|
|
11
|
+
export class ValidatorMetrics {
|
|
12
|
+
private reExecutionTime: Gauge;
|
|
13
|
+
private failedReexecutionCounter: UpDownCounter;
|
|
14
|
+
|
|
15
|
+
constructor(telemetryClient: TelemetryClient) {
|
|
16
|
+
const meter = telemetryClient.getMeter('Validator');
|
|
17
|
+
|
|
18
|
+
this.failedReexecutionCounter = meter.createUpDownCounter(Metrics.VALIDATOR_FAILED_REEXECUTION_COUNT, {
|
|
19
|
+
description: 'The number of failed re-executions',
|
|
20
|
+
unit: 'count',
|
|
21
|
+
valueType: ValueType.INT,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
this.reExecutionTime = meter.createGauge(Metrics.VALIDATOR_RE_EXECUTION_TIME, {
|
|
25
|
+
description: 'The time taken to re-execute a transaction',
|
|
26
|
+
unit: 'ms',
|
|
27
|
+
valueType: ValueType.DOUBLE,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public reExecutionTimer(): () => void {
|
|
32
|
+
const start = performance.now();
|
|
33
|
+
return () => {
|
|
34
|
+
const end = performance.now();
|
|
35
|
+
this.recordReExecutionTime(end - start);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public recordReExecutionTime(time: number) {
|
|
40
|
+
this.reExecutionTime.record(time);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public async recordFailedReexecution(proposal: BlockProposal) {
|
|
44
|
+
this.failedReexecutionCounter.add(1, {
|
|
45
|
+
[Attributes.STATUS]: 'failed',
|
|
46
|
+
[Attributes.BLOCK_NUMBER]: proposal.payload.header.globalVariables.blockNumber.toString(),
|
|
47
|
+
[Attributes.BLOCK_PROPOSER]: (await proposal.getSender())?.toString(),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/validator.ts
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { type BlockAttestation, type BlockProposal, type L2Block, type Tx, type TxHash } from '@aztec/circuit-types';
|
|
2
|
+
import { type BlockHeader, type GlobalVariables } from '@aztec/circuits.js';
|
|
3
|
+
import { type EpochCache } from '@aztec/epoch-cache';
|
|
4
|
+
import { Buffer32 } from '@aztec/foundation/buffer';
|
|
5
|
+
import { type Fr } from '@aztec/foundation/fields';
|
|
6
|
+
import { createLogger } from '@aztec/foundation/log';
|
|
7
|
+
import { RunningPromise } from '@aztec/foundation/running-promise';
|
|
8
|
+
import { sleep } from '@aztec/foundation/sleep';
|
|
9
|
+
import { DateProvider, type Timer } from '@aztec/foundation/timer';
|
|
10
|
+
import { type P2P } from '@aztec/p2p';
|
|
11
|
+
import { BlockProposalValidator } from '@aztec/p2p/msg_validators';
|
|
12
|
+
import { type TelemetryClient, WithTracer, getTelemetryClient } from '@aztec/telemetry-client';
|
|
13
|
+
|
|
14
|
+
import { type ValidatorClientConfig } from './config.js';
|
|
15
|
+
import { ValidationService } from './duties/validation_service.js';
|
|
16
|
+
import {
|
|
17
|
+
AttestationTimeoutError,
|
|
18
|
+
BlockBuilderNotProvidedError,
|
|
19
|
+
InvalidValidatorPrivateKeyError,
|
|
20
|
+
ReExFailedTxsError,
|
|
21
|
+
ReExStateMismatchError,
|
|
22
|
+
ReExTimeoutError,
|
|
23
|
+
TransactionsNotAvailableError,
|
|
24
|
+
} from './errors/validator.error.js';
|
|
25
|
+
import { type ValidatorKeyStore } from './key_store/interface.js';
|
|
26
|
+
import { LocalKeyStore } from './key_store/local_key_store.js';
|
|
27
|
+
import { ValidatorMetrics } from './metrics.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Callback function for building a block
|
|
31
|
+
*
|
|
32
|
+
* We reuse the sequencer's block building functionality for re-execution
|
|
33
|
+
*/
|
|
34
|
+
type BlockBuilderCallback = (
|
|
35
|
+
txs: Iterable<Tx> | AsyncIterableIterator<Tx>,
|
|
36
|
+
globalVariables: GlobalVariables,
|
|
37
|
+
opts?: { validateOnly?: boolean },
|
|
38
|
+
) => Promise<{
|
|
39
|
+
block: L2Block;
|
|
40
|
+
publicProcessorDuration: number;
|
|
41
|
+
numTxs: number;
|
|
42
|
+
numFailedTxs: number;
|
|
43
|
+
blockBuildingTimer: Timer;
|
|
44
|
+
}>;
|
|
45
|
+
|
|
46
|
+
export interface Validator {
|
|
47
|
+
start(): Promise<void>;
|
|
48
|
+
registerBlockProposalHandler(): void;
|
|
49
|
+
registerBlockBuilder(blockBuilder: BlockBuilderCallback): void;
|
|
50
|
+
|
|
51
|
+
// Block validation responsiblities
|
|
52
|
+
createBlockProposal(header: BlockHeader, archive: Fr, txs: TxHash[]): Promise<BlockProposal | undefined>;
|
|
53
|
+
attestToProposal(proposal: BlockProposal): void;
|
|
54
|
+
|
|
55
|
+
broadcastBlockProposal(proposal: BlockProposal): void;
|
|
56
|
+
collectAttestations(proposal: BlockProposal, required: number, deadline: Date): Promise<BlockAttestation[]>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validator Client
|
|
61
|
+
*/
|
|
62
|
+
export class ValidatorClient extends WithTracer implements Validator {
|
|
63
|
+
private validationService: ValidationService;
|
|
64
|
+
private metrics: ValidatorMetrics;
|
|
65
|
+
|
|
66
|
+
// Used to check if we are sending the same proposal twice
|
|
67
|
+
private previousProposal?: BlockProposal;
|
|
68
|
+
|
|
69
|
+
// Callback registered to: sequencer.buildBlock
|
|
70
|
+
private blockBuilder?: BlockBuilderCallback = undefined;
|
|
71
|
+
|
|
72
|
+
private epochCacheUpdateLoop: RunningPromise;
|
|
73
|
+
|
|
74
|
+
private blockProposalValidator: BlockProposalValidator;
|
|
75
|
+
|
|
76
|
+
constructor(
|
|
77
|
+
private keyStore: ValidatorKeyStore,
|
|
78
|
+
private epochCache: EpochCache,
|
|
79
|
+
private p2pClient: P2P,
|
|
80
|
+
private config: ValidatorClientConfig,
|
|
81
|
+
private dateProvider: DateProvider = new DateProvider(),
|
|
82
|
+
telemetry: TelemetryClient = getTelemetryClient(),
|
|
83
|
+
private log = createLogger('validator'),
|
|
84
|
+
) {
|
|
85
|
+
// Instantiate tracer
|
|
86
|
+
super(telemetry, 'Validator');
|
|
87
|
+
this.metrics = new ValidatorMetrics(telemetry);
|
|
88
|
+
|
|
89
|
+
this.validationService = new ValidationService(keyStore);
|
|
90
|
+
|
|
91
|
+
this.blockProposalValidator = new BlockProposalValidator(epochCache);
|
|
92
|
+
|
|
93
|
+
// Refresh epoch cache every second to trigger commiteeChanged event
|
|
94
|
+
this.epochCacheUpdateLoop = new RunningPromise(
|
|
95
|
+
() =>
|
|
96
|
+
this.epochCache
|
|
97
|
+
.getCommittee()
|
|
98
|
+
.then(() => {})
|
|
99
|
+
.catch(err => log.error('Error updating validator committee', err)),
|
|
100
|
+
log,
|
|
101
|
+
1000,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// Listen to commiteeChanged event to alert operator when their validator has entered the committee
|
|
105
|
+
this.epochCache.on('committeeChanged', (newCommittee, epochNumber) => {
|
|
106
|
+
const me = this.keyStore.getAddress();
|
|
107
|
+
if (newCommittee.some(addr => addr.equals(me))) {
|
|
108
|
+
this.log.info(`Validator ${me.toString()} is on the validator committee for epoch ${epochNumber}`);
|
|
109
|
+
} else {
|
|
110
|
+
this.log.verbose(`Validator ${me.toString()} not on the validator committee for epoch ${epochNumber}`);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
this.log.verbose(`Initialized validator with address ${this.keyStore.getAddress().toString()}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
static new(
|
|
118
|
+
config: ValidatorClientConfig,
|
|
119
|
+
epochCache: EpochCache,
|
|
120
|
+
p2pClient: P2P,
|
|
121
|
+
dateProvider: DateProvider = new DateProvider(),
|
|
122
|
+
telemetry: TelemetryClient = getTelemetryClient(),
|
|
123
|
+
) {
|
|
124
|
+
if (!config.validatorPrivateKey) {
|
|
125
|
+
throw new InvalidValidatorPrivateKeyError();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const privateKey = validatePrivateKey(config.validatorPrivateKey);
|
|
129
|
+
const localKeyStore = new LocalKeyStore(privateKey);
|
|
130
|
+
|
|
131
|
+
const validator = new ValidatorClient(localKeyStore, epochCache, p2pClient, config, dateProvider, telemetry);
|
|
132
|
+
validator.registerBlockProposalHandler();
|
|
133
|
+
return validator;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
public async start() {
|
|
137
|
+
// Sync the committee from the smart contract
|
|
138
|
+
// https://github.com/AztecProtocol/aztec-packages/issues/7962
|
|
139
|
+
|
|
140
|
+
const me = this.keyStore.getAddress();
|
|
141
|
+
const inCommittee = await this.epochCache.isInCommittee(me);
|
|
142
|
+
if (inCommittee) {
|
|
143
|
+
this.log.info(`Started validator with address ${me.toString()} in current validator committee`);
|
|
144
|
+
} else {
|
|
145
|
+
this.log.info(`Started validator with address ${me.toString()}`);
|
|
146
|
+
}
|
|
147
|
+
this.epochCacheUpdateLoop.start();
|
|
148
|
+
return Promise.resolve();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
public async stop() {
|
|
152
|
+
await this.epochCacheUpdateLoop.stop();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
public registerBlockProposalHandler() {
|
|
156
|
+
const handler = (block: BlockProposal): Promise<BlockAttestation | undefined> => {
|
|
157
|
+
return this.attestToProposal(block);
|
|
158
|
+
};
|
|
159
|
+
this.p2pClient.registerBlockProposalHandler(handler);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Register a callback function for building a block
|
|
164
|
+
*
|
|
165
|
+
* We reuse the sequencer's block building functionality for re-execution
|
|
166
|
+
*/
|
|
167
|
+
public registerBlockBuilder(blockBuilder: BlockBuilderCallback) {
|
|
168
|
+
this.blockBuilder = blockBuilder;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async attestToProposal(proposal: BlockProposal): Promise<BlockAttestation | undefined> {
|
|
172
|
+
const slotNumber = proposal.slotNumber.toNumber();
|
|
173
|
+
const proposalInfo = {
|
|
174
|
+
slotNumber,
|
|
175
|
+
blockNumber: proposal.payload.header.globalVariables.blockNumber.toNumber(),
|
|
176
|
+
archive: proposal.payload.archive.toString(),
|
|
177
|
+
txCount: proposal.payload.txHashes.length,
|
|
178
|
+
txHashes: proposal.payload.txHashes.map(txHash => txHash.toString()),
|
|
179
|
+
};
|
|
180
|
+
this.log.verbose(`Received request to attest for slot ${slotNumber}`);
|
|
181
|
+
|
|
182
|
+
// Check that I am in the committee
|
|
183
|
+
if (!(await this.epochCache.isInCommittee(this.keyStore.getAddress()))) {
|
|
184
|
+
this.log.verbose(`Not in the committee, skipping attestation`);
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Check that the proposal is from the current proposer, or the next proposer.
|
|
189
|
+
const invalidProposal = await this.blockProposalValidator.validate(proposal);
|
|
190
|
+
if (invalidProposal) {
|
|
191
|
+
this.log.verbose(`Proposal is not valid, skipping attestation`);
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Check that all of the tranasctions in the proposal are available in the tx pool before attesting
|
|
196
|
+
this.log.verbose(`Processing attestation for slot ${slotNumber}`, proposalInfo);
|
|
197
|
+
try {
|
|
198
|
+
await this.ensureTransactionsAreAvailable(proposal);
|
|
199
|
+
|
|
200
|
+
if (this.config.validatorReexecute) {
|
|
201
|
+
this.log.verbose(`Re-executing transactions in the proposal before attesting`);
|
|
202
|
+
await this.reExecuteTransactions(proposal);
|
|
203
|
+
}
|
|
204
|
+
} catch (error: any) {
|
|
205
|
+
// If the transactions are not available, then we should not attempt to attest
|
|
206
|
+
if (error instanceof TransactionsNotAvailableError) {
|
|
207
|
+
this.log.error(`Transactions not available, skipping attestation`, error, proposalInfo);
|
|
208
|
+
} else {
|
|
209
|
+
// This branch most commonly be hit if the transactions are available, but the re-execution fails
|
|
210
|
+
// Catch all error handler
|
|
211
|
+
this.log.error(`Failed to attest to proposal`, error, proposalInfo);
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Provided all of the above checks pass, we can attest to the proposal
|
|
217
|
+
this.log.info(`Attesting to proposal for slot ${slotNumber}`, proposalInfo);
|
|
218
|
+
|
|
219
|
+
// If the above function does not throw an error, then we can attest to the proposal
|
|
220
|
+
return this.validationService.attestToProposal(proposal);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Re-execute the transactions in the proposal and check that the state updates match the header state
|
|
225
|
+
* @param proposal - The proposal to re-execute
|
|
226
|
+
*/
|
|
227
|
+
async reExecuteTransactions(proposal: BlockProposal) {
|
|
228
|
+
const { header, txHashes } = proposal.payload;
|
|
229
|
+
|
|
230
|
+
const txs = (await Promise.all(txHashes.map(tx => this.p2pClient.getTxByHash(tx)))).filter(
|
|
231
|
+
tx => tx !== undefined,
|
|
232
|
+
) as Tx[];
|
|
233
|
+
|
|
234
|
+
// If we cannot request all of the transactions, then we should fail
|
|
235
|
+
if (txs.length !== txHashes.length) {
|
|
236
|
+
throw new TransactionsNotAvailableError(txHashes);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Assertion: This check will fail if re-execution is not enabled
|
|
240
|
+
if (this.blockBuilder === undefined) {
|
|
241
|
+
throw new BlockBuilderNotProvidedError();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Use the sequencer's block building logic to re-execute the transactions
|
|
245
|
+
const stopTimer = this.metrics.reExecutionTimer();
|
|
246
|
+
const { block, numFailedTxs } = await this.blockBuilder(txs, header.globalVariables, {
|
|
247
|
+
validateOnly: true,
|
|
248
|
+
});
|
|
249
|
+
stopTimer();
|
|
250
|
+
|
|
251
|
+
this.log.verbose(`Transaction re-execution complete`);
|
|
252
|
+
|
|
253
|
+
if (numFailedTxs > 0) {
|
|
254
|
+
await this.metrics.recordFailedReexecution(proposal);
|
|
255
|
+
throw new ReExFailedTxsError(numFailedTxs);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (block.body.txEffects.length !== txHashes.length) {
|
|
259
|
+
await this.metrics.recordFailedReexecution(proposal);
|
|
260
|
+
throw new ReExTimeoutError();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// This function will throw an error if state updates do not match
|
|
264
|
+
if (!block.archive.root.equals(proposal.archive)) {
|
|
265
|
+
await this.metrics.recordFailedReexecution(proposal);
|
|
266
|
+
throw new ReExStateMismatchError();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Ensure that all of the transactions in the proposal are available in the tx pool before attesting
|
|
272
|
+
*
|
|
273
|
+
* 1. Check if the local tx pool contains all of the transactions in the proposal
|
|
274
|
+
* 2. If any transactions are not in the local tx pool, request them from the network
|
|
275
|
+
* 3. If we cannot retrieve them from the network, throw an error
|
|
276
|
+
* @param proposal - The proposal to attest to
|
|
277
|
+
*/
|
|
278
|
+
async ensureTransactionsAreAvailable(proposal: BlockProposal) {
|
|
279
|
+
const txHashes: TxHash[] = proposal.payload.txHashes;
|
|
280
|
+
const transactionStatuses = await Promise.all(txHashes.map(txHash => this.p2pClient.getTxStatus(txHash)));
|
|
281
|
+
|
|
282
|
+
const missingTxs = txHashes.filter((_, index) => !['pending', 'mined'].includes(transactionStatuses[index] ?? ''));
|
|
283
|
+
|
|
284
|
+
if (missingTxs.length === 0) {
|
|
285
|
+
return; // All transactions are available
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
this.log.verbose(`Missing ${missingTxs.length} transactions in the tx pool, requesting from the network`);
|
|
289
|
+
|
|
290
|
+
const requestedTxs = await this.p2pClient.requestTxs(missingTxs);
|
|
291
|
+
if (requestedTxs.some(tx => tx === undefined)) {
|
|
292
|
+
throw new TransactionsNotAvailableError(missingTxs);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async createBlockProposal(header: BlockHeader, archive: Fr, txs: TxHash[]): Promise<BlockProposal | undefined> {
|
|
297
|
+
if (this.previousProposal?.slotNumber.equals(header.globalVariables.slotNumber)) {
|
|
298
|
+
this.log.verbose(`Already made a proposal for the same slot, skipping proposal`);
|
|
299
|
+
return Promise.resolve(undefined);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const newProposal = await this.validationService.createBlockProposal(header, archive, txs);
|
|
303
|
+
this.previousProposal = newProposal;
|
|
304
|
+
return newProposal;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
broadcastBlockProposal(proposal: BlockProposal): void {
|
|
308
|
+
this.p2pClient.broadcastProposal(proposal);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// TODO(https://github.com/AztecProtocol/aztec-packages/issues/7962)
|
|
312
|
+
async collectAttestations(proposal: BlockProposal, required: number, deadline: Date): Promise<BlockAttestation[]> {
|
|
313
|
+
// Wait and poll the p2pClient's attestation pool for this block until we have enough attestations
|
|
314
|
+
const slot = proposal.payload.header.globalVariables.slotNumber.toBigInt();
|
|
315
|
+
this.log.debug(`Collecting ${required} attestations for slot ${slot} with deadline ${deadline.toISOString()}`);
|
|
316
|
+
|
|
317
|
+
if (+deadline < this.dateProvider.now()) {
|
|
318
|
+
this.log.error(
|
|
319
|
+
`Deadline ${deadline.toISOString()} for collecting ${required} attestations for slot ${slot} is in the past`,
|
|
320
|
+
);
|
|
321
|
+
throw new AttestationTimeoutError(required, slot);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const proposalId = proposal.archive.toString();
|
|
325
|
+
const myAttestation = await this.validationService.attestToProposal(proposal);
|
|
326
|
+
|
|
327
|
+
let attestations: BlockAttestation[] = [];
|
|
328
|
+
while (true) {
|
|
329
|
+
const collectedAttestations = [myAttestation, ...(await this.p2pClient.getAttestationsForSlot(slot, proposalId))];
|
|
330
|
+
const oldSenders = await Promise.all(attestations.map(attestation => attestation.getSender()));
|
|
331
|
+
for (const collected of collectedAttestations) {
|
|
332
|
+
const collectedSender = await collected.getSender();
|
|
333
|
+
if (!oldSenders.some(sender => sender.equals(collectedSender))) {
|
|
334
|
+
this.log.debug(`Received attestation for slot ${slot} from ${collectedSender.toString()}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
attestations = collectedAttestations;
|
|
338
|
+
|
|
339
|
+
if (attestations.length >= required) {
|
|
340
|
+
this.log.verbose(`Collected all ${required} attestations for slot ${slot}`);
|
|
341
|
+
return attestations;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (+deadline < this.dateProvider.now()) {
|
|
345
|
+
this.log.error(`Timeout ${deadline.toISOString()} waiting for ${required} attestations for slot ${slot}`);
|
|
346
|
+
throw new AttestationTimeoutError(required, slot);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
this.log.debug(`Collected ${attestations.length} attestations so far`);
|
|
350
|
+
await sleep(this.config.attestationPollingIntervalMs);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function validatePrivateKey(privateKey: string): Buffer32 {
|
|
356
|
+
try {
|
|
357
|
+
return Buffer32.fromString(privateKey);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
throw new InvalidValidatorPrivateKeyError();
|
|
360
|
+
}
|
|
361
|
+
}
|