@aztec/validator-ha-signer 0.0.1-commit.7d4e6cd → 0.0.1-commit.86469d5

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 +66 -17
  3. package/dest/config.d.ts.map +1 -1
  4. package/dest/config.js +41 -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 +56 -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 +35 -15
  11. package/dest/db/types.d.ts +80 -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 +23 -11
  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 +9 -12
  33. package/dest/validator_ha_signer.d.ts.map +1 -1
  34. package/dest/validator_ha_signer.js +31 -30
  35. package/package.json +9 -8
  36. package/src/config.ts +75 -50
  37. package/src/db/postgres.ts +76 -19
  38. package/src/db/schema.ts +35 -15
  39. package/src/db/types.ts +110 -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 +55 -13
  44. package/src/test/pglite_pool.ts +256 -0
  45. package/src/types.ts +125 -19
  46. package/src/validator_ha_signer.ts +38 -40
@@ -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
  *
@@ -28,19 +30,22 @@ import { SlashingProtectionService } from './slashing_protection_service.js';
28
30
  config;
29
31
  log;
30
32
  slashingProtection;
33
+ rollupAddress;
31
34
  constructor(db, config){
32
35
  this.config = config;
33
36
  this.log = createLogger('validator-ha-signer');
34
- if (!config.enabled) {
37
+ if (!config.haSigningEnabled) {
35
38
  // this shouldn't happen, the validator should use different signer for non-HA setups
36
39
  throw new Error('Validator HA Signer is not enabled in config');
37
40
  }
38
41
  if (!config.nodeId || config.nodeId === '') {
39
42
  throw new Error('NODE_ID is required for high-availability setups');
40
43
  }
44
+ this.rollupAddress = config.l1Contracts.rollupAddress;
41
45
  this.slashingProtection = new SlashingProtectionService(db, config);
42
46
  this.log.info('Validator HA Signer initialized with slashing protection', {
43
- nodeId: config.nodeId
47
+ nodeId: config.nodeId,
48
+ rollupAddress: this.rollupAddress.toString()
44
49
  });
45
50
  }
46
51
  /**
@@ -53,31 +58,35 @@ import { SlashingProtectionService } from './slashing_protection_service.js';
53
58
  *
54
59
  * @param validatorAddress - The validator's Ethereum address
55
60
  * @param messageHash - The hash to be signed
56
- * @param context - The signing context (slot, block number, duty type)
61
+ * @param context - The signing context (HA-protected duty types only)
57
62
  * @param signFn - Function that performs the actual signing
58
63
  * @returns The signature
59
64
  *
60
65
  * @throws DutyAlreadySignedError if the duty was already signed (expected in HA)
61
66
  * @throws SlashingProtectionError if attempting to sign different data for same slot (expected in HA)
62
67
  */ 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,
68
+ let dutyIdentifier;
69
+ if (context.dutyType === DutyType.BLOCK_PROPOSAL) {
70
+ dutyIdentifier = {
71
+ rollupAddress: this.rollupAddress,
72
+ validatorAddress,
69
73
  slot: context.slot,
70
- blockNumber: context.blockNumber
71
- });
72
- return await signFn(messageHash);
74
+ blockIndexWithinCheckpoint: context.blockIndexWithinCheckpoint,
75
+ dutyType: context.dutyType
76
+ };
77
+ } else {
78
+ dutyIdentifier = {
79
+ rollupAddress: this.rollupAddress,
80
+ validatorAddress,
81
+ slot: context.slot,
82
+ dutyType: context.dutyType
83
+ };
73
84
  }
74
- const { slot, blockNumber, dutyType } = context;
75
85
  // Acquire lock and get the token for ownership verification
86
+ const blockNumber = getBlockNumberFromSigningContext(context);
76
87
  const lockToken = await this.slashingProtection.checkAndRecord({
77
- validatorAddress,
78
- slot,
88
+ ...dutyIdentifier,
79
89
  blockNumber,
80
- dutyType,
81
90
  messageHash: messageHash.toString(),
82
91
  nodeId: this.config.nodeId
83
92
  });
