@aztec/validator-ha-signer 0.0.1-commit.6d3c34e → 0.0.1-commit.7035c9bd6

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 (58) hide show
  1. package/README.md +50 -37
  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 +37 -6
  9. package/dest/db/postgres.d.ts.map +1 -1
  10. package/dest/db/postgres.js +86 -28
  11. package/dest/db/schema.d.ts +21 -10
  12. package/dest/db/schema.d.ts.map +1 -1
  13. package/dest/db/schema.js +49 -20
  14. package/dest/db/types.d.ts +109 -33
  15. package/dest/db/types.d.ts.map +1 -1
  16. package/dest/db/types.js +57 -8
  17. package/dest/errors.d.ts +9 -5
  18. package/dest/errors.d.ts.map +1 -1
  19. package/dest/errors.js +7 -4
  20. package/dest/factory.d.ts +42 -15
  21. package/dest/factory.d.ts.map +1 -1
  22. package/dest/factory.js +80 -15
  23. package/dest/metrics.d.ts +51 -0
  24. package/dest/metrics.d.ts.map +1 -0
  25. package/dest/metrics.js +103 -0
  26. package/dest/migrations.d.ts +1 -1
  27. package/dest/migrations.d.ts.map +1 -1
  28. package/dest/migrations.js +13 -2
  29. package/dest/slashing_protection_service.d.ts +25 -6
  30. package/dest/slashing_protection_service.d.ts.map +1 -1
  31. package/dest/slashing_protection_service.js +74 -22
  32. package/dest/test/pglite_pool.d.ts +92 -0
  33. package/dest/test/pglite_pool.d.ts.map +1 -0
  34. package/dest/test/pglite_pool.js +210 -0
  35. package/dest/types.d.ts +40 -16
  36. package/dest/types.d.ts.map +1 -1
  37. package/dest/types.js +4 -1
  38. package/dest/validator_ha_signer.d.ts +18 -13
  39. package/dest/validator_ha_signer.d.ts.map +1 -1
  40. package/dest/validator_ha_signer.js +45 -36
  41. package/package.json +15 -10
  42. package/src/db/index.ts +1 -0
  43. package/src/db/lmdb.ts +264 -0
  44. package/src/db/postgres.ts +109 -27
  45. package/src/db/schema.ts +51 -20
  46. package/src/db/types.ts +166 -32
  47. package/src/errors.ts +7 -2
  48. package/src/factory.ts +99 -15
  49. package/src/metrics.ts +138 -0
  50. package/src/migrations.ts +17 -1
  51. package/src/slashing_protection_service.ts +119 -27
  52. package/src/test/pglite_pool.ts +256 -0
  53. package/src/types.ts +65 -16
  54. package/src/validator_ha_signer.ts +64 -45
  55. package/dest/config.d.ts +0 -47
  56. package/dest/config.d.ts.map +0 -1
  57. package/dest/config.js +0 -64
  58. package/src/config.ts +0 -116
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,14 +1,18 @@
1
1
  /**
2
2
  * PostgreSQL implementation of SlashingProtectionDatabase
3
3
  */
4
+ import { SlotNumber } from '@aztec/foundation/branded-types';
4
5
  import { randomBytes } from '@aztec/foundation/crypto/random';
5
6
  import { EthAddress } from '@aztec/foundation/eth-address';
6
7
  import { type Logger, createLogger } from '@aztec/foundation/log';
8
+ import { makeBackoff, retry } from '@aztec/foundation/retry';
7
9
 
8
- import type { Pool, QueryResult } from 'pg';
10
+ import type { QueryResult, QueryResultRow } from 'pg';
9
11
 
10
12
  import type { SlashingProtectionDatabase, TryInsertOrGetResult } from '../types.js';
