@aztec/validator-ha-signer 0.0.1-commit.b6e433891 → 0.0.1-commit.b8a057fa
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 +2 -2
- package/dest/db/lmdb.d.ts +6 -2
- package/dest/db/lmdb.d.ts.map +1 -1
- package/dest/db/lmdb.js +37 -2
- package/dest/db/migrations/1_initial-schema.d.ts +4 -2
- package/dest/db/migrations/1_initial-schema.d.ts.map +1 -1
- package/dest/db/migrations/1_initial-schema.js +34 -4
- package/dest/db/migrations/2_add-checkpoint-number.d.ts +7 -0
- package/dest/db/migrations/2_add-checkpoint-number.d.ts.map +1 -0
- package/dest/db/migrations/2_add-checkpoint-number.js +17 -0
- package/dest/db/postgres.d.ts +1 -1
- package/dest/db/postgres.d.ts.map +1 -1
- package/dest/db/postgres.js +2 -0
- package/dest/db/schema.d.ts +6 -6
- package/dest/db/schema.d.ts.map +1 -1
- package/dest/db/schema.js +9 -4
- package/dest/db/types.d.ts +11 -5
- package/dest/db/types.d.ts.map +1 -1
- package/dest/db/types.js +2 -1
- package/dest/errors.d.ts +14 -1
- package/dest/errors.d.ts.map +1 -1
- package/dest/errors.js +15 -0
- package/dest/factory.d.ts +7 -9
- package/dest/factory.d.ts.map +1 -1
- package/dest/factory.js +35 -9
- package/dest/slashing_protection_service.d.ts +4 -2
- package/dest/slashing_protection_service.d.ts.map +1 -1
- package/dest/slashing_protection_service.js +12 -9
- package/dest/types.d.ts +4 -3
- package/dest/types.d.ts.map +1 -1
- package/dest/types.js +2 -1
- package/dest/validator_ha_signer.d.ts +3 -2
- package/dest/validator_ha_signer.d.ts.map +1 -1
- package/dest/validator_ha_signer.js +33 -8
- package/package.json +7 -7
- package/src/db/lmdb.ts +46 -2
- package/src/db/migrations/1_initial-schema.ts +35 -4
- package/src/db/migrations/2_add-checkpoint-number.ts +19 -0
- package/src/db/postgres.ts +2 -0
- package/src/db/schema.ts +9 -4
- package/src/db/types.ts +11 -9
- package/src/errors.ts +21 -0
- package/src/factory.ts +58 -13
- package/src/slashing_protection_service.ts +14 -9
- package/src/types.ts +6 -0
- package/src/validator_ha_signer.ts +47 -7
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Add checkpoint_number column to validator_duties table
|
|
3
|
+
*/
|
|
4
|
+
import type { MigrationBuilder } from 'node-pg-migrate';
|
|
5
|
+
|
|
6
|
+
export function up(pgm: MigrationBuilder): void {
|
|
7
|
+
pgm.addColumn('validator_duties', {
|
|
8
|
+
// eslint-disable-next-line camelcase
|
|
9
|
+
checkpoint_number: { type: 'bigint', notNull: true, default: 0 },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
pgm.sql(`UPDATE schema_version SET version = 2 WHERE version = 1`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function down(pgm: MigrationBuilder): void {
|
|
16
|
+
pgm.dropColumn('validator_duties', 'checkpoint_number');
|
|
17
|
+
|
|
18
|
+
pgm.sql(`UPDATE schema_version SET version = 1 WHERE version = 2`);
|
|
19
|
+
}
|
package/src/db/postgres.ts
CHANGED
|
@@ -107,6 +107,7 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
|
|
|
107
107
|
params.validatorAddress.toString(),
|
|
108
108
|
params.slot.toString(),
|
|
109
109
|
params.blockNumber.toString(),
|
|
110
|
+
params.checkpointNumber.toString(),
|
|
110
111
|
blockIndexWithinCheckpoint,
|
|
111
112
|
params.dutyType,
|
|
112
113
|
params.messageHash,
|
|
@@ -230,6 +231,7 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
|
|
|
230
231
|
validatorAddress: row.validator_address,
|
|
231
232
|
slot: row.slot,
|
|
232
233
|
blockNumber: row.block_number,
|
|
234
|
+
checkpointNumber: row.checkpoint_number,
|
|
233
235
|
blockIndexWithinCheckpoint: row.block_index_within_checkpoint,
|
|
234
236
|
dutyType: row.duty_type,
|
|
235
237
|
status: row.status,
|
package/src/db/schema.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
/**
|
|
10
10
|
* Current schema version
|
|
11
11
|
*/
|
|
12
|
-
export const SCHEMA_VERSION =
|
|
12
|
+
export const SCHEMA_VERSION = 2;
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* SQL to create the validator_duties table
|
|
@@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS validator_duties (
|
|
|
20
20
|
validator_address VARCHAR(42) NOT NULL,
|
|
21
21
|
slot BIGINT NOT NULL,
|
|
22
22
|
block_number BIGINT NOT NULL,
|
|
23
|
+
checkpoint_number BIGINT NOT NULL DEFAULT 0,
|
|
23
24
|
block_index_within_checkpoint INTEGER NOT NULL DEFAULT 0,
|
|
24
25
|
duty_type VARCHAR(30) NOT NULL CHECK (duty_type IN ('BLOCK_PROPOSAL', 'CHECKPOINT_PROPOSAL', 'ATTESTATION', 'ATTESTATIONS_AND_SIGNERS', 'GOVERNANCE_VOTE', 'SLASHING_VOTE')),
|
|
25
26
|
status VARCHAR(20) NOT NULL CHECK (status IN ('signing', 'signed')),
|
|
@@ -106,6 +107,7 @@ WITH inserted AS (
|
|
|
106
107
|
validator_address,
|
|
107
108
|
slot,
|
|
108
109
|
block_number,
|
|
110
|
+
checkpoint_number,
|
|
109
111
|
block_index_within_checkpoint,
|
|
110
112
|
duty_type,
|
|
111
113
|
status,
|
|
@@ -113,13 +115,14 @@ WITH inserted AS (
|
|
|
113
115
|
node_id,
|
|
114
116
|
lock_token,
|
|
115
117
|
started_at
|
|
116
|
-
) VALUES ($1, $2, $3, $4, $5, $6, 'signing', $
|
|
118
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'signing', $8, $9, $10, CURRENT_TIMESTAMP)
|
|
117
119
|
ON CONFLICT (rollup_address, validator_address, slot, duty_type, block_index_within_checkpoint) DO NOTHING
|
|
118
120
|
RETURNING
|
|
119
121
|
rollup_address,
|
|
120
122
|
validator_address,
|
|
121
123
|
slot,
|
|
122
124
|
block_number,
|
|
125
|
+
checkpoint_number,
|
|
123
126
|
block_index_within_checkpoint,
|
|
124
127
|
duty_type,
|
|
125
128
|
status,
|
|
@@ -139,6 +142,7 @@ SELECT
|
|
|
139
142
|
validator_address,
|
|
140
143
|
slot,
|
|
141
144
|
block_number,
|
|
145
|
+
checkpoint_number,
|
|
142
146
|
block_index_within_checkpoint,
|
|
143
147
|
duty_type,
|
|
144
148
|
status,
|
|
@@ -154,8 +158,8 @@ FROM validator_duties
|
|
|
154
158
|
WHERE rollup_address = $1
|
|
155
159
|
AND validator_address = $2
|
|
156
160
|
AND slot = $3
|
|
157
|
-
AND duty_type = $
|
|
158
|
-
AND block_index_within_checkpoint = $
|
|
161
|
+
AND duty_type = $7
|
|
162
|
+
AND block_index_within_checkpoint = $6
|
|
159
163
|
AND NOT EXISTS (SELECT 1 FROM inserted);
|
|
160
164
|
`;
|
|
161
165
|
|
|
@@ -253,6 +257,7 @@ SELECT
|
|
|
253
257
|
validator_address,
|
|
254
258
|
slot,
|
|
255
259
|
block_number,
|
|
260
|
+
checkpoint_number,
|
|
256
261
|
block_index_within_checkpoint,
|
|
257
262
|
duty_type,
|
|
258
263
|
status,
|
package/src/db/types.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
BlockNumber,
|
|
3
|
-
type CheckpointNumber,
|
|
4
|
-
type IndexWithinCheckpoint,
|
|
5
|
-
SlotNumber,
|
|
6
|
-
} from '@aztec/foundation/branded-types';
|
|
1
|
+
import { BlockNumber, CheckpointNumber, type IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types';
|
|
7
2
|
import { EthAddress } from '@aztec/foundation/eth-address';
|
|
8
3
|
import type { Signature } from '@aztec/foundation/eth-signature';
|
|
9
4
|
import { DutyType } from '@aztec/stdlib/ha-signing';
|
|
@@ -16,6 +11,7 @@ export interface DutyRow {
|
|
|
16
11
|
validator_address: string;
|
|
17
12
|
slot: string;
|
|
18
13
|
block_number: string;
|
|
14
|
+
checkpoint_number: string;
|
|
19
15
|
block_index_within_checkpoint: number;
|
|
20
16
|
duty_type: DutyType;
|
|
21
17
|
status: DutyStatus;
|
|
@@ -38,6 +34,7 @@ export interface StoredDutyRecord {
|
|
|
38
34
|
validatorAddress: string;
|
|
39
35
|
slot: string;
|
|
40
36
|
blockNumber: string;
|
|
37
|
+
checkpointNumber: string;
|
|
41
38
|
blockIndexWithinCheckpoint: number;
|
|
42
39
|
dutyType: DutyType;
|
|
43
40
|
status: DutyStatus;
|
|
@@ -81,8 +78,10 @@ export interface ValidatorDutyRecord {
|
|
|
81
78
|
validatorAddress: EthAddress;
|
|
82
79
|
/** Slot number for this duty */
|
|
83
80
|
slot: SlotNumber;
|
|
84
|
-
/** Block number for this duty */
|
|
81
|
+
/** Block number for this duty (0 for non-block-proposal duties) */
|
|
85
82
|
blockNumber: BlockNumber;
|
|
83
|
+
/** Checkpoint number for this duty (0 for attestation and vote duties) */
|
|
84
|
+
checkpointNumber: CheckpointNumber;
|
|
86
85
|
/** Block index within checkpoint (0, 1, 2... for block proposals, -1 for other duty types) */
|
|
87
86
|
blockIndexWithinCheckpoint: number;
|
|
88
87
|
/** Type of duty being performed */
|
|
@@ -117,6 +116,7 @@ export function recordFromFields(stored: StoredDutyRecord): ValidatorDutyRecord
|
|
|
117
116
|
validatorAddress: EthAddress.fromString(stored.validatorAddress),
|
|
118
117
|
slot: SlotNumber.fromString(stored.slot),
|
|
119
118
|
blockNumber: BlockNumber.fromString(stored.blockNumber),
|
|
119
|
+
checkpointNumber: CheckpointNumber.fromString(stored.checkpointNumber),
|
|
120
120
|
blockIndexWithinCheckpoint: stored.blockIndexWithinCheckpoint,
|
|
121
121
|
dutyType: stored.dutyType,
|
|
122
122
|
status: stored.status,
|
|
@@ -208,8 +208,10 @@ export function getBlockIndexFromDutyIdentifier(duty: DutyIdentifier): number {
|
|
|
208
208
|
* Additional parameters for checking and recording a new duty
|
|
209
209
|
*/
|
|
210
210
|
interface CheckAndRecordExtra {
|
|
211
|
-
/** Block number for this duty */
|
|
212
|
-
blockNumber: BlockNumber
|
|
211
|
+
/** Block number for this duty (0 for non-block-proposal duties) */
|
|
212
|
+
blockNumber: BlockNumber;
|
|
213
|
+
/** Checkpoint number for this duty (0 for attestation and vote duties) */
|
|
214
|
+
checkpointNumber: CheckpointNumber;
|
|
213
215
|
/** The signing root (hash) for this duty */
|
|
214
216
|
messageHash: string;
|
|
215
217
|
/** Identifier for the node that acquired the lock */
|
package/src/errors.ts
CHANGED
|
@@ -22,6 +22,27 @@ export class DutyAlreadySignedError extends Error {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Thrown when the slashing-protection record for an in-flight signing operation can no longer be
|
|
27
|
+
* updated because it is no longer owned by this node - for example, the stuck-duty cleanup loop
|
|
28
|
+
* deleted the SIGNING row while the remote signer was slow. The produced signature must be
|
|
29
|
+
* discarded rather than broadcast: with no protection record in place, a later attempt for the
|
|
30
|
+
* same duty with different data would sign freely, which is slashable equivocation.
|
|
31
|
+
*/
|
|
32
|
+
export class SigningLockLostError extends Error {
|
|
33
|
+
constructor(
|
|
34
|
+
public readonly slot: SlotNumber,
|
|
35
|
+
public readonly dutyType: DutyType,
|
|
36
|
+
public readonly nodeId: string,
|
|
37
|
+
) {
|
|
38
|
+
super(
|
|
39
|
+
`Slashing protection record for ${dutyType} at slot ${slot} was lost before signing completed ` +
|
|
40
|
+
`(node ${nodeId}); discarding signature`,
|
|
41
|
+
);
|
|
42
|
+
this.name = 'SigningLockLostError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
/**
|
|
26
47
|
* Thrown when attempting to sign data that conflicts with an already-signed duty.
|
|
27
48
|
* This means the same validator tried to sign DIFFERENT data for the same slot.
|
package/src/factory.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Factory functions for creating validator HA signers
|
|
3
3
|
*/
|
|
4
|
+
import { createLogger } from '@aztec/foundation/log';
|
|
4
5
|
import { DateProvider } from '@aztec/foundation/timer';
|
|
5
6
|
import { createStore } from '@aztec/kv-store/lmdb-v2';
|
|
6
7
|
import type { LocalSignerConfig, ValidatorHASignerConfig } from '@aztec/stdlib/ha-signing';
|
|
@@ -8,7 +9,7 @@ import { getTelemetryClient } from '@aztec/telemetry-client';
|
|
|
8
9
|
|
|
9
10
|
import { Pool } from 'pg';
|
|
10
11
|
|
|
11
|
-
import { LmdbSlashingProtectionDatabase } from './db/lmdb.js';
|
|
12
|
+
import { LmdbSlashingProtectionDatabase, migrateLmdbSlashingProtectionDatabase } from './db/lmdb.js';
|
|
12
13
|
import { PostgresSlashingProtectionDatabase } from './db/postgres.js';
|
|
13
14
|
import { HASignerMetrics } from './metrics.js';
|
|
14
15
|
import type { CreateHASignerDeps, CreateLocalSignerWithProtectionDeps, SlashingProtectionDatabase } from './types.js';
|
|
@@ -31,7 +32,7 @@ import { ValidatorHASigner } from './validator_ha_signer.js';
|
|
|
31
32
|
* databaseUrl: process.env.DATABASE_URL,
|
|
32
33
|
* nodeId: 'validator-node-1',
|
|
33
34
|
* pollingIntervalMs: 100,
|
|
34
|
-
*
|
|
35
|
+
* peerSigningTimeoutMs: 3000,
|
|
35
36
|
* });
|
|
36
37
|
* signer.start(); // Start background cleanup
|
|
37
38
|
*
|
|
@@ -57,7 +58,8 @@ export async function createHASigner(
|
|
|
57
58
|
const { databaseUrl, poolMaxCount, poolMinCount, poolIdleTimeoutMs, poolConnectionTimeoutMs, ...signerConfig } =
|
|
58
59
|
config;
|
|
59
60
|
|
|
60
|
-
|
|
61
|
+
const databaseUrlValue = databaseUrl?.getValue();
|
|
62
|
+
if (!databaseUrlValue) {
|
|
61
63
|
throw new Error('databaseUrl is required for createHASigner');
|
|
62
64
|
}
|
|
63
65
|
|
|
@@ -68,7 +70,7 @@ export async function createHASigner(
|
|
|
68
70
|
let pool: Pool;
|
|
69
71
|
if (!deps?.pool) {
|
|
70
72
|
pool = new Pool({
|
|
71
|
-
connectionString:
|
|
73
|
+
connectionString: databaseUrlValue,
|
|
72
74
|
max: poolMaxCount ?? 10,
|
|
73
75
|
min: poolMinCount ?? 0,
|
|
74
76
|
idleTimeoutMillis: poolIdleTimeoutMs ?? 10_000,
|
|
@@ -78,6 +80,16 @@ export async function createHASigner(
|
|
|
78
80
|
pool = deps.pool;
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
// pg re-emits idle-client errors (e.g. a Postgres restart severing an idle connection) on the
|
|
84
|
+
// pool. Without an 'error' listener, Node escalates these to an uncaughtException and crashes the
|
|
85
|
+
// process - taking down every HA replica sharing the DB at once. pg destroys and replaces the
|
|
86
|
+
// errored client itself, so logging is the only action needed. Log just message/code, never the
|
|
87
|
+
// raw error object (it can carry connection metadata).
|
|
88
|
+
const log = createLogger('validator-ha-signer:factory');
|
|
89
|
+
pool.on('error', (err: NodeJS.ErrnoException) => {
|
|
90
|
+
log.warn('Postgres pool error on idle client', { message: err.message, code: err.code });
|
|
91
|
+
});
|
|
92
|
+
|
|
81
93
|
// Create database instance
|
|
82
94
|
const db = new PostgresSlashingProtectionDatabase(pool);
|
|
83
95
|
|
|
@@ -100,9 +112,10 @@ export async function createHASigner(
|
|
|
100
112
|
* high-availability (multi-node) setup. It prevents a proposer from sending two
|
|
101
113
|
* proposals for the same slot if the node crashes and restarts mid-proposal.
|
|
102
114
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
115
|
+
* `config.dataDirectory` is required so the protection database is persisted to disk and survives
|
|
116
|
+
* crashes/restarts. Booting without it throws, since an ephemeral store silently drops all
|
|
117
|
+
* double-signing protection across restarts. Set `config.allowEphemeralSigningProtection` to opt
|
|
118
|
+
* into the ephemeral store anyway (dev/test networks only) — a loud warning is logged in that case.
|
|
106
119
|
*
|
|
107
120
|
* @param config - Local signer config
|
|
108
121
|
* @param deps - Optional dependencies (telemetry, date provider).
|
|
@@ -118,11 +131,43 @@ export async function createLocalSignerWithProtection(
|
|
|
118
131
|
const telemetryClient = deps?.telemetryClient ?? getTelemetryClient();
|
|
119
132
|
const dateProvider = deps?.dateProvider ?? new DateProvider();
|
|
120
133
|
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
134
|
+
const log = createLogger('validator-ha-signer:factory');
|
|
135
|
+
|
|
136
|
+
if (!config.dataDirectory) {
|
|
137
|
+
if (!config.allowEphemeralSigningProtection) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
'Local signing protection requires a persistent data directory, but none was configured. ' +
|
|
140
|
+
'Set DATA_DIRECTORY so double-signing protection survives restarts, or explicitly opt into an ' +
|
|
141
|
+
'ephemeral store (dev/test only) with VALIDATOR_ALLOW_EPHEMERAL_SIGNING_PROTECTION=true.',
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
log.warn(
|
|
145
|
+
'Local signing protection is running with an EPHEMERAL store: no data directory is configured. ' +
|
|
146
|
+
'Double-signing protection will NOT survive a restart. This is unsafe for production validators.',
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const kvStore = await createStore(
|
|
151
|
+
'signing-protection',
|
|
152
|
+
LmdbSlashingProtectionDatabase.SCHEMA_VERSION,
|
|
153
|
+
{
|
|
154
|
+
dataDirectory: config.dataDirectory,
|
|
155
|
+
dataStoreMapSizeKb: config.signingProtectionMapSizeKb ?? config.dataStoreMapSizeKb,
|
|
156
|
+
rollupAddress: config.rollupAddress,
|
|
157
|
+
},
|
|
158
|
+
undefined,
|
|
159
|
+
{
|
|
160
|
+
onUpgrade: (dataDirectory, currentVersion, latestVersion) =>
|
|
161
|
+
migrateLmdbSlashingProtectionDatabase(
|
|
162
|
+
dataDirectory,
|
|
163
|
+
currentVersion,
|
|
164
|
+
latestVersion,
|
|
165
|
+
config.signingProtectionMapSizeKb ?? config.dataStoreMapSizeKb,
|
|
166
|
+
),
|
|
167
|
+
schemaVersionMismatchPolicy: 'throw',
|
|
168
|
+
versionFileReadFailurePolicy: 'throw',
|
|
169
|
+
},
|
|
170
|
+
);
|
|
126
171
|
|
|
127
172
|
const db = new LmdbSlashingProtectionDatabase(kvStore, dateProvider);
|
|
128
173
|
|
|
@@ -159,7 +204,7 @@ export function createSignerFromSharedDb(
|
|
|
159
204
|
db: SlashingProtectionDatabase,
|
|
160
205
|
config: Pick<
|
|
161
206
|
ValidatorHASignerConfig,
|
|
162
|
-
'nodeId' | 'pollingIntervalMs' | '
|
|
207
|
+
'nodeId' | 'pollingIntervalMs' | 'peerSigningTimeoutMs' | 'maxStuckDutiesAgeMs' | 'rollupAddress'
|
|
163
208
|
>,
|
|
164
209
|
deps?: CreateLocalSignerWithProtectionDeps,
|
|
165
210
|
): { signer: ValidatorHASigner; db: SlashingProtectionDatabase } {
|
|
@@ -26,6 +26,9 @@ export interface SlashingProtectionServiceDeps {
|
|
|
26
26
|
dateProvider: DateProvider;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** Default max age (ms) of a stuck SIGNING duty before cleanup reclaims it: 2x the 72s Aztec slot duration. */
|
|
30
|
+
export const DEFAULT_MAX_STUCK_DUTIES_AGE_MS = 144_000;
|
|
31
|
+
|
|
29
32
|
/**
|
|
30
33
|
* Slashing Protection Service
|
|
31
34
|
*
|
|
@@ -44,7 +47,7 @@ export interface SlashingProtectionServiceDeps {
|
|
|
44
47
|
export class SlashingProtectionService {
|
|
45
48
|
private readonly log: Logger;
|
|
46
49
|
private readonly pollingIntervalMs: number;
|
|
47
|
-
private readonly
|
|
50
|
+
private readonly peerSigningTimeoutMs: number;
|
|
48
51
|
private readonly maxStuckDutiesAgeMs: number;
|
|
49
52
|
|
|
50
53
|
private readonly metrics: HASignerMetrics;
|
|
@@ -60,9 +63,8 @@ export class SlashingProtectionService {
|
|
|
60
63
|
) {
|
|
61
64
|
this.log = createLogger('slashing-protection');
|
|
62
65
|
this.pollingIntervalMs = config.pollingIntervalMs;
|
|
63
|
-
this.
|
|
64
|
-
|
|
65
|
-
this.maxStuckDutiesAgeMs = config.maxStuckDutiesAgeMs ?? 144_000;
|
|
66
|
+
this.peerSigningTimeoutMs = config.peerSigningTimeoutMs;
|
|
67
|
+
this.maxStuckDutiesAgeMs = config.maxStuckDutiesAgeMs ?? DEFAULT_MAX_STUCK_DUTIES_AGE_MS;
|
|
66
68
|
|
|
67
69
|
this.cleanupRunningPromise = new RunningPromise(this.cleanup.bind(this), this.log, this.maxStuckDutiesAgeMs);
|
|
68
70
|
this.metrics = deps.metrics;
|
|
@@ -132,10 +134,10 @@ export class SlashingProtectionService {
|
|
|
132
134
|
throw new DutyAlreadySignedError(slot, dutyType, record.blockIndexWithinCheckpoint, record.nodeId);
|
|
133
135
|
} else if (record.status === DutyStatus.SIGNING) {
|
|
134
136
|
// Another node is currently signing - check for timeout
|
|
135
|
-
if (this.dateProvider.now() - startTime > this.
|
|
137
|
+
if (this.dateProvider.now() - startTime > this.peerSigningTimeoutMs) {
|
|
136
138
|
this.log.warn(`Timeout waiting for signing to complete for duty ${dutyType} at slot ${slot}`, {
|
|
137
139
|
validatorAddress: validatorAddress.toString(),
|
|
138
|
-
timeoutMs: this.
|
|
140
|
+
timeoutMs: this.peerSigningTimeoutMs,
|
|
139
141
|
signingNodeId: record.nodeId,
|
|
140
142
|
});
|
|
141
143
|
this.metrics.recordDutyAlreadySigned(dutyType);
|
|
@@ -241,10 +243,10 @@ export class SlashingProtectionService {
|
|
|
241
243
|
*/
|
|
242
244
|
async start() {
|
|
243
245
|
// One-time cleanup at startup: remove duties from previous rollup versions
|
|
244
|
-
const numOutdatedRollupDuties = await this.db.cleanupOutdatedRollupDuties(this.config.
|
|
246
|
+
const numOutdatedRollupDuties = await this.db.cleanupOutdatedRollupDuties(this.config.rollupAddress);
|
|
245
247
|
if (numOutdatedRollupDuties > 0) {
|
|
246
248
|
this.log.info(`Cleaned up ${numOutdatedRollupDuties} duties with outdated rollup address at startup`, {
|
|
247
|
-
currentRollupAddress: this.config.
|
|
249
|
+
currentRollupAddress: this.config.rollupAddress.toString(),
|
|
248
250
|
});
|
|
249
251
|
this.metrics.recordCleanup('outdated_rollup', numOutdatedRollupDuties);
|
|
250
252
|
}
|
|
@@ -275,7 +277,10 @@ export class SlashingProtectionService {
|
|
|
275
277
|
* Runs in the background via RunningPromise.
|
|
276
278
|
*/
|
|
277
279
|
private async cleanup() {
|
|
278
|
-
// 1. Clean up stuck duties (our own node's duties that got stuck in 'signing' status)
|
|
280
|
+
// 1. Clean up stuck duties (our own node's duties that got stuck in 'signing' status).
|
|
281
|
+
// This cannot race an in-flight signing: every signing operation is hard-bounded by a timeout
|
|
282
|
+
// clamped below maxStuckDutiesAgeMs / 2 (see ValidatorHASigner), so a live SIGNING row is
|
|
283
|
+
// always released long before it can be considered stuck.
|
|
279
284
|
const numStuckDuties = await this.db.cleanupOwnStuckDuties(this.config.nodeId, this.maxStuckDutiesAgeMs);
|
|
280
285
|
if (numStuckDuties > 0) {
|
|
281
286
|
this.log.verbose(`Cleaned up ${numStuckDuties} stuck duties`, {
|
package/src/types.ts
CHANGED
|
@@ -2,11 +2,14 @@ import { SlotNumber } from '@aztec/foundation/branded-types';
|
|
|
2
2
|
import type { EthAddress } from '@aztec/foundation/eth-address';
|
|
3
3
|
import { DateProvider } from '@aztec/foundation/timer';
|
|
4
4
|
import {
|
|
5
|
+
type AttestationSigningContext,
|
|
6
|
+
type CheckpointProposalSigningContext,
|
|
5
7
|
DutyType,
|
|
6
8
|
type HAProtectedSigningContext,
|
|
7
9
|
type SigningContext,
|
|
8
10
|
type ValidatorHASignerConfig,
|
|
9
11
|
getBlockNumberFromSigningContext as getBlockNumberFromSigningContextFromStdlib,
|
|
12
|
+
getCheckpointNumberFromSigningContext as getCheckpointNumberFromSigningContextFromStdlib,
|
|
10
13
|
isHAProtectedContext,
|
|
11
14
|
} from '@aztec/stdlib/ha-signing';
|
|
12
15
|
import type { TelemetryClient } from '@aztec/telemetry-client';
|
|
@@ -25,8 +28,10 @@ import type {
|
|
|
25
28
|
} from './db/types.js';
|
|
26
29
|
|
|
27
30
|
export type {
|
|
31
|
+
AttestationSigningContext,
|
|
28
32
|
BlockProposalDutyIdentifier,
|
|
29
33
|
CheckAndRecordParams,
|
|
34
|
+
CheckpointProposalSigningContext,
|
|
30
35
|
DeleteDutyParams,
|
|
31
36
|
DutyIdentifier,
|
|
32
37
|
DutyRow,
|
|
@@ -40,6 +45,7 @@ export type {
|
|
|
40
45
|
export { DutyStatus, DutyType, getBlockIndexFromDutyIdentifier, normalizeBlockIndex } from './db/types.js';
|
|
41
46
|
export { isHAProtectedContext };
|
|
42
47
|
export { getBlockNumberFromSigningContextFromStdlib as getBlockNumberFromSigningContext };
|
|
48
|
+
export { getCheckpointNumberFromSigningContextFromStdlib as getCheckpointNumberFromSigningContext };
|
|
43
49
|
|
|
44
50
|
/**
|
|
45
51
|
* Result of tryInsertOrGetExisting operation
|
|
@@ -9,17 +9,19 @@ import type { Buffer32 } from '@aztec/foundation/buffer';
|
|
|
9
9
|
import { EthAddress } from '@aztec/foundation/eth-address';
|
|
10
10
|
import type { Signature } from '@aztec/foundation/eth-signature';
|
|
11
11
|
import { type Logger, createLogger } from '@aztec/foundation/log';
|
|
12
|
-
import type
|
|
12
|
+
import { type DateProvider, executeTimeout } from '@aztec/foundation/timer';
|
|
13
13
|
import {
|
|
14
14
|
type BaseSignerConfig,
|
|
15
15
|
DutyType,
|
|
16
16
|
type HAProtectedSigningContext,
|
|
17
17
|
getBlockNumberFromSigningContext,
|
|
18
|
+
getCheckpointNumberFromSigningContext,
|
|
18
19
|
} from '@aztec/stdlib/ha-signing';
|
|
19
20
|
|
|
20
21
|
import type { DutyIdentifier } from './db/types.js';
|
|
22
|
+
import { SigningLockLostError } from './errors.js';
|
|
21
23
|
import type { HASignerMetrics } from './metrics.js';
|
|
22
|
-
import { SlashingProtectionService } from './slashing_protection_service.js';
|
|
24
|
+
import { DEFAULT_MAX_STUCK_DUTIES_AGE_MS, SlashingProtectionService } from './slashing_protection_service.js';
|
|
23
25
|
import type { SlashingProtectionDatabase } from './types.js';
|
|
24
26
|
|
|
25
27
|
export interface ValidatorHASignerDeps {
|
|
@@ -27,6 +29,9 @@ export interface ValidatorHASignerDeps {
|
|
|
27
29
|
dateProvider: DateProvider;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
/** Default hard timeout (ms) for a single signer call when not configured. */
|
|
33
|
+
const DEFAULT_SIGNER_CALL_TIMEOUT_MS = 30_000;
|
|
34
|
+
|
|
30
35
|
/**
|
|
31
36
|
* Validator High Availability Signer
|
|
32
37
|
*
|
|
@@ -53,6 +58,7 @@ export class ValidatorHASigner {
|
|
|
53
58
|
|
|
54
59
|
private readonly dateProvider: DateProvider;
|
|
55
60
|
private readonly metrics: HASignerMetrics;
|
|
61
|
+
private readonly signerCallTimeoutMs: number;
|
|
56
62
|
|
|
57
63
|
constructor(
|
|
58
64
|
db: SlashingProtectionDatabase,
|
|
@@ -64,17 +70,31 @@ export class ValidatorHASigner {
|
|
|
64
70
|
this.metrics = deps.metrics;
|
|
65
71
|
this.dateProvider = deps.dateProvider;
|
|
66
72
|
|
|
73
|
+
// Clamp the signer-call timeout below half the stuck-duty max age. This maintains the
|
|
74
|
+
// invariant that an in-flight signing always times out and releases its SIGNING row well before
|
|
75
|
+
// stuck-duty cleanup could consider it stuck, so cleanup can never delete a live duty (only
|
|
76
|
+
// signWithProtection writes SIGNING rows, and every path through it is bounded by this timeout).
|
|
77
|
+
// If timers misbehave anyway, the recordSuccess-returns-false throw is the backstop: the duty
|
|
78
|
+
// fails instead of broadcasting an unprotected signature.
|
|
79
|
+
const maxStuckDutiesAgeMs = config.maxStuckDutiesAgeMs ?? DEFAULT_MAX_STUCK_DUTIES_AGE_MS;
|
|
80
|
+
this.signerCallTimeoutMs = Math.min(
|
|
81
|
+
config.signerCallTimeoutMs ?? DEFAULT_SIGNER_CALL_TIMEOUT_MS,
|
|
82
|
+
maxStuckDutiesAgeMs / 2,
|
|
83
|
+
);
|
|
84
|
+
|
|
67
85
|
if (!config.nodeId || config.nodeId === '') {
|
|
68
86
|
throw new Error('NODE_ID is required for high-availability setups');
|
|
69
87
|
}
|
|
70
|
-
this.rollupAddress = config.
|
|
88
|
+
this.rollupAddress = config.rollupAddress;
|
|
71
89
|
this.slashingProtection = new SlashingProtectionService(db, config, {
|
|
72
90
|
metrics: deps.metrics,
|
|
73
91
|
dateProvider: deps.dateProvider,
|
|
74
92
|
});
|
|
93
|
+
|
|
75
94
|
this.log.info('Validator HA Signer initialized with slashing protection', {
|
|
76
95
|
nodeId: config.nodeId,
|
|
77
96
|
rollupAddress: this.rollupAddress.toString(),
|
|
97
|
+
signerCallTimeoutMs: this.signerCallTimeoutMs,
|
|
78
98
|
});
|
|
79
99
|
}
|
|
80
100
|
|
|
@@ -125,17 +145,29 @@ export class ValidatorHASigner {
|
|
|
125
145
|
// Acquire lock and get the token for ownership verification
|
|
126
146
|
// DutyAlreadySignedError and SlashingProtectionError may be thrown here and are recorded in the service
|
|
127
147
|
const blockNumber = getBlockNumberFromSigningContext(context);
|
|
148
|
+
const checkpointNumber = getCheckpointNumberFromSigningContext(context);
|
|
128
149
|
const lockToken = await this.slashingProtection.checkAndRecord({
|
|
129
150
|
...dutyIdentifier,
|
|
130
151
|
blockNumber,
|
|
152
|
+
checkpointNumber,
|
|
131
153
|
messageHash: messageHash.toString(),
|
|
132
154
|
nodeId: this.config.nodeId,
|
|
133
155
|
});
|
|
134
156
|
|
|
135
|
-
// Perform signing
|
|
157
|
+
// Perform signing under a hard timeout. If the signer hangs, executeTimeout aborts and rejects;
|
|
158
|
+
// the orphaned signFn promise resolving later is discarded (never broadcast). A timeout takes the
|
|
159
|
+
// same failure path as any signing error: release the lock so the duty can be retried safely.
|
|
136
160
|
let signature: Signature;
|
|
137
161
|
try {
|
|
138
|
-
signature = await
|
|
162
|
+
signature = await executeTimeout(
|
|
163
|
+
() => signFn(messageHash),
|
|
164
|
+
this.signerCallTimeoutMs,
|
|
165
|
+
() =>
|
|
166
|
+
new Error(
|
|
167
|
+
`Signing operation for ${dutyType} at slot ${context.slot} timed out after ` +
|
|
168
|
+
`${this.signerCallTimeoutMs}ms`,
|
|
169
|
+
),
|
|
170
|
+
);
|
|
139
171
|
} catch (error: any) {
|
|
140
172
|
// Delete duty to allow retry (only succeeds if we own the lock)
|
|
141
173
|
await this.slashingProtection.deleteDuty({ ...dutyIdentifier, lockToken });
|
|
@@ -143,13 +175,21 @@ export class ValidatorHASigner {
|
|
|
143
175
|
throw error;
|
|
144
176
|
}
|
|
145
177
|
|
|
146
|
-
// Record success (only succeeds if we own the lock)
|
|
147
|
-
|
|
178
|
+
// Record success (only succeeds if we still own the lock).
|
|
179
|
+
// A false result means our SIGNING row is gone or no longer ours (e.g. deleted by stuck-duty
|
|
180
|
+
// cleanup while signing was slow). We must not broadcast this signature: without a protection
|
|
181
|
+
// record, a later attempt for the same duty with different data would sign freely (slashable).
|
|
182
|
+
// Do not delete the duty here - we no longer own it, and another node may legitimately hold it.
|
|
183
|
+
const recorded = await this.slashingProtection.recordSuccess({
|
|
148
184
|
...dutyIdentifier,
|
|
149
185
|
signature,
|
|
150
186
|
nodeId: this.config.nodeId,
|
|
151
187
|
lockToken,
|
|
152
188
|
});
|
|
189
|
+
if (!recorded) {
|
|
190
|
+
this.metrics.recordSigningError(dutyType);
|
|
191
|
+
throw new SigningLockLostError(context.slot, dutyType, this.config.nodeId);
|
|
192
|
+
}
|
|
153
193
|
|
|
154
194
|
const duration = this.dateProvider.now() - startTime;
|
|
155
195
|
this.metrics.recordSigningSuccess(dutyType, duration);
|