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

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 +42 -37
  2. package/dest/config.d.ts +49 -17
  3. package/dest/config.d.ts.map +1 -1
  4. package/dest/config.js +28 -19
  5. package/dest/db/postgres.d.ts +20 -5
  6. package/dest/db/postgres.d.ts.map +1 -1
  7. package/dest/db/postgres.js +50 -19
  8. package/dest/db/schema.d.ts +11 -7
  9. package/dest/db/schema.d.ts.map +1 -1
  10. package/dest/db/schema.js +19 -7
  11. package/dest/db/types.d.ts +75 -23
  12. package/dest/db/types.d.ts.map +1 -1
  13. package/dest/db/types.js +34 -0
  14. package/dest/errors.d.ts +9 -5
  15. package/dest/errors.d.ts.map +1 -1
  16. package/dest/errors.js +7 -4
  17. package/dest/factory.d.ts +6 -14
  18. package/dest/factory.d.ts.map +1 -1
  19. package/dest/factory.js +6 -11
  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 +9 -3
  24. package/dest/slashing_protection_service.d.ts.map +1 -1
  25. package/dest/slashing_protection_service.js +21 -9
  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 +78 -14
  30. package/dest/types.d.ts.map +1 -1
  31. package/dest/types.js +21 -1
  32. package/dest/validator_ha_signer.d.ts +7 -11
  33. package/dest/validator_ha_signer.d.ts.map +1 -1
  34. package/dest/validator_ha_signer.js +25 -29
  35. package/package.json +8 -8
  36. package/src/config.ts +59 -50
  37. package/src/db/postgres.ts +68 -19
  38. package/src/db/schema.ts +19 -7
  39. package/src/db/types.ts +105 -21
  40. package/src/errors.ts +7 -2
  41. package/src/factory.ts +8 -13
  42. package/src/migrations.ts +17 -1
  43. package/src/slashing_protection_service.ts +46 -12
  44. package/src/test/pglite_pool.ts +256 -0
  45. package/src/types.ts +121 -19
  46. package/src/validator_ha_signer.ts +32 -39
@@ -5,7 +5,9 @@
5
5
  * This ensures that even with multiple validator nodes running, only one
6
6
  * node will sign for a given duty (slot + duty type).
7
7
  */ import { createLogger } from '@aztec/foundation/log';
8
+ import { DutyType } from './db/types.js';
8
9
  import { SlashingProtectionService } from './slashing_protection_service.js';