11
13
  import {
14
+ CLEANUP_OLD_DUTIES,
15
+ CLEANUP_OUTDATED_ROLLUP_DUTIES,
12
16
  CLEANUP_OWN_STUCK_DUTIES,
13
17
  DELETE_DUTY,
14
18
  INSERT_OR_GET_DUTY,
@@ -16,6 +20,16 @@ import {
16
20
  UPDATE_DUTY_SIGNED,
17
21
  } from './schema.js';
18
22
  import type { CheckAndRecordParams, DutyRow, DutyType, InsertOrGetRow, ValidatorDutyRecord } from './types.js';
23
+ import { getBlockIndexFromDutyIdentifier, recordFromFields } from './types.js';
24
+
25
+ /**
26
+ * Minimal pool interface for database operations.
27
+ * Both pg.Pool and test adapters (e.g., PGlite) satisfy this interface.
28
+ */
29
+ export interface QueryablePool {
30
+ query<R extends QueryResultRow = any>(text: string, values?: any[]): Promise<QueryResult<R>>;
31
+ end(): Promise<void>;
32
+ }
19
33
 
20
34
  /**
21
35
  * PostgreSQL implementation of the slashing protection database
@@ -23,7 +37,7 @@ import type { CheckAndRecordParams, DutyRow, DutyType, InsertOrGetRow, Validator
23
37
  export class PostgresSlashingProtectionDatabase implements SlashingProtectionDatabase {
24
38
  private readonly log: Logger;
25
39
 
26
- constructor(private readonly pool: Pool) {
40
+ constructor(private readonly pool: QueryablePool) {
27
41
  this.log = createLogger('slashing-protection:postgres');
28
42
  }
29
43
 
@@ -48,13 +62,13 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
48
62
  dbVersion = result.rows[0].version;
49
63
  } catch {
50
64
  throw new Error(
51
- 'Database schema not initialized. Please run migrations first: aztec migrate up --database-url <url>',
65
+ 'Database schema not initialized. Please run migrations first: aztec migrate-ha-db up --database-url <url>',
52
66
  );
53
67
  }
54
68
 
55
69
  if (dbVersion < SCHEMA_VERSION) {
56
70
  throw new Error(
57
- `Database schema version ${dbVersion} is outdated (expected ${SCHEMA_VERSION}). Please run migrations: aztec migrate up --database-url <url>`,
71
+ `Database schema version ${dbVersion} is outdated (expected ${SCHEMA_VERSION}). Please run migrations: aztec migrate-ha-db up --database-url <url>`,
58
72
  );
59
73
  }
60
74
 
@@ -72,24 +86,55 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
72
86
  *
73
87
  * @returns { isNew: true, record } if we successfully inserted and acquired the lock
74
88
  * @returns { isNew: false, record } if a record already exists. lock_token is empty if the record already exists.
89
+ *
90
+ * Retries if no rows are returned, which can happen under high concurrency
91
+ * when another transaction just committed the row but it's not yet visible.
75
92
  */
76
93
  async tryInsertOrGetExisting(params: CheckAndRecordParams): Promise<TryInsertOrGetResult> {
77
94
  // create a token for ownership verification
78
95
  const lockToken = randomBytes(16).toString('hex');
79
96
 
80
- const result: QueryResult<InsertOrGetRow> = await this.pool.query(INSERT_OR_GET_DUTY, [
81
- params.validatorAddress.toString(),
82
- params.slot.toString(),
83
- params.blockNumber.toString(),
84
- params.dutyType,
85
- params.messageHash,
86
- params.nodeId,
87
- lockToken,
88
- ]);
97
+ // Use fast retries with custom backoff: 10ms, 20ms, 30ms (then stop)
98
+ const fastBackoff = makeBackoff([0.01, 0.02, 0.03]);
99
+
100
+ // Get the normalized block index using type-safe helper
101
+ const blockIndexWithinCheckpoint = getBlockIndexFromDutyIdentifier(params);
102
+
103
+ const result = await retry<QueryResult<InsertOrGetRow>>(
104
+ async () => {
105
+ const queryResult: QueryResult<InsertOrGetRow> = await this.pool.query(INSERT_OR_GET_DUTY, [
106
+ params.rollupAddress.toString(),
107
+ params.validatorAddress.toString(),
108
+ params.slot.toString(),
109
+ params.blockNumber.toString(),
110
+ blockIndexWithinCheckpoint,
111
+ params.dutyType,
112
+ params.messageHash,
113
+ params.nodeId,
114
+ lockToken,
115
+ ]);
116
+
117
+ // Throw error if no rows to trigger retry
118
+ if (queryResult.rows.length === 0) {
119
+ throw new Error('INSERT_OR_GET_DUTY returned no rows');
120
+ }
121
+
122
+ return queryResult;
123
+ },
124
+ `INSERT_OR_GET_DUTY for node ${params.nodeId}`,
125
+ fastBackoff,
126
+ this.log,
127
+ true,
128
+ );
89
129
 
90
130
  if (result.rows.length === 0) {
91
- // This shouldn't happen - the query always returns either the inserted or existing row
92
- throw new Error('INSERT_OR_GET_DUTY returned no rows');
131
+ // this should never happen as the retry function should throw if it still fails after retries
132
+ throw new Error('INSERT_OR_GET_DUTY returned no rows after retries');
133
+ }
134
+
135
+ if (result.rows.length > 1) {
136
+ // this should never happen if database constraints are correct (PRIMARY KEY should prevent duplicates)
137
+ throw new Error(`INSERT_OR_GET_DUTY returned ${result.rows.length} rows (expected exactly 1).`);
93
138
  }
94
139
 
95
140
  const row = result.rows[0];
@@ -106,25 +151,31 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
106
151
  * @returns true if the update succeeded, false if token didn't match or duty not found
107
152
  */
108
153
  async updateDutySigned(
154
+ rollupAddress: EthAddress,
109
155
  validatorAddress: EthAddress,
110
- slot: bigint,
156
+ slot: SlotNumber,
111
157
  dutyType: DutyType,
112
158
  signature: string,
113
159
  lockToken: string,
160
+ blockIndexWithinCheckpoint: number,
114
161
  ): Promise<boolean> {
115
162
  const result = await this.pool.query(UPDATE_DUTY_SIGNED, [
116
163
  signature,
164
+ rollupAddress.toString(),
117
165
  validatorAddress.toString(),
118
166
  slot.toString(),
119
167
  dutyType,
168
+ blockIndexWithinCheckpoint,
120
169
  lockToken,
121
170
  ]);
122
171
 
123
172
  if (result.rowCount === 0) {
124
173
  this.log.warn('Failed to update duty to signed status: invalid token or duty not found', {
174
+ rollupAddress: rollupAddress.toString(),
125
175
  validatorAddress: validatorAddress.toString(),
126
176
  slot: slot.toString(),
127
177
  dutyType,
178
+ blockIndexWithinCheckpoint,
128
179
  });
129
180
  return false;
130
181
  }
@@ -139,23 +190,29 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
139
190
  * @returns true if the delete succeeded, false if token didn't match or duty not found
140
191
  */
141
192
  async deleteDuty(
193
+ rollupAddress: EthAddress,
142
194
  validatorAddress: EthAddress,
143
- slot: bigint,
195
+ slot: SlotNumber,
144
196
  dutyType: DutyType,
145
197
  lockToken: string,
198
+ blockIndexWithinCheckpoint: number,
146
199
  ): Promise<boolean> {
147
200
  const result = await this.pool.query(DELETE_DUTY, [
201
+ rollupAddress.toString(),
148
202
  validatorAddress.toString(),
149
203
  slot.toString(),
150
204
  dutyType,
205
+ blockIndexWithinCheckpoint,
151
206
  lockToken,
152
207
  ]);
153
208
 
154
209
  if (result.rowCount === 0) {
155
210
  this.log.warn('Failed to delete duty: invalid token or duty not found', {
211
+ rollupAddress: rollupAddress.toString(),
156
212
  validatorAddress: validatorAddress.toString(),
157
213
  slot: slot.toString(),
158
214
  dutyType,
215
+ blockIndexWithinCheckpoint,
159
216
  });
160
217
  return false;
161
218
  }
@@ -163,23 +220,27 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
163
220
  }
164
221
 
165
222
  /**
166
- * 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.
167
226
  */
168
227
  private rowToRecord(row: DutyRow): ValidatorDutyRecord {
169
- return {
170
- validatorAddress: EthAddress.fromString(row.validator_address),
171
- slot: BigInt(row.slot),
172
- blockNumber: BigInt(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,
233
+ blockIndexWithinCheckpoint: row.block_index_within_checkpoint,
173
234
  dutyType: row.duty_type,
174
235
  status: row.status,
175
236
  messageHash: row.message_hash,
176
237
  signature: row.signature ?? undefined,
177
238
  nodeId: row.node_id,
178
239
  lockToken: row.lock_token,
179
- startedAt: row.started_at,
180
- completedAt: row.completed_at ?? undefined,
240
+ startedAtMs: row.started_at.getTime(),
241
+ completedAtMs: row.completed_at?.getTime(),
181
242
  errorMessage: row.error_message ?? undefined,
182
- };
243
+ });
183
244
  }
184
245
 
185
246
  /**
@@ -195,8 +256,29 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
195
256
  * @returns the number of duties cleaned up
196
257
  */
197
258
  async cleanupOwnStuckDuties(nodeId: string, maxAgeMs: number): Promise<number> {
198
- const cutoff = new Date(Date.now() - maxAgeMs);
199
- 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]);
200
282
  return result.rowCount ?? 0;
201
283
  }
202
284
  }
package/src/db/schema.ts CHANGED
@@ -16,11 +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
- duty_type VARCHAR(30) NOT NULL CHECK (duty_type IN ('BLOCK_PROPOSAL', 'ATTESTATION', 'ATTESTATIONS_AND_SIGNERS')),
23
- status VARCHAR(20) NOT NULL CHECK (status IN ('signing', 'signed', 'failed')),
23
+ block_index_within_checkpoint INTEGER NOT NULL DEFAULT 0,
24
+ duty_type VARCHAR(30) NOT NULL CHECK (duty_type IN ('BLOCK_PROPOSAL', 'CHECKPOINT_PROPOSAL', 'ATTESTATION', 'ATTESTATIONS_AND_SIGNERS', 'GOVERNANCE_VOTE', 'SLASHING_VOTE')),
25
+ status VARCHAR(20) NOT NULL CHECK (status IN ('signing', 'signed')),
24
26
  message_hash VARCHAR(66) NOT NULL,
25
27
  signature VARCHAR(132),
26
28
  node_id VARCHAR(255) NOT NULL,
@@ -29,7 +31,7 @@ CREATE TABLE IF NOT EXISTS validator_duties (
29
31
  completed_at TIMESTAMP,
30
32
  error_message TEXT,
31
33
 
32
- PRIMARY KEY (validator_address, slot, duty_type),
34
+ PRIMARY KEY (rollup_address, validator_address, slot, duty_type, block_index_within_checkpoint),
33
35
  CHECK (completed_at IS NULL OR completed_at >= started_at)
34
36
  );