@@ -88,18 +97,14 @@ import { SlashingProtectionService } from './slashing_protection_service.js';
88
97
  } catch (error) {
89
98
  // Delete duty to allow retry (only succeeds if we own the lock)
90
99
  await this.slashingProtection.deleteDuty({
91
- validatorAddress,
92
- slot,
93
- dutyType,
100
+ ...dutyIdentifier,
94
101
  lockToken
95
102
  });
96
103
  throw error;
97
104
  }
98
105
  // Record success (only succeeds if we own the lock)
99
106
  await this.slashingProtection.recordSuccess({
100
- validatorAddress,
101
- slot,
102
- dutyType,
107
+ ...dutyIdentifier,
103
108
  signature,
104
109
  nodeId: this.config.nodeId,
105
110
  lockToken
@@ -107,11 +112,6 @@ import { SlashingProtectionService } from './slashing_protection_service.js';
107
112
  return signature;
108
113
  }
109
114
  /**
110
- * Check if slashing protection is enabled
111
- */ get isEnabled() {
112
- return this.slashingProtection !== undefined;
113
- }
114
- /**
115
115
  * Get the node ID for this signer
116
116
  */ get nodeId() {
117
117
  return this.config.nodeId;
@@ -120,12 +120,13 @@ import { SlashingProtectionService } from './slashing_protection_service.js';
120
120
  * Start the HA signer background tasks (cleanup of stuck duties).
121
121
  * Should be called after construction and before signing operations.
122
122
  */ start() {
123
- this.slashingProtection?.start();
123
+ this.slashingProtection.start();
124
124
  }
125
125
  /**
126
- * Stop the HA signer background tasks.
126
+ * Stop the HA signer background tasks and close database connection.
127
127
  * Should be called during graceful shutdown.
128
128
  */ async stop() {
129
- await this.slashingProtection?.stop();
129
+ await this.slashingProtection.stop();
130
+ await this.slashingProtection.close();
130
131
  }
131
132
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aztec/validator-ha-signer",
3
- "version": "0.0.1-commit.7d4e6cd",
3
+ "version": "0.0.1-commit.86469d5",
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,21 @@
73
74
  ]
74
75
  },
75
76
  "dependencies": {
76
- "@aztec/foundation": "0.0.1-commit.7d4e6cd",
77
- "@aztec/node-keystore": "0.0.1-commit.7d4e6cd",
77
+ "@aztec/ethereum": "0.0.1-commit.86469d5",
78
+ "@aztec/foundation": "0.0.1-commit.86469d5",
78
79
  "node-pg-migrate": "^8.0.4",
79
80
  "pg": "^8.11.3",
80
- "tslib": "^2.4.0"
81
+ "tslib": "^2.4.0",
82
+ "zod": "^3.23.8"
81
83
  },
