@aztec/validator-ha-signer 0.0.1-commit.3469e52 → 0.0.1-commit.3895657bc

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 (46) hide show
  1. package/README.md +10 -2
  2. package/dest/db/index.d.ts +2 -1
  3. package/dest/db/index.d.ts.map +1 -1
  4. package/dest/db/index.js +1 -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/postgres.d.ts +20 -4
  9. package/dest/db/postgres.d.ts.map +1 -1
  10. package/dest/db/postgres.js +44 -17
  11. package/dest/db/schema.d.ts +17 -10
  12. package/dest/db/schema.d.ts.map +1 -1
  13. package/dest/db/schema.js +39 -22
  14. package/dest/db/types.d.ts +43 -19
  15. package/dest/db/types.d.ts.map +1 -1
  16. package/dest/db/types.js +30 -15
  17. package/dest/factory.d.ts +22 -4
  18. package/dest/factory.d.ts.map +1 -1
  19. package/dest/factory.js +50 -5
  20. package/dest/metrics.d.ts +51 -0
  21. package/dest/metrics.d.ts.map +1 -0
  22. package/dest/metrics.js +103 -0
  23. package/dest/slashing_protection_service.d.ts +19 -6
  24. package/dest/slashing_protection_service.d.ts.map +1 -1
  25. package/dest/slashing_protection_service.js +55 -15
  26. package/dest/types.d.ts +32 -72
  27. package/dest/types.d.ts.map +1 -1
  28. package/dest/types.js +3 -20
  29. package/dest/validator_ha_signer.d.ts +15 -6
  30. package/dest/validator_ha_signer.d.ts.map +1 -1
  31. package/dest/validator_ha_signer.js +24 -11
  32. package/package.json +10 -5
  33. package/src/db/index.ts +1 -0
  34. package/src/db/lmdb.ts +264 -0
  35. package/src/db/postgres.ts +45 -12
  36. package/src/db/schema.ts +41 -22
  37. package/src/db/types.ts +67 -17
  38. package/src/factory.ts +61 -4
  39. package/src/metrics.ts +138 -0
  40. package/src/slashing_protection_service.ts +77 -19
  41. package/src/types.ts +50 -103
  42. package/src/validator_ha_signer.ts +41 -15
  43. package/dest/config.d.ts +0 -79
  44. package/dest/config.d.ts.map +0 -1
  45. package/dest/config.js +0 -73
  46. package/src/config.ts +0 -125
