@aztec/validator-ha-signer 0.0.1-commit.001888fc

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.
Files changed (62) hide show
  1. package/README.md +195 -0
  2. package/dest/db/index.d.ts +5 -0
  3. package/dest/db/index.d.ts.map +1 -0
  4. package/dest/db/index.js +4 -0
  5. package/dest/db/lmdb.d.ts +66 -0
  6. package/dest/db/lmdb.d.ts.map +1 -0
  7. package/dest/db/lmdb.js +188 -0
  8. package/dest/db/migrations/1_initial-schema.d.ts +9 -0
  9. package/dest/db/migrations/1_initial-schema.d.ts.map +1 -0
  10. package/dest/db/migrations/1_initial-schema.js +20 -0
  11. package/dest/db/postgres.d.ts +86 -0
  12. package/dest/db/postgres.d.ts.map +1 -0
  13. package/dest/db/postgres.js +208 -0
  14. package/dest/db/schema.d.ts +96 -0
  15. package/dest/db/schema.d.ts.map +1 -0
  16. package/dest/db/schema.js +230 -0
  17. package/dest/db/test_helper.d.ts +10 -0
  18. package/dest/db/test_helper.d.ts.map +1 -0
  19. package/dest/db/test_helper.js +14 -0
  20. package/dest/db/types.d.ts +185 -0
  21. package/dest/db/types.d.ts.map +1 -0
  22. package/dest/db/types.js +64 -0
  23. package/dest/errors.d.ts +34 -0
  24. package/dest/errors.d.ts.map +1 -0
  25. package/dest/errors.js +34 -0
  26. package/dest/factory.d.ts +60 -0
  27. package/dest/factory.d.ts.map +1 -0
  28. package/dest/factory.js +115 -0
  29. package/dest/metrics.d.ts +51 -0
  30. package/dest/metrics.d.ts.map +1 -0
  31. package/dest/metrics.js +103 -0
  32. package/dest/migrations.d.ts +15 -0
  33. package/dest/migrations.d.ts.map +1 -0
  34. package/dest/migrations.js +53 -0
  35. package/dest/slashing_protection_service.d.ts +93 -0
  36. package/dest/slashing_protection_service.d.ts.map +1 -0
  37. package/dest/slashing_protection_service.js +236 -0
  38. package/dest/test/pglite_pool.d.ts +92 -0
  39. package/dest/test/pglite_pool.d.ts.map +1 -0
  40. package/dest/test/pglite_pool.js +210 -0
  41. package/dest/types.d.ts +99 -0
  42. package/dest/types.d.ts.map +1 -0
  43. package/dest/types.js +4 -0
  44. package/dest/validator_ha_signer.d.ts +79 -0
  45. package/dest/validator_ha_signer.d.ts.map +1 -0
  46. package/dest/validator_ha_signer.js +140 -0
  47. package/package.json +110 -0
  48. package/src/db/index.ts +4 -0
  49. package/src/db/lmdb.ts +264 -0
  50. package/src/db/migrations/1_initial-schema.ts +26 -0
  51. package/src/db/postgres.ts +284 -0
  52. package/src/db/schema.ts +267 -0
  53. package/src/db/test_helper.ts +17 -0
  54. package/src/db/types.ts +251 -0
  55. package/src/errors.ts +47 -0
  56. package/src/factory.ts +139 -0
  57. package/src/metrics.ts +138 -0
  58. package/src/migrations.ts +75 -0
  59. package/src/slashing_protection_service.ts +308 -0
  60. package/src/test/pglite_pool.ts +256 -0
  61. package/src/types.ts +154 -0
  62. package/src/validator_ha_signer.ts +183 -0
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Vendored pg-compatible Pool/Client wrapper for PGlite.
3
+ *
4
+ * Copied from @middle-management/pglite-pg-adapter v0.0.3
5
+ * https://www.npmjs.com/package/@middle-management/pglite-pg-adapter
6
+ *
7
+ * Modifications:
8
+ * - Converted to ESM and TypeScript
9
+ * - Uses PGliteInterface instead of PGlite class to avoid TypeScript
10
+ * type mismatches from ESM/CJS dual package resolution with private fields
11
+ * - Simplified rowCount calculation to handle CTEs properly
12
+ */
13
+ import type { PGliteInterface } from '@electric-sql/pglite';
14
+ import { EventEmitter } from 'events';
15
+ import type { QueryResult, QueryResultRow } from 'pg';
16
+ import { Readable, Writable } from 'stream';
17
+
18
+ export interface PoolConfig {
19
+ pglite: PGliteInterface;
20
+ max?: number;
21
+ min?: number;
22
+ }
23
+
24
+ interface ClientConfig {
25
+ pglite: PGliteInterface;
26
+ host?: string;
27
+ port?: number;
28
+ ssl?: boolean;
29
+ }
30
+
31
+ export class Client extends EventEmitter {
32
+ protected pglite: PGliteInterface;
33
+ protected _connected = false;
34
+ readonly host: string;
35
+ readonly port: number;
36
+ readonly ssl: boolean;
37
+ readonly connection: object;
38
+
39
+ // Stub implementations for pg compatibility
40
+ readonly copyFrom = (): Writable => new Writable();
41
+ readonly copyTo = (): Readable => new Readable();
42
+ readonly pauseDrain = (): void => {};
43
+ readonly resumeDrain = (): void => {};
44
+ readonly escapeLiteral = (str: string): string => `'${str.replace(/'/g, "''")}'`;
45
+ readonly escapeIdentifier = (str: string): string => `"${str.replace(/"/g, '""')}"`;
46
+ readonly setTypeParser = (): void => {};
47
+ readonly getTypeParser = (): ((value: string) => unknown) => (value: string) => value;
48
+
49
+ constructor(config: ClientConfig) {
50
+ super();
51
+ this.pglite = config.pglite;
52
+ this.host = config.host || 'localhost';
53
+ this.port = config.port || 5432;
54
+ this.ssl = typeof config.ssl === 'boolean' ? config.ssl : !!config.ssl;
55
+ this.connection = {};
56
+ }
57
+
58
+ connect(): Promise<void> {
59
+ if (this._connected) {
60
+ return Promise.resolve();
61
+ }
62
+ this._connected = true;
63
+ this.emit('connect');
64
+ return Promise.resolve();
65
+ }
66
+
67
+ end(): Promise<void> {
68
+ if (!this._connected) {
69
+ return Promise.resolve();
70
+ }
71
+ this._connected = false;
72
+ this.emit('end');
73
+ return Promise.resolve();
74
+ }
75
+
76
+ async query<R extends QueryResultRow = any>(text: string, values?: any[]): Promise<QueryResult<R>> {
77
+ if (!this._connected) {
78
+ throw new Error('Client is not connected');
79
+ }
80
+ const result = await this.pglite.query<R>(text, values);
81
+ return this.convertPGliteResult(result);
82
+ }
83
+
84
+ protected convertPGliteResult<R extends QueryResultRow>(result: {
85
+ rows: R[];
86
+ fields: Array<{ name: string; dataTypeID: number }>;
87
+ affectedRows?: number;
88
+ }): QueryResult<R> {
89
+ return {
90
+ command: '',
91
+ rowCount: 'affectedRows' in result ? (result.affectedRows ?? 0) : result.rows.length,
92
+ oid: 0,
93
+ fields: result.fields.map(field => ({
94
+ name: field.name,
95
+ tableID: 0,
96
+ columnID: 0,
97
+ dataTypeID: field.dataTypeID,
98
+ dataTypeSize: -1,
99
+ dataTypeModifier: -1,
100
+ format: 'text',
101
+ })),
102
+ rows: result.rows,
103
+ };
104
+ }
105
+
106
+ get connected(): boolean {
107
+ return this._connected;
108
+ }
109
+ }
110
+
111
+ export class Pool extends EventEmitter {
112
+ private clients: PoolClient[] = [];
113
+ private availableClients: PoolClient[] = [];
114
+ private waitingQueue: Array<(client: PoolClient) => void> = [];
115
+ private _ended = false;
116
+ private pglite: PGliteInterface;
117
+ private _config: PoolConfig;
118
+
119
+ readonly expiredCount = 0;
120
+ readonly options: PoolConfig;
121
+
122
+ constructor(config: PoolConfig) {
123
+ super();
124
+ this._config = { max: 10, min: 0, ...config };
125
+ this.pglite = config.pglite;
126
+ this.options = config;
127
+ }
128
+
129
+ get totalCount(): number {
130
+ return this.clients.length;
131
+ }
132
+
133
+ get idleCount(): number {
134
+ return this.availableClients.length;
135
+ }
136
+
137
+ get waitingCount(): number {
138
+ return this.waitingQueue.length;
139
+ }
140
+
141
+ get ending(): boolean {
142
+ return this._ended;
143
+ }
144
+
145
+ get ended(): boolean {
146
+ return this._ended;
147
+ }
148
+
149
+ connect(): Promise<PoolClient> {
150
+ if (this._ended) {
151
+ return Promise.reject(new Error('Pool is ended'));
152
+ }
153
+
154
+ if (this.availableClients.length > 0) {
155
+ const client = this.availableClients.pop()!;
156
+ client._markInUse();
157
+ return Promise.resolve(client);
158
+ }
159
+
160
+ if (this.clients.length < (this._config.max || 10)) {
161
+ const client = new PoolClient(this.pglite, this);
162
+ this.clients.push(client);
163
+ return Promise.resolve(client);
164
+ }
165
+
166
+ return new Promise(resolve => {
167
+ this.waitingQueue.push(resolve);
168
+ });
169
+ }
170
+
171
+ async query<R extends QueryResultRow = any>(text: string, values?: any[]): Promise<QueryResult<R>> {
172
+ const client = await this.connect();
173
+ try {
174
+ return await client.query<R>(text, values);
175
+ } finally {
176
+ client.release();
177
+ }
178
+ }
179
+
180
+ releaseClient(client: PoolClient): void {
181
+ const index = this.clients.indexOf(client);
182
+ if (index !== -1) {
183
+ client._markAvailable();
184
+ if (this.waitingQueue.length > 0) {
185
+ const resolve = this.waitingQueue.shift()!;
186
+ client._markInUse();
187
+ resolve(client);
188
+ } else {
189
+ this.availableClients.push(client);
190
+ }
191
+ }
192
+ }
193
+
194
+ end(): Promise<void> {
195
+ this._ended = true;
196
+ this.clients.forEach(client => client._markReleased());
197
+ this.clients = [];
198
+ this.availableClients = [];
199
+ this.emit('end');
200
+ return Promise.resolve();
201
+ }
202
+ }
203
+
204
+ export class PoolClient extends Client {
205
+ private pool: Pool;
206
+ private _released = false;
207
+ private _inUse = true;
208
+ private _userReleased = false;
209
+
210
+ constructor(pglite: PGliteInterface, pool: Pool) {
211
+ super({ pglite });
212
+ this.pool = pool;
213
+ this._connected = true;
214
+ }
215
+
216
+ override async query<R extends QueryResultRow = any>(text: string, values?: any[]): Promise<QueryResult<R>> {
217
+ if (this._userReleased && !this._inUse) {
218
+ throw new Error('Client has been released back to the pool');
219
+ }
220
+ const result = await this.pglite.query<R>(text, values);
221
+ return this.convertPGliteResult(result);
222
+ }
223
+
224
+ release(): void {
225
+ if (this._released || this._userReleased) {
226
+ return;
227
+ }
228
+ this._userReleased = true;
229
+ this.pool.releaseClient(this);
230
+ }
231
+
232
+ override end(): Promise<void> {
233
+ this.release();
234
+ return Promise.resolve();
235
+ }
236
+
237
+ _markInUse(): void {
238
+ this._inUse = true;
239
+ this._userReleased = false;
240
+ }
241
+
242
+ _markAvailable(): void {
243
+ this._inUse = false;
244
+ this._userReleased = false;
245
+ }
246
+
247
+ _markReleased(): void {
248
+ this._released = true;
249
+ this._inUse = false;
250
+ this._userReleased = true;
251
+ }
252
+
253
+ override get connected(): boolean {
254
+ return this._connected && !this._released;
255
+ }
256
+ }
package/src/types.ts ADDED
@@ -0,0 +1,154 @@
1
+ import { SlotNumber } from '@aztec/foundation/branded-types';
2
+ import type { EthAddress } from '@aztec/foundation/eth-address';
3
+ import { DateProvider } from '@aztec/foundation/timer';
4
+ import {
5
+ DutyType,
6
+ type HAProtectedSigningContext,
7
+ type SigningContext,
8
+ type ValidatorHASignerConfig,
9
+ getBlockNumberFromSigningContext as getBlockNumberFromSigningContextFromStdlib,
10
+ isHAProtectedContext,
11
+ } from '@aztec/stdlib/ha-signing';
12
+ import type { TelemetryClient } from '@aztec/telemetry-client';
13
+
14
+ import type { Pool } from 'pg';
15
+
16
+ import type {
17
+ BlockProposalDutyIdentifier,
18
+ CheckAndRecordParams,
19
+ DeleteDutyParams,
20
+ DutyIdentifier,
21
+ DutyRow,
22
+ OtherDutyIdentifier,
23
+ RecordSuccessParams,
24
+ ValidatorDutyRecord,
25
+ } from './db/types.js';
26
+
27
+ export type {
28
+ BlockProposalDutyIdentifier,
29
+ CheckAndRecordParams,
30
+ DeleteDutyParams,
31
+ DutyIdentifier,
32
+ DutyRow,
33
+ HAProtectedSigningContext,
34
+ OtherDutyIdentifier,
35
+ RecordSuccessParams,
36
+ SigningContext,
37
+ ValidatorDutyRecord,
38
+ ValidatorHASignerConfig,
39
+ };
40
+ export { DutyStatus, DutyType, getBlockIndexFromDutyIdentifier, normalizeBlockIndex } from './db/types.js';
41
+ export { isHAProtectedContext };
42
+ export { getBlockNumberFromSigningContextFromStdlib as getBlockNumberFromSigningContext };
43
+
44
+ /**
45
+ * Result of tryInsertOrGetExisting operation
46
+ */
47
+ export interface TryInsertOrGetResult {
48
+ /** True if we inserted a new record, false if we got an existing record */
49
+ isNew: boolean;
50
+ /** The record (either newly inserted or existing) */
51
+ record: ValidatorDutyRecord;
52
+ }
53
+
54
+ /**
55
+ * deps for creating an HA signer
56
+ */
57
+ export interface CreateHASignerDeps {
58
+ /**
59
+ * Optional PostgreSQL connection pool
60
+ * If provided, databaseUrl and poolConfig are ignored
61
+ */
62
+ pool?: Pool;
63
+ /**
64
+ * Optional telemetry client for metrics
65
+ */
66
+ telemetryClient?: TelemetryClient;
67
+ /**
68
+ * Optional date provider for timestamps
69
+ */
70
+ dateProvider?: DateProvider;
71
+ }
72
+
73
+ /**
74
+ * deps for creating a local signing protection signer
75
+ */
76
+ export type CreateLocalSignerWithProtectionDeps = Omit<CreateHASignerDeps, 'pool'>;
77
+
78
+ /**
79
+ * Database interface for slashing protection operations
80
+ * This abstraction allows for different database implementations (PostgreSQL, SQLite, etc.)
81
+ *
82
+ * The interface is designed around 3 core operations:
83
+ * 1. tryInsertOrGetExisting - Atomically insert or get existing record (eliminates race conditions)
84
+ * 2. updateDutySigned - Update to signed status on success
85
+ * 3. deleteDuty - Delete a duty record on failure
86
+ */
87
+ export interface SlashingProtectionDatabase {
88
+ /**
89
+ * Atomically try to insert a new duty record, or get the existing one if present.
90
+ *
91
+ * @returns { isNew: true, record } if we successfully inserted and acquired the lock
92
+ * @returns { isNew: false, record } if a record already exists (caller should handle based on status)
93
+ */
94
+ tryInsertOrGetExisting(params: CheckAndRecordParams): Promise<TryInsertOrGetResult>;
95
+
96
+ /**
97
+ * Update a duty to 'signed' status with the signature.
98
+ * Only succeeds if the lockToken matches (caller must be the one who created the duty).
99
+ *
100
+ * @returns true if the update succeeded, false if token didn't match or duty not found
101
+ */
102
+ updateDutySigned(
103
+ rollupAddress: EthAddress,
104
+ validatorAddress: EthAddress,
105
+ slot: SlotNumber,
106
+ dutyType: DutyType,
107
+ signature: string,
108
+ lockToken: string,
109
+ blockIndexWithinCheckpoint: number,
110
+ ): Promise<boolean>;
111
+
112
+ /**
113
+ * Delete a duty record.
114
+ * Only succeeds if the lockToken matches (caller must be the one who created the duty).
115
+ * Used when signing fails to allow another node/attempt to retry.
116
+ *
117
+ * @returns true if the delete succeeded, false if token didn't match or duty not found
118
+ */
119
+ deleteDuty(
120
+ rollupAddress: EthAddress,
121
+ validatorAddress: EthAddress,
122
+ slot: SlotNumber,
123
+ dutyType: DutyType,
124
+ lockToken: string,
125
+ blockIndexWithinCheckpoint: number,
126
+ ): Promise<boolean>;
127
+
128
+ /**
129
+ * Cleanup own stuck duties
130
+ * @returns the number of duties cleaned up
131
+ */
132
+ cleanupOwnStuckDuties(nodeId: string, maxAgeMs: number): Promise<number>;
133
+
134
+ /**
135
+ * Cleanup duties with outdated rollup address.
136
+ * Removes all duties where the rollup address doesn't match the current one.
137
+ * Used after a rollup upgrade to clean up duties for the old rollup.
138
+ * @returns the number of duties cleaned up
139
+ */
140
+ cleanupOutdatedRollupDuties(currentRollupAddress: EthAddress): Promise<number>;
141
+
142
+ /**
143
+ * Cleanup old signed duties.
144
+ * Removes only signed duties older than the specified age.
145
+ * @returns the number of duties cleaned up
146
+ */
147
+ cleanupOldDuties(maxAgeMs: number): Promise<number>;
148
+
149
+ /**
150
+ * Close the database connection.
151
+ * Should be called during graceful shutdown.
152
+ */
153
+ close(): Promise<void>;
154
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Validator High Availability Signer
3
+ *
4
+ * Wraps signing operations with distributed locking and slashing protection.
5
+ * This ensures that even with multiple validator nodes running, only one
6
+ * node will sign for a given duty (slot + duty type).
7
+ */
8
+ import type { Buffer32 } from '@aztec/foundation/buffer';
9
+ import { EthAddress } from '@aztec/foundation/eth-address';
10
+ import type { Signature } from '@aztec/foundation/eth-signature';
11
+ import { type Logger, createLogger } from '@aztec/foundation/log';
12
+ import type { DateProvider } from '@aztec/foundation/timer';
13
+ import {
14
+ type BaseSignerConfig,
15
+ DutyType,
16
+ type HAProtectedSigningContext,
17
+ getBlockNumberFromSigningContext,
18
+ } from '@aztec/stdlib/ha-signing';
19
+
20
+ import type { DutyIdentifier } from './db/types.js';
21
+ import type { HASignerMetrics } from './metrics.js';
22
+ import { SlashingProtectionService } from './slashing_protection_service.js';
23
+ import type { SlashingProtectionDatabase } from './types.js';
24
+
25
+ export interface ValidatorHASignerDeps {
26
+ metrics: HASignerMetrics;
27
+ dateProvider: DateProvider;
28
+ }
29
+
30
+ /**
31
+ * Validator High Availability Signer
32
+ *
33
+ * Provides signing capabilities with distributed locking for validators
34
+ * in a high-availability setup.
35
+ *
36
+ * Usage:
37
+ * ```
38
+ * const signer = new ValidatorHASigner(db, config);
39
+ *
40
+ * // Sign with slashing protection
41
+ * const signature = await signer.signWithProtection(
42
+ * validatorAddress,
43
+ * messageHash,
44
+ * { slot: 100n, blockNumber: 50n, dutyType: 'BLOCK_PROPOSAL' },
45
+ * async (root) => localSigner.signMessage(root),
46
+ * );
47
+ * ```
48
+ */
49
+ export class ValidatorHASigner {
50
+ private readonly log: Logger;
51
+ private readonly slashingProtection: SlashingProtectionService;
52
+ private readonly rollupAddress: EthAddress;
53
+
54
+ private readonly dateProvider: DateProvider;
55
+ private readonly metrics: HASignerMetrics;
56
+
57
+ constructor(
58
+ db: SlashingProtectionDatabase,
59
+ private readonly config: BaseSignerConfig,
60
+ deps: ValidatorHASignerDeps,
61
+ ) {
62
+ this.log = createLogger('validator-ha-signer');
63
+
64
+ this.metrics = deps.metrics;
65
+ this.dateProvider = deps.dateProvider;
66
+
67
+ if (!config.nodeId || config.nodeId === '') {
68
+ throw new Error('NODE_ID is required for high-availability setups');
69
+ }
70
+ this.rollupAddress = config.l1Contracts.rollupAddress;
71
+ this.slashingProtection = new SlashingProtectionService(db, config, {
72
+ metrics: deps.metrics,
73
+ dateProvider: deps.dateProvider,
74
+ });
75
+ this.log.info('Validator HA Signer initialized with slashing protection', {
76
+ nodeId: config.nodeId,
77
+ rollupAddress: this.rollupAddress.toString(),
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Sign a message with slashing protection.
83
+ *
84
+ * This method:
85
+ * 1. Acquires a distributed lock for (validator, slot, dutyType)
86
+ * 2. Calls the provided signing function
87
+ * 3. Records the result (success or failure)
88
+ *
89
+ * @param validatorAddress - The validator's Ethereum address
90
+ * @param messageHash - The hash to be signed
91
+ * @param context - The signing context (HA-protected duty types only)
92
+ * @param signFn - Function that performs the actual signing
93
+ * @returns The signature
94
+ *
95
+ * @throws DutyAlreadySignedError if the duty was already signed (expected in HA)
96
+ * @throws SlashingProtectionError if attempting to sign different data for same slot (expected in HA)
97
+ */
98
+ async signWithProtection(
99
+ validatorAddress: EthAddress,
100
+ messageHash: Buffer32,
101
+ context: HAProtectedSigningContext,
102
+ signFn: (messageHash: Buffer32) => Promise<Signature>,
103
+ ): Promise<Signature> {
104
+ const startTime = this.dateProvider.now();
105
+ const dutyType = context.dutyType;
106
+
107
+ let dutyIdentifier: DutyIdentifier;
108
+ if (context.dutyType === DutyType.BLOCK_PROPOSAL) {
109
+ dutyIdentifier = {
110
+ rollupAddress: this.rollupAddress,
111
+ validatorAddress,
112
+ slot: context.slot,
113
+ blockIndexWithinCheckpoint: context.blockIndexWithinCheckpoint,
114
+ dutyType: context.dutyType,
115
+ };
116
+ } else {
117
+ dutyIdentifier = {
118
+ rollupAddress: this.rollupAddress,
119
+ validatorAddress,
120
+ slot: context.slot,
121
+ dutyType: context.dutyType,
122
+ };
123
+ }
124
+
125
+ // Acquire lock and get the token for ownership verification
126
+ // DutyAlreadySignedError and SlashingProtectionError may be thrown here and are recorded in the service
127
+ const blockNumber = getBlockNumberFromSigningContext(context);
128
+ const lockToken = await this.slashingProtection.checkAndRecord({
129
+ ...dutyIdentifier,
130
+ blockNumber,
131
+ messageHash: messageHash.toString(),
132
+ nodeId: this.config.nodeId,
133
+ });
134
+
135
+ // Perform signing
136
+ let signature: Signature;
137
+ try {
138
+ signature = await signFn(messageHash);
139
+ } catch (error: any) {
140
+ // Delete duty to allow retry (only succeeds if we own the lock)
141
+ await this.slashingProtection.deleteDuty({ ...dutyIdentifier, lockToken });
142
+ this.metrics.recordSigningError(dutyType);
143
+ throw error;
144
+ }
145
+
146
+ // Record success (only succeeds if we own the lock)
147
+ await this.slashingProtection.recordSuccess({
148
+ ...dutyIdentifier,
149
+ signature,
150
+ nodeId: this.config.nodeId,
151
+ lockToken,
152
+ });
153
+
154
+ const duration = this.dateProvider.now() - startTime;
155
+ this.metrics.recordSigningSuccess(dutyType, duration);
156
+
157
+ return signature;
158
+ }
159
+
160
+ /**
161
+ * Get the node ID for this signer
162
+ */
163
+ get nodeId(): string {
164
+ return this.config.nodeId;
165
+ }
166
+
167
+ /**
168
+ * Start the HA signer background tasks (cleanup of stuck duties).
169
+ * Should be called after construction and before signing operations.
170
+ */
171
+ async start() {
172
+ await this.slashingProtection.start();
173
+ }
174
+
175
+ /**
176
+ * Stop the HA signer background tasks and close database connection.
177
+ * Should be called during graceful shutdown.
178
+ */
179
+ async stop() {
180
+ await this.slashingProtection.stop();
181
+ await this.slashingProtection.close();
182
+ }
183
+ }