82
84
  "devDependencies": {
83
- "@electric-sql/pglite": "^0.2.17",
85
+ "@electric-sql/pglite": "^0.3.14",
84
86
  "@jest/globals": "^30.0.0",
85
- "@middle-management/pglite-pg-adapter": "^0.0.3",
86
87
  "@types/jest": "^30.0.0",
87
88
  "@types/node": "^22.15.17",
88
89
  "@types/node-pg-migrate": "^2.3.1",
89
90
  "@types/pg": "^8.10.9",
90
- "@typescript/native-preview": "7.0.0-dev.20251126.1",
91
+ "@typescript/native-preview": "7.0.0-dev.20260113.1",
91
92
  "jest": "^30.0.0",
92
93
  "jest-mock-extended": "^4.0.0",
93
94
  "ts-node": "^10.9.1",
package/src/config.ts CHANGED
@@ -1,69 +1,41 @@
1
+ import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses';
1
2
  import {
2
3
  type ConfigMappingsType,
3
4
  booleanConfigHelper,
4
5
  getConfigFromMappings,
5
6
  getDefaultConfig,
6
7
  numberConfigHelper,
8
+ optionalNumberConfigHelper,
7
9
  } from '@aztec/foundation/config';
10
+ import { EthAddress } from '@aztec/foundation/eth-address';
11
+ import type { ZodFor } from '@aztec/foundation/schemas';
12
+
13
+ import { z } from 'zod';
8
14
 
9
15
  /**
10
- * Configuration for the slashing protection service
16
+ * Configuration for the Validator HA Signer
17
+ *
18
+ * This config is used for distributed locking and slashing protection
19
+ * when running multiple validator nodes in a high-availability setup.
11
20
  */
12
- export interface SlashingProtectionConfig {
13
- /** Whether slashing protection is enabled */
14
- enabled: boolean;
21
+ export interface ValidatorHASignerConfig {
22
+ /** Whether HA signing / slashing protection is enabled */
23
+ haSigningEnabled: boolean;
24
+ /** L1 contract addresses (rollup address required) */
25
+ l1Contracts: Pick<L1ContractAddresses, 'rollupAddress'>;
15
26
  /** Unique identifier for this node */
16
27
  nodeId: string;
17
28
  /** How long to wait between polls when a duty is being signed (ms) */
18
29
  pollingIntervalMs: number;
19
30
  /** Maximum time to wait for a duty being signed to complete (ms) */
20
31
  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 {
32
+ /** Maximum age of a stuck duty in ms (defaults to 2x hardcoded Aztec slot duration if not set) */
33
+ maxStuckDutiesAgeMs?: number;
62
34
  /**
63
35
  * PostgreSQL connection string
64
36
  * Format: postgresql://user:password@host:port/database
65
37
  */
66
- databaseUrl: string;
38
+ databaseUrl?: string;
67
39
  /**
68
40
  * PostgreSQL connection pool configuration
69
41
  */
@@ -77,8 +49,41 @@ export interface CreateHASignerConfig extends SlashingProtectionConfig {
77
49
  poolConnectionTimeoutMs?: number;
78
50
  }
79
51
 
80
- export const createHASignerConfigMappings: ConfigMappingsType<CreateHASignerConfig> = {
81
- ...slashingProtectionConfigMappings,
52
+ export const validatorHASignerConfigMappings: ConfigMappingsType<ValidatorHASignerConfig> = {
53
+ haSigningEnabled: {
54
+ env: 'VALIDATOR_HA_SIGNING_ENABLED',
55
+ description: 'Whether HA signing / slashing protection is enabled',
56
+ ...booleanConfigHelper(false),
57
+ },
58
+ l1Contracts: {
59
+ description: 'L1 contract addresses (rollup address required)',
60
+ nested: {
61
+ rollupAddress: {
62
+ description: 'The Ethereum address of the rollup contract (must be set programmatically)',
63
+ parseEnv: (val: string) => EthAddress.fromString(val),
64
+ },
65
+ },
66
+ },
67
+ nodeId: {
68
+ env: 'VALIDATOR_HA_NODE_ID',
69
+ description: 'The unique identifier for this node',
70
+ defaultValue: '',
71
+ },
72
+ pollingIntervalMs: {
73
+ env: 'VALIDATOR_HA_POLLING_INTERVAL_MS',
74
+ description: 'The number of ms to wait between polls when a duty is being signed',
75
+ ...numberConfigHelper(100),
76
+ },
77
+ signingTimeoutMs: {
78
+ env: 'VALIDATOR_HA_SIGNING_TIMEOUT_MS',
79
+ description: 'The maximum time to wait for a duty being signed to complete',
80
+ ...numberConfigHelper(3_000),
81
+ },
82
+ maxStuckDutiesAgeMs: {
83
+ env: 'VALIDATOR_HA_MAX_STUCK_DUTIES_AGE_MS',
84
+ description: 'The maximum age of a stuck duty in ms (defaults to 2x Aztec slot duration)',
85
+ ...optionalNumberConfigHelper(),
86
+ },
82
87
  databaseUrl: {
83
88
  env: 'VALIDATOR_HA_DATABASE_URL',
84
89
  description:
@@ -106,11 +111,31 @@ export const createHASignerConfigMappings: ConfigMappingsType<CreateHASignerConf
106
111
  },
107
112
  };
108
113
 
114
+ export const defaultValidatorHASignerConfig: ValidatorHASignerConfig = getDefaultConfig(
115
+ validatorHASignerConfigMappings,
116
+ );
117
+
109
118
  /**
110
119
  * Returns the validator HA signer configuration from environment variables.
111
120
  * Note: If an environment variable is not set, the default value is used.
112
121
  * @returns The validator HA signer configuration.
113
122
  */
114
- export function getConfigEnvVars(): CreateHASignerConfig {
115
- return getConfigFromMappings<CreateHASignerConfig>(createHASignerConfigMappings);
123
+ export function getConfigEnvVars(): ValidatorHASignerConfig {
124
+ return getConfigFromMappings<ValidatorHASignerConfig>(validatorHASignerConfigMappings);
116
125
  }
126
+
127
+ export const ValidatorHASignerConfigSchema = z.object({
128
+ haSigningEnabled: z.boolean(),
129
+ l1Contracts: z.object({
130
+ rollupAddress: z.instanceof(EthAddress),
131
+ }),
132
+ nodeId: z.string(),
133
+ pollingIntervalMs: z.number().min(0),
134
+ signingTimeoutMs: z.number().min(0),
135
+ maxStuckDutiesAgeMs: z.number().min(0).optional(),
136
+ databaseUrl: z.string().optional(),
137
+ poolMaxCount: z.number().min(0).optional(),
138
+ poolMinCount: z.number().min(0).optional(),
139
+ poolIdleTimeoutMs: z.number().min(0).optional(),
140
+ poolConnectionTimeoutMs: z.number().min(0).optional(),
141
+ }) 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,55 @@ 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.rollupAddress.toString(),
105
+ params.validatorAddress.toString(),
106
+ params.slot.toString(),
107
+ params.blockNumber.toString(),
108
+ blockIndexWithinCheckpoint,
109
+ params.dutyType,
110
+ params.messageHash,
111
+ params.nodeId,
112
+ lockToken,
113
+ ]);
114
+
115
+ // Throw error if no rows to trigger retry
116
+ if (queryResult.rows.length === 0) {
117
+ throw new Error('INSERT_OR_GET_DUTY returned no rows');
118
+ }
119
+
120
+ return queryResult;
121
+ },
122
+ `INSERT_OR_GET_DUTY for node ${params.nodeId}`,
123
+ fastBackoff,
124
+ this.log,
125
+ true,
126
+ );
89
127
 
90
128
  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');
129
+ // this should never happen as the retry function should throw if it still fails after retries
130
+ throw new Error('INSERT_OR_GET_DUTY returned no rows after retries');
131
+ }
132
+
133
+ if (result.rows.length > 1) {
134
+ // this should never happen if database constraints are correct (PRIMARY KEY should prevent duplicates)
135
+ throw new Error(`INSERT_OR_GET_DUTY returned ${result.rows.length} rows (expected exactly 1).`);
93
136
  }
94
137
 
95
138
  const row = result.rows[0];
@@ -106,25 +149,31 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
106
149
  * @returns true if the update succeeded, false if token didn't match or duty not found
107
150
  */
108
151
  async updateDutySigned(
152
+ rollupAddress: EthAddress,
109
153
  validatorAddress: EthAddress,
110
- slot: bigint,
154
+ slot: SlotNumber,
111
155
  dutyType: DutyType,
112
156
  signature: string,
113
157
  lockToken: string,
158
+ blockIndexWithinCheckpoint: number,
114
159
  ): Promise<boolean> {
115
160
  const result = await this.pool.query(UPDATE_DUTY_SIGNED, [
116
161
  signature,
162
+ rollupAddress.toString(),
117
163
  validatorAddress.toString(),
118
164
  slot.toString(),
119
165
  dutyType,
166
+ blockIndexWithinCheckpoint,
120
167
  lockToken,
121
168
  ]);
122
169
 
123
170
  if (result.rowCount === 0) {
124
171
  this.log.warn('Failed to update duty to signed status: invalid token or duty not found', {
172
+ rollupAddress: rollupAddress.toString(),
125
173
  validatorAddress: validatorAddress.toString(),
126
174
  slot: slot.toString(),
127
175
  dutyType,
176
+ blockIndexWithinCheckpoint,
128
177
  });
129
178
  return false;
130
179
  }
@@ -139,23 +188,29 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
139
188
  * @returns true if the delete succeeded, false if token didn't match or duty not found
140
189
  */
141
190
  async deleteDuty(
191
+ rollupAddress: EthAddress,
142
192
  validatorAddress: EthAddress,
143
- slot: bigint,
193
+ slot: SlotNumber,
144
194
  dutyType: DutyType,
145
195
  lockToken: string,
196
+ blockIndexWithinCheckpoint: number,
146
197
  ): Promise<boolean> {
147
198
  const result = await this.pool.query(DELETE_DUTY, [
199
+ rollupAddress.toString(),
148
200
  validatorAddress.toString(),
149
201
  slot.toString(),
150
202
  dutyType,
203
+ blockIndexWithinCheckpoint,
151
204
  lockToken,
152
205
  ]);
153
206
 
154
207
  if (result.rowCount === 0) {
155
208
  this.log.warn('Failed to delete duty: invalid token or duty not found', {
209
+ rollupAddress: rollupAddress.toString(),
156
210
  validatorAddress: validatorAddress.toString(),
157
211
  slot: slot.toString(),
158
212
  dutyType,
213
+ blockIndexWithinCheckpoint,
159
214
  });
160
215
  return false;
161
216
  }
@@ -167,9 +222,11 @@ export class PostgresSlashingProtectionDatabase implements SlashingProtectionDat
167
222
  */
168
223
  private rowToRecord(row: DutyRow): ValidatorDutyRecord {
169
224
  return {
225
+ rollupAddress: EthAddress.fromString(row.rollup_address),
170
226
  validatorAddress: EthAddress.fromString(row.validator_address),
171
- slot: BigInt(row.slot),
172
- blockNumber: BigInt(row.block_number),
227
+ slot: SlotNumber.fromString(row.slot),
228
+ blockNumber: BlockNumber.fromString(row.block_number),
229
+ blockIndexWithinCheckpoint: row.block_index_within_checkpoint,
173
230
  dutyType: row.duty_type,
174
231
  status: row.status,
175
232
  messageHash: row.message_hash,
package/src/db/schema.ts CHANGED
@@ -16,10 +16,12 @@ 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
+ 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')),
23
25
  status VARCHAR(20) NOT NULL CHECK (status IN ('signing', 'signed', 'failed')),
24
26
  message_hash VARCHAR(66) NOT NULL,
25
27
  signature VARCHAR(132),
@@ -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
  /**
@@ -220,9 +238,11 @@ export const DROP_SCHEMA_VERSION_TABLE = `DROP TABLE IF EXISTS schema_version;`;
220
238
  */
221
239
  export const GET_STUCK_DUTIES = `
222
240
  SELECT
241
+ rollup_address,
223
242
  validator_address,
224
243
  slot,
225
244
  block_number,
245
+ block_index_within_checkpoint,
226
246
  duty_type,
227
247
  status,
228
248
  message_hash,