package/src/db/lmdb.ts ADDED
@@ -0,0 +1,264 @@
1
+ /**
2
+ * LMDB implementation of SlashingProtectionDatabase
3
+ *
4
+ * Provides local (single-node) double-signing protection using LMDB as the backend.
5
+ * Suitable for nodes that do NOT run in a high-availability multi-node setup.
6
+ *
7
+ * The LMDB store is single-writer, making setIfNotExists inherently atomic.
8
+ * This means we get crash-restart protection without needing an external database.
9
+ */
10
+ import { SlotNumber } from '@aztec/foundation/branded-types';
11
+ import { randomBytes } from '@aztec/foundation/crypto/random';
12
+ import { EthAddress } from '@aztec/foundation/eth-address';
13
+ import { type Logger, createLogger } from '@aztec/foundation/log';
14
+ import type { DateProvider } from '@aztec/foundation/timer';
15
+ import type { AztecAsyncKVStore, AztecAsyncMap } from '@aztec/kv-store';
16
+
17
+ import type { SlashingProtectionDatabase, TryInsertOrGetResult } from '../types.js';
18
+ import {
19
+ type CheckAndRecordParams,
20
+ DutyStatus,
21
+ DutyType,
22
+ type StoredDutyRecord,
23
+ getBlockIndexFromDutyIdentifier,
24
+ recordFromFields,
25
+ } from './types.js';
26
+
27
+ function dutyKey(
28
+ rollupAddress: string,
29
+ validatorAddress: string,
30
+ slot: string,
31
+ dutyType: string,
32
+ blockIndexWithinCheckpoint: number,
33
+ ): string {
34
+ return `${rollupAddress}:${validatorAddress}:${slot}:${dutyType}:${blockIndexWithinCheckpoint}`;
35
+ }
36
+
37
+ /**
38
+ * LMDB-backed implementation of SlashingProtectionDatabase.
39
+ *
40
+ * Provides single-node double-signing protection that survives crashes and restarts.
41
+ * Does not provide cross-node coordination (that requires the PostgreSQL implementation).
42
+ */
43
+ export class LmdbSlashingProtectionDatabase implements SlashingProtectionDatabase {
44
+ public static readonly SCHEMA_VERSION = 1;
45
+
46
+ private readonly duties: AztecAsyncMap<string, StoredDutyRecord>;
47
+ private readonly log: Logger;
48
+
49
+ constructor(
50
+ private readonly store: AztecAsyncKVStore,
51
+ private readonly dateProvider: DateProvider,
52
+ ) {
53
+ this.log = createLogger('slashing-protection:lmdb');
54
+ this.duties = store.openMap<string, StoredDutyRecord>('signing-protection-duties');
55
+ }
56
+
57
+ /**
58
+ * Atomically try to insert a new duty record, or get the existing one if present.
59
+ *
60
+ * LMDB is single-writer so the read-then-write inside transactionAsync is naturally atomic.
61
+ */
62
+ public async tryInsertOrGetExisting(params: CheckAndRecordParams): Promise<TryInsertOrGetResult> {
63
+ const blockIndexWithinCheckpoint = getBlockIndexFromDutyIdentifier(params);
64
+ const key = dutyKey(
65
+ params.rollupAddress.toString(),
66
+ params.validatorAddress.toString(),
67
+ params.slot.toString(),
68
+ params.dutyType,
69
+ blockIndexWithinCheckpoint,
70
+ );
71
+
72
+ const lockToken = randomBytes(16).toString('hex');
73
+ const now = this.dateProvider.now();
74
+
75
+ const result = await this.store.transactionAsync(async () => {
76
+ const existing = await this.duties.getAsync(key);
77
+ if (existing) {
78
+ return { isNew: false as const, record: { ...existing, lockToken: '' } };
79
+ }
80
+
81
+ const newRecord: StoredDutyRecord = {
82
+ rollupAddress: params.rollupAddress.toString(),
83
+ validatorAddress: params.validatorAddress.toString(),
84
+ slot: params.slot.toString(),
85
+ blockNumber: params.blockNumber.toString(),
86
+ blockIndexWithinCheckpoint,
87
+ dutyType: params.dutyType,
88
+ status: DutyStatus.SIGNING,
89
+ messageHash: params.messageHash,
90
+ nodeId: params.nodeId,
91
+ lockToken,
92
+ startedAtMs: now,
93
+ };
94
+ await this.duties.set(key, newRecord);
95
+ return { isNew: true as const, record: newRecord };
96
+ });
97
+
98
+ if (result.isNew) {
99
+ this.log.debug(`Acquired lock for duty ${params.dutyType} at slot ${params.slot}`, {
100
+ validatorAddress: params.validatorAddress.toString(),
101
+ nodeId: params.nodeId,
102
+ });
103
+ }
104
+
105
+ return { isNew: result.isNew, record: recordFromFields(result.record) };
106
+ }
107
+
108
+ /**
109
+ * Update a duty to 'signed' status with the signature.
110
+ * Only succeeds if the lockToken matches.
111
+ */
112
+ public updateDutySigned(
113
+ rollupAddress: EthAddress,
114
+ validatorAddress: EthAddress,
115
+ slot: SlotNumber,
116
+ dutyType: DutyType,
117
+ signature: string,
118
+ lockToken: string,
119
+ blockIndexWithinCheckpoint: number,
120
+ ): Promise<boolean> {
121
+ const key = dutyKey(
122
+ rollupAddress.toString(),
123
+ validatorAddress.toString(),
124
+ slot.toString(),
125
+ dutyType,
126
+ blockIndexWithinCheckpoint,
127
+ );
128
+
129
+ return this.store.transactionAsync(async () => {
130
+ const existing = await this.duties.getAsync(key);
131
+ if (!existing) {
132
+ this.log.warn('Failed to update duty to signed: duty not found', {
133
+ rollupAddress: rollupAddress.toString(),
134
+ validatorAddress: validatorAddress.toString(),
135
+ slot: slot.toString(),
136
+ dutyType,
137
+ blockIndexWithinCheckpoint,
138
+ });
139
+ return false;
140
+ }
141
+
142
+ if (existing.lockToken !== lockToken) {
143
+ this.log.warn('Failed to update duty to signed: invalid token', {
144
+ rollupAddress: rollupAddress.toString(),
145
+ validatorAddress: validatorAddress.toString(),
146
+ slot: slot.toString(),
147
+ dutyType,
148
+ blockIndexWithinCheckpoint,
149
+ });
150
+ return false;
151
+ }
152
+
153
+ await this.duties.set(key, {
154
+ ...existing,
155
+ status: DutyStatus.SIGNED,
156
+ signature,
157
+ completedAtMs: this.dateProvider.now(),
158
+ });
159
+
160
+ return true;
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Delete a duty record.
166
+ * Only succeeds if the lockToken matches.
167
+ */
168
+ public deleteDuty(
169
+ rollupAddress: EthAddress,
170
+ validatorAddress: EthAddress,
171
+ slot: SlotNumber,
172
+ dutyType: DutyType,
173
+ lockToken: string,
174
+ blockIndexWithinCheckpoint: number,
175
+ ): Promise<boolean> {
176
+ const key = dutyKey(
177
+ rollupAddress.toString(),
178
+ validatorAddress.toString(),
179
+ slot.toString(),
180
+ dutyType,
181
+ blockIndexWithinCheckpoint,
182
+ );
183
+
184
+ return this.store.transactionAsync(async () => {
185
+ const existing = await this.duties.getAsync(key);
186
+ if (!existing || existing.lockToken !== lockToken) {
187
+ this.log.warn('Failed to delete duty: invalid token or duty not found', {
188
+ rollupAddress: rollupAddress.toString(),
189
+ validatorAddress: validatorAddress.toString(),
190
+ slot: slot.toString(),
191
+ dutyType,
192
+ blockIndexWithinCheckpoint,
193
+ });
194
+ return false;
195
+ }
196
+
197
+ await this.duties.delete(key);
198
+ return true;
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Cleanup own stuck duties (SIGNING status older than maxAgeMs).
204
+ */
205
+ public cleanupOwnStuckDuties(nodeId: string, maxAgeMs: number): Promise<number> {
206
+ const cutoffMs = this.dateProvider.now() - maxAgeMs;
207
+
208
+ return this.store.transactionAsync(async () => {
209
+ const keysToDelete: string[] = [];
210
+ for await (const [key, record] of this.duties.entriesAsync()) {
211
+ if (record.nodeId === nodeId && record.status === DutyStatus.SIGNING && record.startedAtMs < cutoffMs) {
212
+ keysToDelete.push(key);
213
+ }
214
+ }
215
+ for (const key of keysToDelete) {
216
+ await this.duties.delete(key);
217
+ }
218
+ return keysToDelete.length;
219
+ });
220
+ }
221
+
222
+ /**
223
+ * Cleanup duties with outdated rollup address.
224
+ *
225
+ * This is always a no-op for the LMDB implementation: the underlying store is created via
226
+ * DatabaseVersionManager (in factory.ts), which already resets the entire data directory at
227
+ * startup whenever the rollup address changes.
228
+ */
229
+ public cleanupOutdatedRollupDuties(_currentRollupAddress: EthAddress): Promise<number> {
230
+ return Promise.resolve(0);
231
+ }
232
+
233
+ /**
234
+ * Cleanup old signed duties older than maxAgeMs.
235
+ */
236
+ public cleanupOldDuties(maxAgeMs: number): Promise<number> {
237
+ const cutoffMs = this.dateProvider.now() - maxAgeMs;
238
+
239
+ return this.store.transactionAsync(async () => {
240
+ const keysToDelete: string[] = [];
241
+ for await (const [key, record] of this.duties.entriesAsync()) {
242
+ if (
243
+ record.status === DutyStatus.SIGNED &&
244
+ record.completedAtMs !== undefined &&
245
+ record.completedAtMs < cutoffMs
246
+ ) {
247
+ keysToDelete.push(key);
248
+ }
249
+ }
250
+ for (const key of keysToDelete) {
251
+ await this.duties.delete(key);
252
+ }
253
+ return keysToDelete.length;
254
+ });
255
+ }
256
+
257
+ /**
258
+ * Close the underlying LMDB store.
259
+ */
260
+ public async close(): Promise<void> {
261
+ await this.store.close();
262
+ this.log.debug('LMDB slashing protection database closed');
263
+ }
264
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * PostgreSQL implementation of SlashingProtectionDatabase
3
3
  */
4
- import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types';
4
+ import { SlotNumber } from '@aztec/foundation/branded-types';
5
5
  import { randomBytes } from '@aztec/foundation/crypto/random';
6
6
  import { EthAddress } from '@aztec/foundation/eth-address';
7
7
  import { type Logger, createLogger } from '@aztec/foundation/log';
@@ -11,6 +11,8 @@ import type { QueryResult, QueryResultRow } from 'pg';
11
11
 
12
12
  import type { SlashingProtectionDatabase, TryInsertOrGetResult } from '../types.js';
13
13
  import {
14
+ CLEANUP_OLD_DUTIES,
15
+ CLEANUP_OUTDATED_ROLLUP_DUTIES,
14
16
  CLEANUP_OWN_STUCK_DUTIES,
15
17
  DELETE_DUTY,
16
18
  INSERT_OR_GET_DUTY,
@@ -18,7 +20,7 @@ import {
18
20
  UPDATE_DUTY_SIGNED,
19
21
  } from './schema.js';
20
22
  import type { CheckAndRecordParams, DutyRow, DutyType, InsertOrGetRow, ValidatorDutyRecord } from './types.js';
21
- import { getBlockIndexFromDutyIdentifier } from './types.js';
23
+ import { getBlockIndexFromDutyIdentifier, recordFromFields } from './types.js';
22
24
 
23
25
  /**
24
26
  * Minimal pool interface for database operations.
@@ -101,6 +103,7 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
101
103
  const result = await retry<QueryResult<InsertOrGetRow>>(
102
104
  async () => {
103
105
  const queryResult: QueryResult<InsertOrGetRow> = await this.pool.query(INSERT_OR_GET_DUTY, [
106
+ params.rollupAddress.toString(),
104
107
  params.validatorAddress.toString(),
105
108
  params.slot.toString(),
106
109
  params.blockNumber.toString(),
@@ -148,6 +151,7 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
148
151
  * @returns true if the update succeeded, false if token didn't match or duty not found
149
152
  */
150
153
  async updateDutySigned(
154
+ rollupAddress: EthAddress,
151
155
  validatorAddress: EthAddress,
152
156
  slot: SlotNumber,
153
157
  dutyType: DutyType,
@@ -157,6 +161,7 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
157
161
  ): Promise<boolean> {
158
162
  const result = await this.pool.query(UPDATE_DUTY_SIGNED, [
159
163
  signature,
164
+ rollupAddress.toString(),
160
165
  validatorAddress.toString(),
161
166
  slot.toString(),
162
167
  dutyType,
@@ -166,6 +171,7 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
166
171
 
167
172
  if (result.rowCount === 0) {
168
173
  this.log.warn('Failed to update duty to signed status: invalid token or duty not found', {
174
+ rollupAddress: rollupAddress.toString(),
169
175
  validatorAddress: validatorAddress.toString(),
170
176
  slot: slot.toString(),
171
177
  dutyType,
@@ -184,6 +190,7 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
184
190
  * @returns true if the delete succeeded, false if token didn't match or duty not found
185
191
  */
186
192
  async deleteDuty(
193
+ rollupAddress: EthAddress,
187
194
  validatorAddress: EthAddress,
188
195
  slot: SlotNumber,
189
196
  dutyType: DutyType,
@@ -191,6 +198,7 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
191
198
  blockIndexWithinCheckpoint: number,
192
199
  ): Promise<boolean> {
193
200
  const result = await this.pool.query(DELETE_DUTY, [
201
+ rollupAddress.toString(),
194
202
  validatorAddress.toString(),
195
203
  slot.toString(),
196
204
  dutyType,
@@ -200,6 +208,7 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
200
208
 
201
209
  if (result.rowCount === 0) {
202
210
  this.log.warn('Failed to delete duty: invalid token or duty not found', {
211
+ rollupAddress: rollupAddress.toString(),
203
212
  validatorAddress: validatorAddress.toString(),
204
213
  slot: slot.toString(),
205
214
  dutyType,
@@ -211,13 +220,16 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
211
220
  }
212
221
 
213
222
  /**
214
- * Convert a database row to a ValidatorDutyRecord
223
+ * Convert a database row to a ValidatorDutyRecord.
224
+ * Maps snake_case column names to StoredDutyRecord (camelCase, ms timestamps),
225
+ * then delegates to the shared recordFromFields() converter.
215
226
  */
216
227
  private rowToRecord(row: DutyRow): ValidatorDutyRecord {
217
- return {
218
- validatorAddress: EthAddress.fromString(row.validator_address),
219
- slot: SlotNumber.fromString(row.slot),
220
- blockNumber: BlockNumber.fromString(row.block_number),
228
+ return recordFromFields({
229
+ rollupAddress: row.rollup_address,
230
+ validatorAddress: row.validator_address,
231
+ slot: row.slot,
232
+ blockNumber: row.block_number,
221
233
  blockIndexWithinCheckpoint: row.block_index_within_checkpoint,
222
234
  dutyType: row.duty_type,
223
235
  status: row.status,
@@ -225,10 +237,10 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
225
237
  signature: row.signature ?? undefined,
226
238
  nodeId: row.node_id,
227
239
  lockToken: row.lock_token,
228
- startedAt: row.started_at,
229
- completedAt: row.completed_at ?? undefined,
240
+ startedAtMs: row.started_at.getTime(),
241
+ completedAtMs: row.completed_at?.getTime(),
230
242
  errorMessage: row.error_message ?? undefined,
231
- };
243
+ });
232
244
  }
233
245
 
234
246
  /**
@@ -244,8 +256,29 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
244
256
  * @returns the number of duties cleaned up
245
257
  */
246
258
  async cleanupOwnStuckDuties(nodeId: string, maxAgeMs: number): Promise<number> {
247
- const cutoff = new Date(Date.now() - maxAgeMs);
248
- const result = await this.pool.query(CLEANUP_OWN_STUCK_DUTIES, [nodeId, cutoff]);
259
+ const result = await this.pool.query(CLEANUP_OWN_STUCK_DUTIES, [nodeId, maxAgeMs]);
260
+ return result.rowCount ?? 0;
261
+ }
262
+
263
+ /**
264
+ * Cleanup duties with outdated rollup address.
265
+ * Removes all duties where the rollup address doesn't match the current one.
266
+ * Used after a rollup upgrade to clean up duties for the old rollup.
267
+ * @returns the number of duties cleaned up
268
+ */
269
+ async cleanupOutdatedRollupDuties(currentRollupAddress: EthAddress): Promise<number> {
270
+ const result = await this.pool.query(CLEANUP_OUTDATED_ROLLUP_DUTIES, [currentRollupAddress.toString()]);
271
+ return result.rowCount ?? 0;
272
+ }
273
+
274
+ /**
275
+ * Cleanup old signed duties.
276
+ * Removes only signed duties older than the specified age.
277
+ * Does not remove 'signing' duties as they may be in progress.
278
+ * @returns the number of duties cleaned up
279
+ */
280
+ async cleanupOldDuties(maxAgeMs: number): Promise<number> {
281
+ const result = await this.pool.query(CLEANUP_OLD_DUTIES, [maxAgeMs]);
249
282
  return result.rowCount ?? 0;
250
283
  }
251
284
  }
package/src/db/schema.ts CHANGED
@@ -16,12 +16,13 @@ export const SCHEMA_VERSION = 1;
16
16
  */
17
17
  export const CREATE_VALIDATOR_DUTIES_TABLE = `
18
18
  CREATE TABLE IF NOT EXISTS validator_duties (
19
+ rollup_address VARCHAR(42) NOT NULL,
19
20
  validator_address VARCHAR(42) NOT NULL,
20
21
  slot BIGINT NOT NULL,
21
22
  block_number BIGINT NOT NULL,
22
23
  block_index_within_checkpoint INTEGER NOT NULL DEFAULT 0,
23
24
  duty_type VARCHAR(30) NOT NULL CHECK (duty_type IN ('BLOCK_PROPOSAL', 'CHECKPOINT_PROPOSAL', 'ATTESTATION', 'ATTESTATIONS_AND_SIGNERS', 'GOVERNANCE_VOTE', 'SLASHING_VOTE')),
24
- status VARCHAR(20) NOT NULL CHECK (status IN ('signing', 'signed', 'failed')),
25
+ status VARCHAR(20) NOT NULL CHECK (status IN ('signing', 'signed')),
25
26
  message_hash VARCHAR(66) NOT NULL,
26
27
  signature VARCHAR(132),
27
28
  node_id VARCHAR(255) NOT NULL,
@@ -30,7 +31,7 @@ CREATE TABLE IF NOT EXISTS validator_duties (
30
31
  completed_at TIMESTAMP,
31
32
  error_message TEXT,
32
33
 
33
- PRIMARY KEY (validator_address, slot, duty_type, block_index_within_checkpoint),
34
+ PRIMARY KEY (rollup_address, validator_address, slot, duty_type, block_index_within_checkpoint),
34
35
  CHECK (completed_at IS NULL OR completed_at >= started_at)
35
36
  );
36
37
  `;
@@ -101,6 +102,7 @@ SELECT version FROM schema_version ORDER BY version DESC LIMIT 1;
101
102
  export const INSERT_OR_GET_DUTY = `
102
103
  WITH inserted AS (
103
104
  INSERT INTO validator_duties (
105
+ rollup_address,
104
106
  validator_address,
105
107
  slot,
106
108
  block_number,
@@ -111,9 +113,10 @@ WITH inserted AS (
111
113
  node_id,
112
114
  lock_token,
113
115
  started_at
114
- ) VALUES ($1, $2, $3, $4, $5, 'signing', $6, $7, $8, CURRENT_TIMESTAMP)
115
- ON CONFLICT (validator_address, slot, duty_type, block_index_within_checkpoint) DO NOTHING
116
+ ) VALUES ($1, $2, $3, $4, $5, $6, 'signing', $7, $8, $9, CURRENT_TIMESTAMP)
117
+ ON CONFLICT (rollup_address, validator_address, slot, duty_type, block_index_within_checkpoint) DO NOTHING
116
118
  RETURNING
119
+ rollup_address,
117
120
  validator_address,
118
121
  slot,
119
122
  block_number,
@@ -132,6 +135,7 @@ WITH inserted AS (
132
135
  SELECT * FROM inserted
133
136
  UNION ALL
134
137
  SELECT
138
+ rollup_address,
135
139
  validator_address,
136
140
  slot,
137
141
  block_number,
@@ -147,10 +151,11 @@ SELECT
147
151
  error_message,
148
152
  FALSE as is_new
149
153
  FROM validator_duties
150
- WHERE validator_address = $1
151
- AND slot = $2
152
- AND duty_type = $5
153
- AND block_index_within_checkpoint = $4
154
+ WHERE rollup_address = $1
155
+ AND validator_address = $2
156
+ AND slot = $3
157
+ AND duty_type = $6
158
+ AND block_index_within_checkpoint = $5
154
159
  AND NOT EXISTS (SELECT 1 FROM inserted);
155
160
  `;
156
161
 
@@ -162,12 +167,13 @@ UPDATE validator_duties
162
167
  SET status = 'signed',
163
168
  signature = $1,
164
169
  completed_at = CURRENT_TIMESTAMP
165
- WHERE validator_address = $2
166
- AND slot = $3
167
- AND duty_type = $4
168
- AND block_index_within_checkpoint = $5
170
+ WHERE rollup_address = $2
171
+ AND validator_address = $3
172
+ AND slot = $4
173
+ AND duty_type = $5
174
+ AND block_index_within_checkpoint = $6
169
175
  AND status = 'signing'
170
- AND lock_token = $6;
176
+ AND lock_token = $7;
171
177
  `;
172
178
 
173
179
  /**
@@ -176,12 +182,13 @@ WHERE validator_address = $2
176
182
  */
177
183
  export const DELETE_DUTY = `
178
184
  DELETE FROM validator_duties
179
- WHERE validator_address = $1
180
- AND slot = $2
181
- AND duty_type = $3
182
- AND block_index_within_checkpoint = $4
185
+ WHERE rollup_address = $1
186
+ AND validator_address = $2
187
+ AND slot = $3
188
+ AND duty_type = $4
189
+ AND block_index_within_checkpoint = $5
183
190
  AND status = 'signing'
184
- AND lock_token = $5;
191
+ AND lock_token = $6;
185
192
  `;
186
193
 
187
194
  /**
@@ -196,23 +203,34 @@ WHERE status = 'signed'
196
203
 
197
204
  /**
198
205
  * Query to clean up old duties (for maintenance)
199
- * Removes duties older than a specified timestamp
206
+ * Removes SIGNED duties older than a specified age (in milliseconds)
200
207
  */
201
208
  export const CLEANUP_OLD_DUTIES = `
202
209
  DELETE FROM validator_duties
203
- WHERE status IN ('signing', 'signed', 'failed')
204
- AND started_at < $1;
210
+ WHERE status = 'signed'
211
+ AND started_at < CURRENT_TIMESTAMP - ($1 || ' milliseconds')::INTERVAL;
205
212
  `;
206
213
 
207
214
  /**
208
215
  * Query to cleanup own stuck duties
209
216
  * Removes duties in 'signing' status for a specific node that are older than maxAgeMs
217
+ * Uses DB's CURRENT_TIMESTAMP to avoid clock skew issues between nodes
210
218
  */
211
219
  export const CLEANUP_OWN_STUCK_DUTIES = `
212
220
  DELETE FROM validator_duties
213
221
  WHERE node_id = $1
214
222
  AND status = 'signing'
215
- AND started_at < $2;
223
+ AND started_at < CURRENT_TIMESTAMP - ($2 || ' milliseconds')::INTERVAL;
224
+ `;
225
+
226
+ /**
227
+ * Query to cleanup duties with outdated rollup address
228
+ * Removes all duties where the rollup address doesn't match the current one
229
+ * Used after a rollup upgrade to clean up duties for the old rollup
230
+ */
231
+ export const CLEANUP_OUTDATED_ROLLUP_DUTIES = `
232
+ DELETE FROM validator_duties
233
+ WHERE rollup_address != $1;
216
234
  `;
217
235
 
218
236
  /**
@@ -231,6 +249,7 @@ export const DROP_SCHEMA_VERSION_TABLE = `DROP TABLE IF EXISTS schema_version;`;
231
249
  */
232
250
  export const GET_STUCK_DUTIES = `
233
251
  SELECT
252
+ rollup_address,
234
253
  validator_address,
235
254
  slot,
236
255
  block_number,
package/src/db/types.ts CHANGED
@@ -1,11 +1,18 @@
1
- import type { BlockNumber, CheckpointNumber, IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types';
2
- import type { EthAddress } from '@aztec/foundation/eth-address';
1
+ import {
2
+ BlockNumber,
3
+ type CheckpointNumber,
4
+ type IndexWithinCheckpoint,
5
+ SlotNumber,
6
+ } from '@aztec/foundation/branded-types';
7
+ import { EthAddress } from '@aztec/foundation/eth-address';
3
8
  import type { Signature } from '@aztec/foundation/eth-signature';
9
+ import { DutyType } from '@aztec/stdlib/ha-signing';
4
10
 
5
11
  /**
6
12
  * Row type from PostgreSQL query
7
13
  */
8
14
  export interface DutyRow {
15
+ rollup_address: string;
9
16
  validator_address: string;
10
17
  slot: string;
11
18
  block_number: string;
@@ -22,24 +29,34 @@ export interface DutyRow {
22
29
  }
23
30
 
24
31
  /**
25
- * Row type from INSERT_OR_GET_DUTY query (includes is_new flag)
32
+ * Plain-primitive representation of a duty record suitable for serialization
33
+ * (e.g. msgpackr for LMDB). All domain types are stored as their string/number
34
+ * equivalents. Timestamps are Unix milliseconds.
26
35
  */
27
- export interface InsertOrGetRow extends DutyRow {
28
- is_new: boolean;
36
+ export interface StoredDutyRecord {
37
+ rollupAddress: string;
38
+ validatorAddress: string;
39
+ slot: string;
40
+ blockNumber: string;
41
+ blockIndexWithinCheckpoint: number;
42
+ dutyType: DutyType;
43
+ status: DutyStatus;
44
+ messageHash: string;
45
+ signature?: string;
46
+ nodeId: string;
47
+ lockToken: string;
48
+ /** Unix timestamp in milliseconds when signing started */
49
+ startedAtMs: number;
50
+ /** Unix timestamp in milliseconds when signing completed */
51
+ completedAtMs?: number;
52
+ errorMessage?: string;
29
53
  }
30
54
 
31
55
  /**
32
- * Type of validator duty being performed
56
+ * Row type from INSERT_OR_GET_DUTY query (includes is_new flag)
33
57
  */
34
- export enum DutyType {
35
- BLOCK_PROPOSAL = 'BLOCK_PROPOSAL',
36
- CHECKPOINT_PROPOSAL = 'CHECKPOINT_PROPOSAL',
37
- ATTESTATION = 'ATTESTATION',
38
- ATTESTATIONS_AND_SIGNERS = 'ATTESTATIONS_AND_SIGNERS',
39
- GOVERNANCE_VOTE = 'GOVERNANCE_VOTE',
40
- SLASHING_VOTE = 'SLASHING_VOTE',
41
- AUTH_REQUEST = 'AUTH_REQUEST',
42
- TXS = 'TXS',
58
+ export interface InsertOrGetRow extends DutyRow {
59
+ is_new: boolean;
43
60
  }
44
61
 
45
62
  /**
@@ -50,10 +67,16 @@ export enum DutyStatus {
50
67
  SIGNED = 'signed',
51
68
  }
52
69
 
70
+ // Re-export DutyType from stdlib
71
+ export { DutyType };
72
+
53
73
  /**
54
- * Record of a validator duty in the database
74
+ * Rich representation of a validator duty, with branded types and Date objects.
75
+ * This is the common output type returned by all SlashingProtectionDatabase implementations.
55
76
  */
56
77
  export interface ValidatorDutyRecord {
78
+ /** Ethereum address of the rollup contract */
79
+ rollupAddress: EthAddress;
57
80
  /** Ethereum address of the validator */
58
81
  validatorAddress: EthAddress;
59
82
  /** Slot number for this duty */
@@ -78,15 +101,41 @@ export interface ValidatorDutyRecord {
78
101
  startedAt: Date;
79
102
  /** When the duty signing was completed (success or failure) */
80
103
  completedAt?: Date;
81
- /** Error message if status is 'failed' */
104
+ /** Error message (currently unused) */
82
105
  errorMessage?: string;
83
106
  }
84
107
 
108
+ /**
109
+ * Convert a {@link StoredDutyRecord} (plain-primitive wire format) to a
110
+ * {@link ValidatorDutyRecord} (rich domain type).
111
+ *
112
+ * Shared by LMDB and any future non-Postgres backend implementations.
113
+ */
114
+ export function recordFromFields(stored: StoredDutyRecord): ValidatorDutyRecord {
115
+ return {
116
+ rollupAddress: EthAddress.fromString(stored.rollupAddress),
117
+ validatorAddress: EthAddress.fromString(stored.validatorAddress),
118
+ slot: SlotNumber.fromString(stored.slot),
119
+ blockNumber: BlockNumber.fromString(stored.blockNumber),
120
+ blockIndexWithinCheckpoint: stored.blockIndexWithinCheckpoint,
121
+ dutyType: stored.dutyType,
122
+ status: stored.status,
123
+ messageHash: stored.messageHash,
124
+ signature: stored.signature,
125
+ nodeId: stored.nodeId,
126
+ lockToken: stored.lockToken,
127
+ startedAt: new Date(stored.startedAtMs),
128
+ completedAt: stored.completedAtMs !== undefined ? new Date(stored.completedAtMs) : undefined,
129
+ errorMessage: stored.errorMessage,
130
+ };
131
+ }
132
+
85
133
  /**
86
134
  * Duty identifier for block proposals.
87
135
  * blockIndexWithinCheckpoint is REQUIRED and must be >= 0.
88
136
  */
89
137
  export interface BlockProposalDutyIdentifier {
138
+ rollupAddress: EthAddress;
90
139
  validatorAddress: EthAddress;
91
140
  slot: SlotNumber;
92
141
  /** Block index within checkpoint (0, 1, 2...). Required for block proposals. */
@@ -99,6 +148,7 @@ export interface BlockProposalDutyIdentifier {
99
148
  * blockIndexWithinCheckpoint is not applicable (internally stored as -1).
100
149
  */
101
150
  export interface OtherDutyIdentifier {
151
+ rollupAddress: EthAddress;
102
152
  validatorAddress: EthAddress;
103
153
  slot: SlotNumber;
104
154
  dutyType: