@aztec/validator-ha-signer 0.0.1-commit.96bb3f7 → 0.0.1-commit.96dac018d

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 (50) hide show
  1. package/README.md +52 -37
  2. package/dest/db/postgres.d.ts +34 -5
  3. package/dest/db/postgres.d.ts.map +1 -1
  4. package/dest/db/postgres.js +80 -22
  5. package/dest/db/schema.d.ts +21 -10
  6. package/dest/db/schema.d.ts.map +1 -1
  7. package/dest/db/schema.js +49 -20
  8. package/dest/db/types.d.ts +76 -31
  9. package/dest/db/types.d.ts.map +1 -1
  10. package/dest/db/types.js +32 -8
  11. package/dest/errors.d.ts +9 -5
  12. package/dest/errors.d.ts.map +1 -1
  13. package/dest/errors.js +7 -4
  14. package/dest/factory.d.ts +6 -14
  15. package/dest/factory.d.ts.map +1 -1
  16. package/dest/factory.js +17 -12
  17. package/dest/metrics.d.ts +51 -0
  18. package/dest/metrics.d.ts.map +1 -0
  19. package/dest/metrics.js +103 -0
  20. package/dest/migrations.d.ts +1 -1
  21. package/dest/migrations.d.ts.map +1 -1
  22. package/dest/migrations.js +13 -2
  23. package/dest/slashing_protection_service.d.ts +25 -6
  24. package/dest/slashing_protection_service.d.ts.map +1 -1
  25. package/dest/slashing_protection_service.js +72 -20
  26. package/dest/test/pglite_pool.d.ts +92 -0
  27. package/dest/test/pglite_pool.d.ts.map +1 -0
  28. package/dest/test/pglite_pool.js +210 -0
  29. package/dest/types.d.ts +38 -18
  30. package/dest/types.d.ts.map +1 -1
  31. package/dest/types.js +4 -1
  32. package/dest/validator_ha_signer.d.ts +18 -13
  33. package/dest/validator_ha_signer.d.ts.map +1 -1
  34. package/dest/validator_ha_signer.js +46 -33
  35. package/package.json +13 -10
  36. package/src/db/postgres.ts +101 -21
  37. package/src/db/schema.ts +51 -20
  38. package/src/db/types.ts +110 -31
  39. package/src/errors.ts +7 -2
  40. package/src/factory.ts +20 -14
  41. package/src/metrics.ts +138 -0
  42. package/src/migrations.ts +17 -1
  43. package/src/slashing_protection_service.ts +117 -25
  44. package/src/test/pglite_pool.ts +256 -0
  45. package/src/types.ts +63 -19
  46. package/src/validator_ha_signer.ts +66 -42
  47. package/dest/config.d.ts +0 -47
  48. package/dest/config.d.ts.map +0 -1
  49. package/dest/config.js +0 -64
  50. package/src/config.ts +0 -116
@@ -1,14 +1,18 @@
1
1
  /**
2
2
  * PostgreSQL implementation of SlashingProtectionDatabase
3
3
  */
4
+ import { BlockNumber, 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 } 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
  }
@@ -167,9 +224,11 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
167
224
  */
168
225
  private rowToRecord(row: DutyRow): ValidatorDutyRecord {
169
226
  return {
227
+ rollupAddress: EthAddress.fromString(row.rollup_address),
170
228
  validatorAddress: EthAddress.fromString(row.validator_address),
171
- slot: BigInt(row.slot),
172
- blockNumber: BigInt(row.block_number),
229
+ slot: SlotNumber.fromString(row.slot),
230
+ blockNumber: BlockNumber.fromString(row.block_number),
231
+ blockIndexWithinCheckpoint: row.block_index_within_checkpoint,
173
232
  dutyType: row.duty_type,
174
233
  status: row.status,
175
234
  messageHash: row.message_hash,
@@ -195,8 +254,29 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
195
254
  * @returns the number of duties cleaned up
196
255
  */
197
256
  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]);