10
+ import { getBlockNumberFromSigningContext } from './types.js';
9
11
  /**
10
12
  * Validator High Availability Signer
11
13
  *
@@ -31,7 +33,7 @@ import { SlashingProtectionService } from './slashing_protection_service.js';
31
33
  constructor(db, config){
32
34
  this.config = config;
33
35
  this.log = createLogger('validator-ha-signer');
34
- if (!config.enabled) {
36
+ if (!config.haSigningEnabled) {
35
37
  // this shouldn't happen, the validator should use different signer for non-HA setups
36
38
  throw new Error('Validator HA Signer is not enabled in config');
37
39
  }
@@ -53,31 +55,33 @@ import { SlashingProtectionService } from './slashing_protection_service.js';
53
55
  *
54
56
  * @param validatorAddress - The validator's Ethereum address
55
57
  * @param messageHash - The hash to be signed
56
- * @param context - The signing context (slot, block number, duty type)
58
+ * @param context - The signing context (HA-protected duty types only)
57
59
  * @param signFn - Function that performs the actual signing
58
60
  * @returns The signature
59
61
  *
60
62
  * @throws DutyAlreadySignedError if the duty was already signed (expected in HA)
61
63
  * @throws SlashingProtectionError if attempting to sign different data for same slot (expected in HA)
62
64
  */ async signWithProtection(validatorAddress, messageHash, context, signFn) {
63
- // If slashing protection is disabled, just sign directly
64
- if (!this.slashingProtection) {
65
- this.log.info('Signing without slashing protection enabled', {
66
- validatorAddress: validatorAddress.toString(),
67
- nodeId: this.config.nodeId,
68
- dutyType: context.dutyType,
65
+ let dutyIdentifier;
66
+ if (context.dutyType === DutyType.BLOCK_PROPOSAL) {
67
+ dutyIdentifier = {
68
+ validatorAddress,
69
69
  slot: context.slot,
70
- blockNumber: context.blockNumber
71
- });
72
- return await signFn(messageHash);
70
+ blockIndexWithinCheckpoint: context.blockIndexWithinCheckpoint,
71
+ dutyType: context.dutyType
72
+ };
73
+ } else {
74
+ dutyIdentifier = {
75
+ validatorAddress,
76
+ slot: context.slot,
77
+ dutyType: context.dutyType
78
+ };
73
79
  }
74
- const { slot, blockNumber, dutyType } = context;
75
80
  // Acquire lock and get the token for ownership verification
81
+ const blockNumber = getBlockNumberFromSigningContext(context);
76
82
  const lockToken = await this.slashingProtection.checkAndRecord({
77
- validatorAddress,
78
- slot,
83
+ ...dutyIdentifier,
79
84
  blockNumber,
80
- dutyType,
81
85
  messageHash: messageHash.toString(),
82
86
  nodeId: this.config.nodeId
83
87
  });
@@ -88,18 +92,14 @@ import { SlashingProtectionService } from './slashing_protection_service.js';
88
92
  } catch (error) {
89
93
  // Delete duty to allow retry (only succeeds if we own the lock)
90
94
  await this.slashingProtection.deleteDuty({
91
- validatorAddress,
92
- slot,
93
- dutyType,
95
+ ...dutyIdentifier,
94
96
  lockToken
95
97
  });
96
98
  throw error;
97
99
  }
98
100
  // Record success (only succeeds if we own the lock)
99
101
  await this.slashingProtection.recordSuccess({
100
- validatorAddress,
101
- slot,
102
- dutyType,
102
+ ...dutyIdentifier,
103
103
  signature,
104
104
  nodeId: this.config.nodeId,
105
105
  lockToken
@@ -107,11 +107,6 @@ import { SlashingProtectionService } from './slashing_protection_service.js';
107
107
  return signature;
108
108
  }
109
109
  /**
110
- * Check if slashing protection is enabled
111
- */ get isEnabled() {
112
- return this.slashingProtection !== undefined;
113
- }
114
- /**
115
110
  * Get the node ID for this signer
116
111
  */ get nodeId() {
117
112
  return this.config.nodeId;
@@ -120,12 +115,13 @@ import { SlashingProtectionService } from './slashing_protection_service.js';
120
115
  * Start the HA signer background tasks (cleanup of stuck duties).
121
116
  * Should be called after construction and before signing operations.
122
117
  */ start() {
123
- this.slashingProtection?.start();
118
+ this.slashingProtection.start();
124
119
  }
125
120
  /**
126
- * Stop the HA signer background tasks.
121
+ * Stop the HA signer background tasks and close database connection.
127
122
  * Should be called during graceful shutdown.
128
123
  */ async stop() {
129
- await this.slashingProtection?.stop();
124
+ await this.slashingProtection.stop();
125
+ await this.slashingProtection.close();
130
126
  }
131
127
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aztec/validator-ha-signer",
3
- "version": "0.0.1-commit.96bb3f7",
3
+ "version": "0.0.1-commit.c80b6263",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./config": "./dest/config.js",
@@ -10,7 +10,8 @@
10
10
  "./migrations": "./dest/migrations.js",
11
11
  "./slashing-protection-service": "./dest/slashing_protection_service.js",
12
12
  "./types": "./dest/types.js",
13
- "./validator-ha-signer": "./dest/validator_ha_signer.js"
13
+ "./validator-ha-signer": "./dest/validator_ha_signer.js",
14
+ "./test": "./dest/test/pglite_pool.js"
14
15
  },
15
16
  "typedocOptions": {
16
17
  "entryPoints": [
@@ -73,21 +74,20 @@
73
74
  ]
74
75
  },
75
76
  "dependencies": {
76
- "@aztec/foundation": "0.0.1-commit.96bb3f7",
77
- "@aztec/node-keystore": "0.0.1-commit.96bb3f7",
77
+ "@aztec/foundation": "0.0.1-commit.c80b6263",
78
78
  "node-pg-migrate": "^8.0.4",
79
79
  "pg": "^8.11.3",
80
- "tslib": "^2.4.0"
80
+ "tslib": "^2.4.0",
81
+ "zod": "^3.23.8"
81
82
  },
82
83
  "devDependencies": {
83
- "@electric-sql/pglite": "^0.2.17",
84
+ "@electric-sql/pglite": "^0.3.14",
84
85
  "@jest/globals": "^30.0.0",
85
- "@middle-management/pglite-pg-adapter": "^0.0.3",
86
86
  "@types/jest": "^30.0.0",
87
87
  "@types/node": "^22.15.17",
88
88
  "@types/node-pg-migrate": "^2.3.1",
89
89
  "@types/pg": "^8.10.9",
90
- "@typescript/native-preview": "7.0.0-dev.20251126.1",
90
+ "@typescript/native-preview": "7.0.0-dev.20260113.1",
91
91
  "jest": "^30.0.0",
92
92
  "jest-mock-extended": "^4.0.0",
93
93
  "ts-node": "^10.9.1",
package/src/config.ts CHANGED
@@ -4,66 +4,34 @@ import {
4
4
  getConfigFromMappings,
5
5
  getDefaultConfig,
6
6
  numberConfigHelper,
7
+ optionalNumberConfigHelper,
7
8
  } from '@aztec/foundation/config';
9
+ import type { ZodFor } from '@aztec/foundation/schemas';
10
+
11
+ import { z } from 'zod';
8
12
 
9
13
  /**
10
- * Configuration for the slashing protection service
14
+ * Configuration for the Validator HA Signer
15
+ *
16
+ * This config is used for distributed locking and slashing protection
17
+ * when running multiple validator nodes in a high-availability setup.
11
18
  */
12
- export interface SlashingProtectionConfig {
13
- /** Whether slashing protection is enabled */
14
- enabled: boolean;
19
+ export interface ValidatorHASignerConfig {
20
+ /** Whether HA signing / slashing protection is enabled */
21
+ haSigningEnabled: boolean;
15
22
  /** Unique identifier for this node */
16
23
  nodeId: string;
17
24
  /** How long to wait between polls when a duty is being signed (ms) */
18
25
  pollingIntervalMs: number;
19
26
  /** Maximum time to wait for a duty being signed to complete (ms) */
20
27
  signingTimeoutMs: number;
21
- /** Maximum age of a stuck duty in ms */
22
- maxStuckDutiesAgeMs: number;
23
- }
24
-
25
- export const slashingProtectionConfigMappings: ConfigMappingsType<SlashingProtectionConfig> = {
26
- enabled: {
27
- env: 'SLASHING_PROTECTION_ENABLED',
28
- description: 'Whether slashing protection is enabled',
29
- ...booleanConfigHelper(true),
30
- },
31
- nodeId: {
32
- env: 'SLASHING_PROTECTION_NODE_ID',
33
- description: 'The unique identifier for this node',
34
- defaultValue: '',
35
- },
36
- pollingIntervalMs: {
37
- env: 'SLASHING_PROTECTION_POLLING_INTERVAL_MS',
38
- description: 'The number of ms to wait between polls when a duty is being signed',
39
- ...numberConfigHelper(100),
40
- },
41
- signingTimeoutMs: {
42
- env: 'SLASHING_PROTECTION_SIGNING_TIMEOUT_MS',
43
- description: 'The maximum time to wait for a duty being signed to complete',
44
- ...numberConfigHelper(3_000),
45
- },
46
- maxStuckDutiesAgeMs: {
47
- env: 'SLASHING_PROTECTION_MAX_STUCK_DUTIES_AGE_MS',
48
- description: 'The maximum age of a stuck duty in ms',
49
- // hard-coding at current 2 slot duration. This should be set by the validator on init
50
- ...numberConfigHelper(72_000),
51
- },
52
- };
53
-
54
- export const defaultSlashingProtectionConfig: SlashingProtectionConfig = getDefaultConfig(
55
- slashingProtectionConfigMappings,
56
- );
57
-
58
- /**
59
- * Configuration for creating an HA signer with PostgreSQL backend
60
- */
61
- export interface CreateHASignerConfig extends SlashingProtectionConfig {
28
+ /** Maximum age of a stuck duty in ms (defaults to 2x hardcoded Aztec slot duration if not set) */
29
+ maxStuckDutiesAgeMs?: number;
62
30
  /**
63
31
  * PostgreSQL connection string
64
32
  * Format: postgresql://user:password@host:port/database
65
33
  */
66
- databaseUrl: string;
34
+ databaseUrl?: string;
67
35
  /**
68
36
  * PostgreSQL connection pool configuration
69
37
  */
@@ -77,8 +45,32 @@ export interface CreateHASignerConfig extends SlashingProtectionConfig {
77
45
  poolConnectionTimeoutMs?: number;
78
46
  }
79
47
 
80
- export const createHASignerConfigMappings: ConfigMappingsType<CreateHASignerConfig> = {
81
- ...slashingProtectionConfigMappings,
48
+ export const validatorHASignerConfigMappings: ConfigMappingsType<ValidatorHASignerConfig> = {
49
+ haSigningEnabled: {
50
+ env: 'VALIDATOR_HA_SIGNING_ENABLED',
51
+ description: 'Whether HA signing / slashing protection is enabled',
52
+ ...booleanConfigHelper(false),
53
+ },
54
+ nodeId: {
55
+ env: 'VALIDATOR_HA_NODE_ID',
56
+ description: 'The unique identifier for this node',
57
+ defaultValue: '',
58
+ },
59
+ pollingIntervalMs: {
60
+ env: 'VALIDATOR_HA_POLLING_INTERVAL_MS',
61
+ description: 'The number of ms to wait between polls when a duty is being signed',
62
+ ...numberConfigHelper(100),
63
+ },
64
+ signingTimeoutMs: {
65
+ env: 'VALIDATOR_HA_SIGNING_TIMEOUT_MS',
66
+ description: 'The maximum time to wait for a duty being signed to complete',
67
+ ...numberConfigHelper(3_000),
68
+ },
69
+ maxStuckDutiesAgeMs: {
70
+ env: 'VALIDATOR_HA_MAX_STUCK_DUTIES_AGE_MS',
71
+ description: 'The maximum age of a stuck duty in ms (defaults to 2x Aztec slot duration)',
72
+ ...optionalNumberConfigHelper(),
73
+ },
82
74
  databaseUrl: {
83
75
  env: 'VALIDATOR_HA_DATABASE_URL',
84
76
  description:
@@ -106,11 +98,28 @@ export const createHASignerConfigMappings: ConfigMappingsType<CreateHASignerConf
106
98
  },
107
99
  };
108
100
 
101
+ export const defaultValidatorHASignerConfig: ValidatorHASignerConfig = getDefaultConfig(
102
+ validatorHASignerConfigMappings,
103
+ );
104
+
109
105
  /**
110
106
  * Returns the validator HA signer configuration from environment variables.
111
107
  * Note: If an environment variable is not set, the default value is used.
112
108
  * @returns The validator HA signer configuration.
113
109
  */
114
- export function getConfigEnvVars(): CreateHASignerConfig {
115
- return getConfigFromMappings<CreateHASignerConfig>(createHASignerConfigMappings);
110
+ export function getConfigEnvVars(): ValidatorHASignerConfig {
111
+ return getConfigFromMappings<ValidatorHASignerConfig>(validatorHASignerConfigMappings);
116
112
  }
113
+
114
+ export const ValidatorHASignerConfigSchema = z.object({
115
+ haSigningEnabled: z.boolean(),
116
+ nodeId: z.string(),
117
+ pollingIntervalMs: z.number().min(0),
118
+ signingTimeoutMs: z.number().min(0),
119
+ maxStuckDutiesAgeMs: z.number().min(0).optional(),
120
+ databaseUrl: z.string().optional(),
121
+ poolMaxCount: z.number().min(0).optional(),
122
+ poolMinCount: z.number().min(0).optional(),
123
+ poolIdleTimeoutMs: z.number().min(0).optional(),
124
+ poolConnectionTimeoutMs: z.number().min(0).optional(),
125
+ }) satisfies ZodFor<ValidatorHASignerConfig>;
@@ -1,11 +1,13 @@
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 {
@@ -16,6 +18,16 @@ import {
16
18
  UPDATE_DUTY_SIGNED,
17
19
  } from './schema.js';
18
20
  import type { CheckAndRecordParams, DutyRow, DutyType, InsertOrGetRow, ValidatorDutyRecord } from './types.js';
21
+ import { getBlockIndexFromDutyIdentifier } from './types.js';
22
+
23
+ /**
24
+ * Minimal pool interface for database operations.
25
+ * Both pg.Pool and test adapters (e.g., PGlite) satisfy this interface.
26
+ */
27
+ export interface QueryablePool {
28
+ query<R extends QueryResultRow = any>(text: string, values?: any[]): Promise<QueryResult<R>>;
29
+ end(): Promise<void>;
30
+ }
19
31
 
20
32
  /**
21
33
  * PostgreSQL implementation of the slashing protection database
@@ -23,7 +35,7 @@ import type { CheckAndRecordParams, DutyRow, DutyType, InsertOrGetRow, Validator
23
35
  export class PostgresSlashingProtectionDatabase implements SlashingProtectionDatabase {
24
36
  private readonly log: Logger;
25
37
 
26
- constructor(private readonly pool: Pool) {
38
+ constructor(private readonly pool: QueryablePool) {
27
39
  this.log = createLogger('slashing-protection:postgres');
28
40
  }
29
41
 
@@ -48,13 +60,13 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
48
60
  dbVersion = result.rows[0].version;
49
61
  } catch {
50
62
  throw new Error(
51
- 'Database schema not initialized. Please run migrations first: aztec migrate up --database-url <url>',
63
+ 'Database schema not initialized. Please run migrations first: aztec migrate-ha-db up --database-url <url>',
52
64
  );
53
65
  }
54
66
 
55
67
  if (dbVersion < SCHEMA_VERSION) {
56
68
  throw new Error(
57
- `Database schema version ${dbVersion} is outdated (expected ${SCHEMA_VERSION}). Please run migrations: aztec migrate up --database-url <url>`,
69
+ `Database schema version ${dbVersion} is outdated (expected ${SCHEMA_VERSION}). Please run migrations: aztec migrate-ha-db up --database-url <url>`,
58
70
  );
59
71
  }
60
72
 
@@ -72,24 +84,54 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
72
84
  *
73
85
  * @returns { isNew: true, record } if we successfully inserted and acquired the lock
74
86
  * @returns { isNew: false, record } if a record already exists. lock_token is empty if the record already exists.
87
+ *
88
+ * Retries if no rows are returned, which can happen under high concurrency
89
+ * when another transaction just committed the row but it's not yet visible.
75
90
  */
76
91
  async tryInsertOrGetExisting(params: CheckAndRecordParams): Promise<TryInsertOrGetResult> {
77
92
  // create a token for ownership verification
78
93
  const lockToken = randomBytes(16).toString('hex');
79
94
 
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
- ]);
95
+ // Use fast retries with custom backoff: 10ms, 20ms, 30ms (then stop)
96
+ const fastBackoff = makeBackoff([0.01, 0.02, 0.03]);
97
+
98
+ // Get the normalized block index using type-safe helper
99
+ const blockIndexWithinCheckpoint = getBlockIndexFromDutyIdentifier(params);
100
+
101
+ const result = await retry<QueryResult<InsertOrGetRow>>(
102
+ async () => {
103
+ const queryResult: QueryResult<InsertOrGetRow> = await this.pool.query(INSERT_OR_GET_DUTY, [
104
+ params.validatorAddress.toString(),
105
+ params.slot.toString(),
106
+ params.blockNumber.toString(),
107
+ blockIndexWithinCheckpoint,
108
+ params.dutyType,
109
+ params.messageHash,
110
+ params.nodeId,
111
+ lockToken,
112
+ ]);
113
+
114
+ // Throw error if no rows to trigger retry
115
+ if (queryResult.rows.length === 0) {
116
+ throw new Error('INSERT_OR_GET_DUTY returned no rows');
117
+ }
118
+
119
+ return queryResult;
120
+ },
121
+ `INSERT_OR_GET_DUTY for node ${params.nodeId}`,
122
+ fastBackoff,
123
+ this.log,
124
+ true,
125
+ );
89
126
 
90
127
  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');
128
+ // this should never happen as the retry function should throw if it still fails after retries
129
+ throw new Error('INSERT_OR_GET_DUTY returned no rows after retries');
130
+ }
131
+
132
+ if (result.rows.length > 1) {
133
+ // this should never happen if database constraints are correct (PRIMARY KEY should prevent duplicates)
134
+ throw new Error(`INSERT_OR_GET_DUTY returned ${result.rows.length} rows (expected exactly 1).`);
93
135
  }
94
136
 
95
137
  const row = result.rows[0];
@@ -107,16 +149,18 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
107
149
  */
108
150
  async updateDutySigned(
109
151
  validatorAddress: EthAddress,
110
- slot: bigint,
152
+ slot: SlotNumber,
111
153
  dutyType: DutyType,
112
154
  signature: string,
113
155
  lockToken: string,
156
+ blockIndexWithinCheckpoint: number,
114
157
  ): Promise<boolean> {
115
158
  const result = await this.pool.query(UPDATE_DUTY_SIGNED, [
116
159
  signature,
117
160
  validatorAddress.toString(),
118
161
  slot.toString(),
119
162
  dutyType,
163
+ blockIndexWithinCheckpoint,
120
164
  lockToken,
121
165
  ]);
122
166
 
@@ -125,6 +169,7 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
125
169
  validatorAddress: validatorAddress.toString(),
126
170
  slot: slot.toString(),
127
171
  dutyType,
172
+ blockIndexWithinCheckpoint,
128
173
  });
129
174
  return false;
130
175
  }
@@ -140,14 +185,16 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
140
185
  */
141
186
  async deleteDuty(
142
187
  validatorAddress: EthAddress,
143
- slot: bigint,
188
+ slot: SlotNumber,
144
189
  dutyType: DutyType,
145
190
  lockToken: string,
191
+ blockIndexWithinCheckpoint: number,
146
192
  ): Promise<boolean> {
147
193
  const result = await this.pool.query(DELETE_DUTY, [
148
194
  validatorAddress.toString(),
149
195
  slot.toString(),
150
196
  dutyType,
197
+ blockIndexWithinCheckpoint,
151
198
  lockToken,
152
199
  ]);
153
200
 
@@ -156,6 +203,7 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
156
203
  validatorAddress: validatorAddress.toString(),
157
204
  slot: slot.toString(),
158
205
  dutyType,
206
+ blockIndexWithinCheckpoint,
159
207
  });
160
208
  return false;
161
209
  }
@@ -168,8 +216,9 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
168
216
  private rowToRecord(row: DutyRow): ValidatorDutyRecord {
169
217
  return {
170
218
  validatorAddress: EthAddress.fromString(row.validator_address),
171
- slot: BigInt(row.slot),
172
- blockNumber: BigInt(row.block_number),
219
+ slot: SlotNumber.fromString(row.slot),
220
+ blockNumber: BlockNumber.fromString(row.block_number),
221
+ blockIndexWithinCheckpoint: row.block_index_within_checkpoint,
173
222
  dutyType: row.duty_type,
174
223
  status: row.status,
175
224
  messageHash: row.message_hash,
package/src/db/schema.ts CHANGED
@@ -19,7 +19,8 @@ CREATE TABLE IF NOT EXISTS validator_duties (
19
19
  validator_address VARCHAR(42) NOT NULL,
20
20
  slot BIGINT NOT NULL,
21
21
  block_number BIGINT NOT NULL,
22
- duty_type VARCHAR(30) NOT NULL CHECK (duty_type IN ('BLOCK_PROPOSAL', 'ATTESTATION', 'ATTESTATIONS_AND_SIGNERS')),
22
+ block_index_within_checkpoint INTEGER NOT NULL DEFAULT 0,
23
+ duty_type VARCHAR(30) NOT NULL CHECK (duty_type IN ('BLOCK_PROPOSAL', 'CHECKPOINT_PROPOSAL', 'ATTESTATION', 'ATTESTATIONS_AND_SIGNERS', 'GOVERNANCE_VOTE', 'SLASHING_VOTE')),
23
24
  status VARCHAR(20) NOT NULL CHECK (status IN ('signing', 'signed', 'failed')),
24
25
  message_hash VARCHAR(66) NOT NULL,
25
26
  signature VARCHAR(132),
@@ -29,7 +30,7 @@ CREATE TABLE IF NOT EXISTS validator_duties (
29
30
  completed_at TIMESTAMP,
30
31
  error_message TEXT,
31
32
 
32
- PRIMARY KEY (validator_address, slot, duty_type),
33
+ PRIMARY KEY (validator_address, slot, duty_type, block_index_within_checkpoint),
33
34
  CHECK (completed_at IS NULL OR completed_at >= started_at)
34
35
  );
35
36
  `;
@@ -92,6 +93,10 @@ SELECT version FROM schema_version ORDER BY version DESC LIMIT 1;
92
93
  * returns the existing record instead.
93
94
  *
94
95
  * Returns the record with an `is_new` flag indicating whether we inserted or got existing.
96
+ *
97
+ * Note: In high concurrency scenarios, if the INSERT conflicts and another transaction
98
+ * just committed the row, there's a small window where the SELECT might not see it yet.
99
+ * The application layer should retry if no rows are returned.
95
100
  */
96
101
  export const INSERT_OR_GET_DUTY = `
97
102
  WITH inserted AS (
@@ -99,18 +104,20 @@ WITH inserted AS (
99
104
  validator_address,
100
105
  slot,
101
106
  block_number,
107
+ block_index_within_checkpoint,
102
108
  duty_type,
103
109
  status,
104
110
  message_hash,
105
111
  node_id,
106
112
  lock_token,
107
113
  started_at
108
- ) VALUES ($1, $2, $3, $4, 'signing', $5, $6, $7, CURRENT_TIMESTAMP)
109
- ON CONFLICT (validator_address, slot, duty_type) DO NOTHING
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
110
116
  RETURNING
111
117
  validator_address,
112
118
  slot,
113
119
  block_number,
120
+ block_index_within_checkpoint,
114
121
  duty_type,
115
122
  status,
116
123
  message_hash,
@@ -128,6 +135,7 @@ SELECT
128
135
  validator_address,
129
136
  slot,
130
137
  block_number,
138
+ block_index_within_checkpoint,
131
139
  duty_type,
132
140
  status,
133
141
  message_hash,
@@ -141,7 +149,8 @@ SELECT
141
149
  FROM validator_duties
142
150
  WHERE validator_address = $1
143
151
  AND slot = $2
144
- AND duty_type = $4
152
+ AND duty_type = $5
153
+ AND block_index_within_checkpoint = $4
145
154
  AND NOT EXISTS (SELECT 1 FROM inserted);
146
155
  `;
147
156
 
@@ -156,8 +165,9 @@ SET status = 'signed',
156
165
  WHERE validator_address = $2
157
166
  AND slot = $3
158
167
  AND duty_type = $4
168
+ AND block_index_within_checkpoint = $5
159
169
  AND status = 'signing'
160
- AND lock_token = $5;
170
+ AND lock_token = $6;
161
171
  `;
162
172
 
163
173
  /**
@@ -169,8 +179,9 @@ DELETE FROM validator_duties
169
179
  WHERE validator_address = $1
170
180
  AND slot = $2
171
181
  AND duty_type = $3
182
+ AND block_index_within_checkpoint = $4
172
183
  AND status = 'signing'
173
- AND lock_token = $4;
184
+ AND lock_token = $5;
174
185
  `;
175
186
 
176
187
  /**
@@ -223,6 +234,7 @@ SELECT
223
234
  validator_address,
224
235
  slot,
225
236
  block_number,
237
+ block_index_within_checkpoint,
226
238
  duty_type,
227
239
  status,
228
240
  message_hash,