@aztec/validator-ha-signer 0.0.1-commit.7d4e6cd → 0.0.1-commit.d1f2d6c
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.
- package/README.md +42 -37
- package/dest/config.d.ts +49 -17
- package/dest/config.d.ts.map +1 -1
- package/dest/config.js +28 -19
- package/dest/db/postgres.d.ts +20 -5
- package/dest/db/postgres.d.ts.map +1 -1
- package/dest/db/postgres.js +50 -19
- package/dest/db/schema.d.ts +11 -7
- package/dest/db/schema.d.ts.map +1 -1
- package/dest/db/schema.js +19 -7
- package/dest/db/types.d.ts +75 -23
- package/dest/db/types.d.ts.map +1 -1
- package/dest/db/types.js +34 -0
- package/dest/errors.d.ts +9 -5
- package/dest/errors.d.ts.map +1 -1
- package/dest/errors.js +7 -4
- package/dest/factory.d.ts +6 -14
- package/dest/factory.d.ts.map +1 -1
- package/dest/factory.js +6 -11
- package/dest/migrations.d.ts +1 -1
- package/dest/migrations.d.ts.map +1 -1
- package/dest/migrations.js +13 -2
- package/dest/slashing_protection_service.d.ts +9 -3
- package/dest/slashing_protection_service.d.ts.map +1 -1
- package/dest/slashing_protection_service.js +21 -9
- package/dest/test/pglite_pool.d.ts +92 -0
- package/dest/test/pglite_pool.d.ts.map +1 -0
- package/dest/test/pglite_pool.js +210 -0
- package/dest/types.d.ts +78 -14
- package/dest/types.d.ts.map +1 -1
- package/dest/types.js +21 -1
- package/dest/validator_ha_signer.d.ts +7 -11
- package/dest/validator_ha_signer.d.ts.map +1 -1
- package/dest/validator_ha_signer.js +25 -29
- package/package.json +8 -8
- package/src/config.ts +59 -50
- package/src/db/postgres.ts +68 -19
- package/src/db/schema.ts +19 -7
- package/src/db/types.ts +105 -21
- package/src/errors.ts +7 -2
- package/src/factory.ts +8 -13
- package/src/migrations.ts +17 -1
- package/src/slashing_protection_service.ts +46 -12
- package/src/test/pglite_pool.ts +256 -0
- package/src/types.ts +121 -19
- 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.
|
|
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 (
|
|
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
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
validatorAddress
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.0.1-commit.d1f2d6c",
|
|
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.
|
|
77
|
-
"@aztec/node-keystore": "0.0.1-commit.7d4e6cd",
|
|
77
|
+
"@aztec/foundation": "0.0.1-commit.d1f2d6c",
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
13
|
-
/** Whether slashing protection is enabled */
|
|
14
|
-
|
|
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
|
|
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
|
|
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
|
|
81
|
-
|
|
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():
|
|
115
|
-
return getConfigFromMappings<
|
|
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>;
|
package/src/db/postgres.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
//
|
|
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:
|
|
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:
|
|
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:
|
|
172
|
-
blockNumber:
|
|
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
|
-
|
|
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', $
|
|
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 = $
|
|
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 = $
|
|
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 = $
|
|
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,
|