257
+ const result = await this.pool.query(CLEANUP_OWN_STUCK_DUTIES, [nodeId, maxAgeMs]);
258
+ return result.rowCount ?? 0;
259
+ }
260
+
261
+ /**
262
+ * Cleanup duties with outdated rollup address.
263
+ * Removes all duties where the rollup address doesn't match the current one.
264
+ * Used after a rollup upgrade to clean up duties for the old rollup.
265
+ * @returns the number of duties cleaned up
266
+ */
267
+ async cleanupOutdatedRollupDuties(currentRollupAddress: EthAddress): Promise<number> {
268
+ const result = await this.pool.query(CLEANUP_OUTDATED_ROLLUP_DUTIES, [currentRollupAddress.toString()]);
269
+ return result.rowCount ?? 0;
270
+ }
271
+
272
+ /**
273
+ * Cleanup old signed duties.
274
+ * Removes only signed duties older than the specified age.
275
+ * Does not remove 'signing' duties as they may be in progress.
276
+ * @returns the number of duties cleaned up
277
+ */
278
+ async cleanupOldDuties(maxAgeMs: number): Promise<number> {
279
+ const result = await this.pool.query(CLEANUP_OLD_DUTIES, [maxAgeMs]);
200
280
  return result.rowCount ?? 0;
201
281
  }