35
37
  `;
@@ -92,25 +94,33 @@ SELECT version FROM schema_version ORDER BY version DESC LIMIT 1;
92
94
  * returns the existing record instead.
93
95
  *
94
96
  * Returns the record with an `is_new` flag indicating whether we inserted or got existing.
97
+ *
98
+ * Note: In high concurrency scenarios, if the INSERT conflicts and another transaction
99
+ * just committed the row, there's a small window where the SELECT might not see it yet.
100
+ * The application layer should retry if no rows are returned.
95
101
  */
96
102
  export const INSERT_OR_GET_DUTY = `
97
103
  WITH inserted AS (
98
104
  INSERT INTO validator_duties (
105
+ rollup_address,
99
106
  validator_address,
100
107
  slot,
101
108
  block_number,
109
+ block_index_within_checkpoint,
102
110
  duty_type,
103
111
  status,
104
112
  message_hash,
105
113
  node_id,
106
114
  lock_token,
107
115
  started_at
108
- ) VALUES ($1, $2, $3, $4, 'signing', $5, $6, $7, CURRENT_TIMESTAMP)
109
- ON CONFLICT (validator_address, slot, duty_type) 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
110
118
  RETURNING
119
+ rollup_address,
111
120
  validator_address,
112
121
  slot,
113
122
  block_number,
123
+ block_index_within_checkpoint,
114
124
  duty_type,
115
125
  status,
116
126
  message_hash,
@@ -125,9 +135,11 @@ WITH inserted AS (
125
135
  SELECT * FROM inserted
126
136
  UNION ALL
127
137
  SELECT
138
+ rollup_address,
128
139
  validator_address,
129
140
  slot,
130
141
  block_number,
142
+ block_index_within_checkpoint,
131
143
  duty_type,
132
144
  status,
133
145
  message_hash,
@@ -139,9 +151,11 @@ SELECT
139
151
  error_message,
140
152
  FALSE as is_new
141
153
  FROM validator_duties
142
- WHERE validator_address = $1
143
- AND slot = $2
144
- AND duty_type = $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
145
159
  AND NOT EXISTS (SELECT 1 FROM inserted);
146
160
  `;
147
161
 
@@ -153,11 +167,13 @@ UPDATE validator_duties
153
167
  SET status = 'signed',
154
168
  signature = $1,
155
169
  completed_at = CURRENT_TIMESTAMP
156
- WHERE validator_address = $2
157
- AND slot = $3
158
- AND duty_type = $4
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
159
175
  AND status = 'signing'
160
- AND lock_token = $5;
176
+ AND lock_token = $7;
161
177
  `;
162
178
 
163
179
  /**
@@ -166,11 +182,13 @@ WHERE validator_address = $2
166
182
  */
167
183
  export const DELETE_DUTY = `
168
184
  DELETE FROM validator_duties
169
- WHERE validator_address = $1
170
- AND slot = $2
171
- AND duty_type = $3
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
172
190
  AND status = 'signing'
173
- AND lock_token = $4;
191
+ AND lock_token = $6;
174
192
  `;
175
193
 
176
194
  /**
@@ -185,23 +203,34 @@ WHERE status = 'signed'
185
203
 
186
204
  /**
187
205
  * Query to clean up old duties (for maintenance)
188
- * Removes duties older than a specified timestamp
206
+ * Removes SIGNED duties older than a specified age (in milliseconds)
189
207
  */
190
208
  export const CLEANUP_OLD_DUTIES = `
191
209
  DELETE FROM validator_duties
192
- WHERE status IN ('signing', 'signed', 'failed')
193
- AND started_at < $1;
210
+ WHERE status = 'signed'
211
+ AND started_at < CURRENT_TIMESTAMP - ($1 || ' milliseconds')::INTERVAL;
194
212
  `;
195
213
 
196
214
  /**
197
215
  * Query to cleanup own stuck duties
198
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
199
218
  */
200
219
  export const CLEANUP_OWN_STUCK_DUTIES = `
201
220
  DELETE FROM validator_duties
202
221
  WHERE node_id = $1
203
222
  AND status = 'signing'
204
- 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;
205
234
  `;
206
235
 
207
236
  /**
@@ -220,9 +249,11 @@ export const DROP_SCHEMA_VERSION_TABLE = `DROP TABLE IF EXISTS schema_version;`;
220
249
  */
221
250
  export const GET_STUCK_DUTIES = `
222
251
  SELECT
252
+ rollup_address,
223
253
  validator_address,
224
254
  slot,
225
255
  block_number,
256
+ block_index_within_checkpoint,
226
257
  duty_type,
227
258
  status,
228
259
  message_hash,