202
282
  }
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,
package/src/db/types.ts CHANGED
@@ -1,13 +1,17 @@
1
+ import type { BlockNumber, CheckpointNumber, IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types';
1
2
  import type { EthAddress } from '@aztec/foundation/eth-address';
2
3
  import type { Signature } from '@aztec/foundation/eth-signature';
4
+ import { DutyType } from '@aztec/stdlib/ha-signing';
3
5
 
4
6
  /**
5
7
  * Row type from PostgreSQL query
6
8
  */
7
9
  export interface DutyRow {
10
+ rollup_address: string;
8
11
  validator_address: string;
9
12
  slot: string;
10
13
  block_number: string;
14
+ block_index_within_checkpoint: number;
11
15
  duty_type: DutyType;
12
16
  status: DutyStatus;
13
17
  message_hash: string;
@@ -26,15 +30,6 @@ export interface InsertOrGetRow extends DutyRow {
26
30
  is_new: boolean;
27
31
  }
28
32
 
29
- /**
30
- * Type of validator duty being performed
31
- */
32
- export enum DutyType {
33
- BLOCK_PROPOSAL = 'BLOCK_PROPOSAL',
34
- ATTESTATION = 'ATTESTATION',
35
- ATTESTATIONS_AND_SIGNERS = 'ATTESTATIONS_AND_SIGNERS',
36
- }
37
-
38
33
  /**
39
34
  * Status of a duty in the database
40
35
  */
@@ -43,16 +38,23 @@ export enum DutyStatus {
43
38
  SIGNED = 'signed',
44
39
  }
45
40
 
41
+ // Re-export DutyType from stdlib
42
+ export { DutyType };
43
+
46
44
  /**
47
45
  * Record of a validator duty in the database
48
46
  */
49
47
  export interface ValidatorDutyRecord {
48
+ /** Ethereum address of the rollup contract */
49
+ rollupAddress: EthAddress;
50
50
  /** Ethereum address of the validator */
51
51
  validatorAddress: EthAddress;
52
52
  /** Slot number for this duty */
53
- slot: bigint;
53
+ slot: SlotNumber;
54
54
  /** Block number for this duty */
55
- blockNumber: bigint;
55
+ blockNumber: BlockNumber;
56
+ /** Block index within checkpoint (0, 1, 2... for block proposals, -1 for other duty types) */
57
+ blockIndexWithinCheckpoint: number;
56
58
  /** Type of duty being performed */
57
59
  dutyType: DutyType;
58
60
  /** Current status of the duty */
@@ -69,49 +71,126 @@ export interface ValidatorDutyRecord {
69
71
  startedAt: Date;
70
72
  /** When the duty signing was completed (success or failure) */
71
73
  completedAt?: Date;
72
- /** Error message if status is 'failed' */
74
+ /** Error message (currently unused) */
73
75
  errorMessage?: string;
74
76
  }
75
77
 
76
78
  /**
77
- * Minimal info needed to identify a unique duty
79
+ * Duty identifier for block proposals.
80
+ * blockIndexWithinCheckpoint is REQUIRED and must be >= 0.
78
81
  */
79
- export interface DutyIdentifier {
82
+ export interface BlockProposalDutyIdentifier {
83
+ rollupAddress: EthAddress;
80
84
  validatorAddress: EthAddress;
81
- slot: bigint;
82
- dutyType: DutyType;
85
+ slot: SlotNumber;
86
+ /** Block index within checkpoint (0, 1, 2...). Required for block proposals. */
87
+ blockIndexWithinCheckpoint: IndexWithinCheckpoint;
88
+ dutyType: DutyType.BLOCK_PROPOSAL;
83
89
  }
84
90
 
85
91
  /**
86
- * Parameters for checking and recording a new duty
92
+ * Duty identifier for non-block-proposal duties.
93
+ * blockIndexWithinCheckpoint is not applicable (internally stored as -1).
87
94
  */
88
- export interface CheckAndRecordParams {
95
+ export interface OtherDutyIdentifier {
96
+ rollupAddress: EthAddress;
89
97
  validatorAddress: EthAddress;
90
- slot: bigint;
91
- blockNumber: bigint;
92
- dutyType: DutyType;
98
+ slot: SlotNumber;
99
+ dutyType:
100
+ | DutyType.CHECKPOINT_PROPOSAL
101
+ | DutyType.ATTESTATION
102
+ | DutyType.ATTESTATIONS_AND_SIGNERS
103
+ | DutyType.GOVERNANCE_VOTE
104
+ | DutyType.SLASHING_VOTE
105
+ | DutyType.AUTH_REQUEST
106
+ | DutyType.TXS;
107
+ }
108
+
109
+ /**
110
+ * Minimal info needed to identify a unique duty.
111
+ * Uses discriminated union to enforce type safety:
112
+ * - BLOCK_PROPOSAL duties MUST have blockIndexWithinCheckpoint >= 0
113
+ * - Other duty types do NOT have blockIndexWithinCheckpoint (internally -1)
114
+ */
115
+ export type DutyIdentifier = BlockProposalDutyIdentifier | OtherDutyIdentifier;
116
+
117
+ /**
118
+ * Validates and normalizes the block index for a duty.
119
+ * - BLOCK_PROPOSAL: validates blockIndexWithinCheckpoint is provided and >= 0
120
+ * - Other duty types: always returns -1
121
+ *
122
+ * @throws Error if BLOCK_PROPOSAL is missing blockIndexWithinCheckpoint or has invalid value
123
+ */
124
+ export function normalizeBlockIndex(dutyType: DutyType, blockIndexWithinCheckpoint: number | undefined): number {
125
+ if (dutyType === DutyType.BLOCK_PROPOSAL) {
126
+ if (blockIndexWithinCheckpoint === undefined) {
127
+ throw new Error('BLOCK_PROPOSAL duties require blockIndexWithinCheckpoint to be specified');
128
+ }
129
+ if (blockIndexWithinCheckpoint < 0) {
130
+ throw new Error(
131
+ `BLOCK_PROPOSAL duties require blockIndexWithinCheckpoint >= 0, got ${blockIndexWithinCheckpoint}`,
132
+ );
133
+ }
134
+ return blockIndexWithinCheckpoint;
135
+ }
136
+ // For all other duty types, always use -1
137
+ return -1;
138
+ }
139
+
140
+ /**
141
+ * Gets the block index from a DutyIdentifier.
142
+ * - BLOCK_PROPOSAL: returns the blockIndexWithinCheckpoint
143
+ * - Other duty types: returns -1
144
+ */
145
+ export function getBlockIndexFromDutyIdentifier(duty: DutyIdentifier): number {
146
+ if (duty.dutyType === DutyType.BLOCK_PROPOSAL) {
147
+ return duty.blockIndexWithinCheckpoint;
148
+ }
149
+ return -1;
150
+ }
151
+
152
+ /**
153
+ * Additional parameters for checking and recording a new duty
154
+ */
155
+ interface CheckAndRecordExtra {
156
+ /** Block number for this duty */
157
+ blockNumber: BlockNumber | CheckpointNumber;
158
+ /** The signing root (hash) for this duty */
93
159
  messageHash: string;
160
+ /** Identifier for the node that acquired the lock */
94
161
  nodeId: string;
95
162
  }
96
163
 
97
164
  /**
98
- * Parameters for recording a successful signing
165
+ * Parameters for checking and recording a new duty.
166
+ * Uses intersection with DutyIdentifier to preserve the discriminated union.
99
167
  */
100
- export interface RecordSuccessParams {
101
- validatorAddress: EthAddress;
102
- slot: bigint;
103
- dutyType: DutyType;
168
+ export type CheckAndRecordParams = DutyIdentifier & CheckAndRecordExtra;
169
+
170
+ /**
171
+ * Additional parameters for recording a successful signing
172
+ */
173
+ interface RecordSuccessExtra {
104
174
  signature: Signature;
105
175
  nodeId: string;
106
176
  lockToken: string;
107
177
  }
108
178
 
109
179
  /**
110
- * Parameters for deleting a duty
180
+ * Parameters for recording a successful signing.
181
+ * Uses intersection with DutyIdentifier to preserve the discriminated union.
111
182
  */
112
- export interface DeleteDutyParams {
113
- validatorAddress: EthAddress;
114
- slot: bigint;
115
- dutyType: DutyType;
183
+ export type RecordSuccessParams = DutyIdentifier & RecordSuccessExtra;
184
+
185
+ /**
186
+ * Additional parameters for deleting a duty
187
+ */
188
+ interface DeleteDutyExtra {
116
189
  lockToken: string;
117
190
  }
191
+
192
+ /**
193
+ * Parameters for deleting a duty.
194
+ * Uses intersection with DutyIdentifier to preserve the discriminated union.
195
+ */
196
+ export type DeleteDutyParams = DutyIdentifier & DeleteDutyExtra;
package/src/errors.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Custom errors for the validator HA signer
3
3
  */
4
+ import type { SlotNumber } from '@aztec/foundation/branded-types';
5
+
4
6
  import type { DutyType } from './db/types.js';
5
7
 
6
8
  /**
@@ -10,8 +12,9 @@ import type { DutyType } from './db/types.js';
10
12
  */
11
13
  export class DutyAlreadySignedError extends Error {
12
14
  constructor(
13
- public readonly slot: bigint,
15
+ public readonly slot: SlotNumber,
14
16
  public readonly dutyType: DutyType,
17
+ public readonly blockIndexWithinCheckpoint: number,
15
18
  public readonly signedByNode: string,
16
19
  ) {
17
20
  super(`Duty ${dutyType} for slot ${slot} already signed by node ${signedByNode}`);
@@ -28,10 +31,12 @@ export class DutyAlreadySignedError extends Error {
28
31
  */
29
32
  export class SlashingProtectionError extends Error {
30
33
  constructor(
31
- public readonly slot: bigint,
34
+ public readonly slot: SlotNumber,
32
35
  public readonly dutyType: DutyType,
36
+ public readonly blockIndexWithinCheckpoint: number,
33
37
  public readonly existingMessageHash: string,
34
38
  public readonly attemptedMessageHash: string,
39
+ public readonly signedByNode: string,
35
40
  ) {
36
41
  super(
37
42
  `Slashing protection: ${dutyType} for slot ${slot} was already signed with different data. ` +