Skip to content

Commit 7e6f438

Browse files
authored
fix: preserve LMDB slashing protection (#23145)
- Preserve local validator slashing-protection records across the known LMDB schema 1 -> 2 migration. - Add a fail-closed schema mismatch policy for versioned stores and wire it into signing protection. - Add regression coverage for preserving legacy duty records and refusing newer stored schemas. Fixes [A-1029](https://linear.app/aztec-labs/issue/A-1029/prevent-lmdb-slashing-protection-reset-on-schema-mismatch) Fixes AztecProtocol/aztec-claude#888
1 parent 7c9e3d5 commit 7e6f438

6 files changed

Lines changed: 221 additions & 10 deletions

File tree

yarn-project/kv-store/src/lmdb-v2/factory.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { EthAddress } from '@aztec/foundation/eth-address';
22
import { type LoggerBindings, createLogger } from '@aztec/foundation/log';
3-
import { DatabaseVersionManager } from '@aztec/stdlib/database-version/manager';
3+
import { DatabaseVersionManager, type SchemaVersionMismatchPolicy } from '@aztec/stdlib/database-version/manager';
44
import type { DataStoreConfig } from '@aztec/stdlib/kv-store';
55

66
import { mkdir, mkdtemp, rm } from 'fs/promises';
@@ -11,11 +11,18 @@ import { AztecLMDBStoreV2 } from './store.js';
1111

1212
const MAX_READERS = 16;
1313

14+
/** Optional versioning hooks for persistent LMDB stores. */
15+
export type CreateStoreOptions = {
16+
onUpgrade?: (dataDir: string, currentVersion: number, latestVersion: number) => Promise<void>;
17+
schemaVersionMismatchPolicy?: SchemaVersionMismatchPolicy;
18+
};
19+
1420
export async function createStore(
1521
name: string,
1622
schemaVersion: number,
1723
config: DataStoreConfig,
1824
bindings?: LoggerBindings,
25+
options: CreateStoreOptions = {},
1926
): Promise<AztecLMDBStoreV2> {
2027
const log = createLogger('kv-store:lmdb-v2:' + name, bindings);
2128
const { dataDirectory, l1Contracts } = config;
@@ -35,6 +42,8 @@ export async function createStore(
3542
dataDirectory: subDir,
3643
onOpen: dbDirectory =>
3744
AztecLMDBStoreV2.new(dbDirectory, config.dataStoreMapSizeKb, MAX_READERS, () => Promise.resolve(), bindings),
45+
onUpgrade: options.onUpgrade,
46+
schemaVersionMismatchPolicy: options.schemaVersionMismatchPolicy,
3847
});
3948

4049
log.info(

yarn-project/stdlib/src/database-version/version_manager.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ describe('VersionManager', () => {
1515
let versionManager: DatabaseVersionManager<object>;
1616
let currentVersion: number;
1717
let rollupAddress: EthAddress;
18+
let expectVersionFileWritten: boolean;
1819

1920
beforeEach(() => {
2021
fs = {
@@ -28,6 +29,7 @@ describe('VersionManager', () => {
2829

2930
currentVersion = 42;
3031
rollupAddress = EthAddress.random();
32+
expectVersionFileWritten = true;
3133

3234
openSpy = jest.fn(() => Promise.resolve({}));
3335
upgradeSpy = jest.fn(() => Promise.resolve());
@@ -43,6 +45,9 @@ describe('VersionManager', () => {
4345

4446
describe('open', () => {
4547
afterEach(() => {
48+
if (!expectVersionFileWritten) {
49+
return;
50+
}
4651
// Verify version file was created
4752
expect(fs.writeFile).toHaveBeenCalledWith(
4853
join(tempDir, DatabaseVersionManager.VERSION_FILE),
@@ -96,6 +101,26 @@ describe('VersionManager', () => {
96101
expect(upgradeSpy).not.toHaveBeenCalled();
97102
});
98103

104+
it('unless schema mismatches are configured to throw', async () => {
105+
fs.readFile.mockResolvedValueOnce(new DatabaseVersion(currentVersion + 1, rollupAddress).toBuffer());
106+
versionManager = new DatabaseVersionManager({
107+
schemaVersion: currentVersion,
108+
rollupAddress,
109+
dataDirectory: tempDir,
110+
onOpen: openSpy,
111+
onUpgrade: upgradeSpy,
112+
schemaVersionMismatchPolicy: 'throw',
113+
fileSystem: fs,
114+
});
115+
expectVersionFileWritten = false;
116+
117+
await expect(versionManager.open()).rejects.toThrow(
118+
`stored schema version ${currentVersion + 1} is incompatible with expected schema version ${currentVersion}`,
119+
);
120+
expect(fs.rm).not.toHaveBeenCalled();
121+
expect(openSpy).not.toHaveBeenCalled();
122+
});
123+
99124
it('when the upgrade fails', async () => {
100125
fs.readFile.mockResolvedValueOnce(new DatabaseVersion(currentVersion - 1, rollupAddress).toBuffer());
101126
upgradeSpy.mockRejectedValueOnce(new Error('Test: failed upgrade'));

yarn-project/stdlib/src/database-version/version_manager.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import { DatabaseVersion } from './database_version.js';
99
export type DatabaseVersionManagerFs = Pick<typeof fs, 'readFile' | 'writeFile' | 'rm' | 'mkdir'>;
1010

1111
export const DATABASE_VERSION_FILE_NAME = 'db_version';
12+
export type SchemaVersionMismatchPolicy = 'reset' | 'throw';
1213

1314
export type DatabaseVersionManagerOptions<T> = {
1415
schemaVersion: number;
1516
rollupAddress: EthAddress;
1617
dataDirectory: string;
1718
onOpen: (dataDir: string) => Promise<T>;
1819
onUpgrade?: (dataDir: string, currentVersion: number, latestVersion: number) => Promise<void>;
20+
schemaVersionMismatchPolicy?: SchemaVersionMismatchPolicy;
1921
fileSystem?: DatabaseVersionManagerFs;
2022
log?: Logger;
2123
};
@@ -34,6 +36,7 @@ export class DatabaseVersionManager<T> {
3436
private dataDirectory: string;
3537
private onOpen: (dataDir: string) => Promise<T>;
3638
private onUpgrade?: (dataDir: string, currentVersion: number, latestVersion: number) => Promise<void>;
39+
private schemaVersionMismatchPolicy: SchemaVersionMismatchPolicy;
3740
private fileSystem: DatabaseVersionManagerFs;
3841
private log: Logger;
3942

@@ -45,6 +48,7 @@ export class DatabaseVersionManager<T> {
4548
* @param dataDirectory - The directory where version information will be stored
4649
* @param onOpen - A callback to the open the database at the given location
4750
* @param onUpgrade - An optional callback to upgrade the database before opening. If not provided it will reset the database
51+
* @param schemaVersionMismatchPolicy - Whether schema mismatches should reset data or throw
4852
* @param fileSystem - An interface to access the filesystem
4953
* @param log - Optional custom logger
5054
* @param options - Configuration options
@@ -55,6 +59,7 @@ export class DatabaseVersionManager<T> {
5559
dataDirectory,
5660
onOpen,
5761
onUpgrade,
62+
schemaVersionMismatchPolicy = 'reset',
5863
fileSystem = fs,
5964
log = createLogger(`foundation:version-manager`),
6065
}: DatabaseVersionManagerOptions<T>) {
@@ -68,6 +73,7 @@ export class DatabaseVersionManager<T> {
6873
this.dataDirectory = dataDirectory;
6974
this.onOpen = onOpen;
7075
this.onUpgrade = onUpgrade;
76+
this.schemaVersionMismatchPolicy = schemaVersionMismatchPolicy;
7177
this.fileSystem = fileSystem;
7278
this.log = log;
7379
}
@@ -115,10 +121,21 @@ export class DatabaseVersionManager<T> {
115121
try {
116122
await this.onUpgrade(this.dataDirectory, storedVersion.schemaVersion, this.currentVersion.schemaVersion);
117123
} catch (error) {
124+
if (this.schemaVersionMismatchPolicy === 'throw') {
125+
throw new Error(
126+
`Failed to upgrade database at ${this.dataDirectory} from schema version ${storedVersion.schemaVersion} to ${this.currentVersion.schemaVersion}`,
127+
{ cause: error },
128+
);
129+
}
118130
this.log.error(`Failed to upgrade: ${error}. Falling back to reset.`);
119131
needsReset = true;
120132
}
121133
} else if (cmp !== 0) {
134+
if (this.schemaVersionMismatchPolicy === 'throw') {
135+
throw new Error(
136+
`Cannot open database at ${this.dataDirectory}: stored schema version ${storedVersion.schemaVersion} is incompatible with expected schema version ${this.currentVersion.schemaVersion}`,
137+
);
138+
}
122139
if (shouldLogDataReset) {
123140
this.log.info(
124141
`Can't upgrade from version ${storedVersion} to ${this.currentVersion}. Resetting database at ${this.dataDirectory}`,

yarn-project/validator-ha-signer/src/db/lmdb.test.ts

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import { BlockNumber, CheckpointNumber, IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types';
22
import { EthAddress } from '@aztec/foundation/eth-address';
33
import { TestDateProvider } from '@aztec/foundation/timer';
4-
import { type AztecLMDBStoreV2, openStoreAt, openTmpStore } from '@aztec/kv-store/lmdb-v2';
4+
import { type AztecLMDBStoreV2, createStore, openStoreAt, openTmpStore } from '@aztec/kv-store/lmdb-v2';
55

66
import { afterEach, beforeEach, describe, expect, it } from '@jest/globals';
77
import { mkdir, mkdtemp, rm } from 'fs/promises';
88
import { tmpdir } from 'os';
99
import { join } from 'path';
1010

11+
import { createLocalSignerWithProtection } from '../factory.js';
1112
import { LmdbSlashingProtectionDatabase } from './lmdb.js';
12-
import { DutyStatus, DutyType } from './types.js';
13+
import { DutyStatus, DutyType, type StoredDutyRecord } from './types.js';
1314

1415
describe('LmdbSlashingProtectionDatabase', () => {
1516
let store: AztecLMDBStoreV2;
@@ -418,3 +419,104 @@ describe('LmdbSlashingProtectionDatabase - persistence across restarts', () => {
418419
}
419420
});
420421
});
422+
423+
describe('LmdbSlashingProtectionDatabase - schema migration', () => {
424+
const ROLLUP_ADDRESS = EthAddress.random();
425+
const VALIDATOR_ADDRESS = EthAddress.random();
426+
const SLOT = SlotNumber(100);
427+
const BLOCK_NUMBER = BlockNumber(50);
428+
const BLOCK_INDEX = IndexWithinCheckpoint(0);
429+
const DUTY_TYPE = DutyType.BLOCK_PROPOSAL;
430+
const MESSAGE_HASH = '0xdeadbeef';
431+
const NODE_ID = 'local';
432+
const SIGNATURE = '0xsignature';
433+
434+
type StoredDutyRecordV1 = Omit<StoredDutyRecord, 'checkpointNumber'>;
435+
436+
let dataDir: string;
437+
let dateProvider: TestDateProvider;
438+
439+
const defaultParams = () => ({
440+
rollupAddress: ROLLUP_ADDRESS,
441+
validatorAddress: VALIDATOR_ADDRESS,
442+
slot: SLOT,
443+
blockNumber: BLOCK_NUMBER,
444+
checkpointNumber: CheckpointNumber(1),
445+
blockIndexWithinCheckpoint: BLOCK_INDEX,
446+
dutyType: DUTY_TYPE,
447+
messageHash: MESSAGE_HASH,
448+
nodeId: NODE_ID,
449+
});
450+
451+
const createConfig = () => ({
452+
l1Contracts: { rollupAddress: ROLLUP_ADDRESS },
453+
nodeId: NODE_ID,
454+
pollingIntervalMs: 100,
455+
signingTimeoutMs: 3_000,
456+
dataDirectory: dataDir,
457+
dataStoreMapSizeKb: 1024 * 1024,
458+
});
459+
460+
const seedSchemaVersion1Duty = async (record: StoredDutyRecordV1) => {
461+
const store = await createStore('signing-protection', 1, createConfig());
462+
const duties = store.openMap<string, StoredDutyRecordV1>('signing-protection-duties');
463+
await duties.set(
464+
`${record.rollupAddress}:${record.validatorAddress}:${record.slot}:${record.dutyType}:${record.blockIndexWithinCheckpoint}`,
465+
record,
466+
);
467+
await store.close();
468+
};
469+
470+
beforeEach(async () => {
471+
dataDir = await mkdtemp(join(tmpdir(), 'lmdb-slashing-migration-'));
472+
await mkdir(dataDir, { recursive: true });
473+
dateProvider = new TestDateProvider();
474+
});
475+
476+
afterEach(async () => {
477+
await rm(dataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
478+
});
479+
480+
it('migrates schema 1 duty records without allowing duplicate signing', async () => {
481+
await seedSchemaVersion1Duty({
482+
rollupAddress: ROLLUP_ADDRESS.toString(),
483+
validatorAddress: VALIDATOR_ADDRESS.toString(),
484+
slot: SLOT.toString(),
485+
blockNumber: BLOCK_NUMBER.toString(),
486+
blockIndexWithinCheckpoint: BLOCK_INDEX,
487+
dutyType: DUTY_TYPE,
488+
status: DutyStatus.SIGNED,
489+
messageHash: MESSAGE_HASH,
490+
signature: SIGNATURE,
491+
nodeId: NODE_ID,
492+
lockToken: 'legacy-lock-token',
493+
startedAtMs: dateProvider.now(),
494+
completedAtMs: dateProvider.now(),
495+
});
496+
497+
const { db } = await createLocalSignerWithProtection(createConfig(), { dateProvider });
498+
try {
499+
const result = await db.tryInsertOrGetExisting(defaultParams());
500+
501+
expect(result.isNew).toBe(false);
502+
expect(result.record.status).toBe(DutyStatus.SIGNED);
503+
expect(result.record.signature).toBe(SIGNATURE);
504+
expect(result.record.checkpointNumber).toBe(CheckpointNumber(0));
505+
} finally {
506+
await db.close();
507+
}
508+
});
509+
510+
it('fails closed instead of resetting when the stored schema is newer', async () => {
511+
const store = await createStore(
512+
'signing-protection',
513+
LmdbSlashingProtectionDatabase.SCHEMA_VERSION + 1,
514+
createConfig(),
515+
);
516+
await store.close();
517+
518+
await expect(createLocalSignerWithProtection(createConfig(), { dateProvider })).rejects.toThrow(
519+
`stored schema version ${LmdbSlashingProtectionDatabase.SCHEMA_VERSION + 1} is incompatible with expected schema version ${LmdbSlashingProtectionDatabase.SCHEMA_VERSION}`,
520+
);
521+
});
522+
});

yarn-project/validator-ha-signer/src/db/lmdb.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { EthAddress } from '@aztec/foundation/eth-address';
1313
import { type Logger, createLogger } from '@aztec/foundation/log';
1414
import type { DateProvider } from '@aztec/foundation/timer';
1515
import type { AztecAsyncKVStore, AztecAsyncMap } from '@aztec/kv-store';
16+
import { openStoreAt } from '@aztec/kv-store/lmdb-v2';
1617

1718
import type { SlashingProtectionDatabase, TryInsertOrGetResult } from '../types.js';
1819
import {
@@ -24,6 +25,48 @@ import {
2425
recordFromFields,
2526
} from './types.js';
2627

28+
const DUTIES_MAP_NAME = 'signing-protection-duties';
29+
const LEGACY_CHECKPOINT_NUMBER = '0';
30+
31+
type StoredDutyRecordV1 = Omit<StoredDutyRecord, 'checkpointNumber'> & { checkpointNumber?: undefined };
32+
type MigratableStoredDutyRecord = StoredDutyRecord | StoredDutyRecordV1;
33+
34+
function needsCheckpointNumberMigration(record: MigratableStoredDutyRecord): record is StoredDutyRecordV1 {
35+
return record.checkpointNumber === undefined;
36+
}
37+
38+
/**
39+
* Migrates local slashing-protection duties from schema 1 to schema 2.
40+
*/
41+
export async function migrateLmdbSlashingProtectionDatabase(
42+
dataDirectory: string,
43+
currentVersion: number,
44+
latestVersion: number,
45+
dbMapSizeKb?: number,
46+
): Promise<void> {
47+
if (currentVersion !== 1 || latestVersion !== LmdbSlashingProtectionDatabase.SCHEMA_VERSION) {
48+
throw new Error(`Unsupported LMDB slashing-protection migration ${currentVersion} -> ${latestVersion}`);
49+
}
50+
51+
const store = await openStoreAt(dataDirectory, dbMapSizeKb);
52+
try {
53+
const duties = store.openMap<string, MigratableStoredDutyRecord>(DUTIES_MAP_NAME);
54+
const migratedRecords: { key: string; value: StoredDutyRecord }[] = [];
55+
56+
for await (const [key, record] of duties.entriesAsync()) {
57+
if (needsCheckpointNumberMigration(record)) {
58+
migratedRecords.push({ key, value: { ...record, checkpointNumber: LEGACY_CHECKPOINT_NUMBER } });
59+
}
60+
}
61+
62+
if (migratedRecords.length > 0) {
63+
await duties.setMany(migratedRecords);
64+
}
65+
} finally {
66+
await store.close();
67+
}
68+
}
69+
2770
function dutyKey(
2871
rollupAddress: string,
2972
validatorAddress: string,
@@ -51,7 +94,7 @@ export class LmdbSlashingProtectionDatabase implements SlashingProtectionDatabas
5194
private readonly dateProvider: DateProvider,
5295
) {
5396
this.log = createLogger('slashing-protection:lmdb');
54-
this.duties = store.openMap<string, StoredDutyRecord>('signing-protection-duties');
97+
this.duties = store.openMap<string, StoredDutyRecord>(DUTIES_MAP_NAME);
5598
}
5699

57100
/**

yarn-project/validator-ha-signer/src/factory.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { getTelemetryClient } from '@aztec/telemetry-client';
88

99
import { Pool } from 'pg';
1010

11-
import { LmdbSlashingProtectionDatabase } from './db/lmdb.js';
11+
import { LmdbSlashingProtectionDatabase, migrateLmdbSlashingProtectionDatabase } from './db/lmdb.js';
1212
import { PostgresSlashingProtectionDatabase } from './db/postgres.js';
1313
import { HASignerMetrics } from './metrics.js';
1414
import type { CreateHASignerDeps, CreateLocalSignerWithProtectionDeps, SlashingProtectionDatabase } from './types.js';
@@ -119,11 +119,26 @@ export async function createLocalSignerWithProtection(
119119
const telemetryClient = deps?.telemetryClient ?? getTelemetryClient();
120120
const dateProvider = deps?.dateProvider ?? new DateProvider();
121121

122-
const kvStore = await createStore('signing-protection', LmdbSlashingProtectionDatabase.SCHEMA_VERSION, {
123-
dataDirectory: config.dataDirectory,
124-
dataStoreMapSizeKb: config.signingProtectionMapSizeKb ?? config.dataStoreMapSizeKb,
125-
l1Contracts: config.l1Contracts,
126-
});
122+
const kvStore = await createStore(
123+
'signing-protection',
124+
LmdbSlashingProtectionDatabase.SCHEMA_VERSION,
125+
{
126+
dataDirectory: config.dataDirectory,
127+
dataStoreMapSizeKb: config.signingProtectionMapSizeKb ?? config.dataStoreMapSizeKb,
128+
l1Contracts: config.l1Contracts,
129+
},
130+
undefined,
131+
{
132+
onUpgrade: (dataDirectory, currentVersion, latestVersion) =>
133+
migrateLmdbSlashingProtectionDatabase(
134+
dataDirectory,
135+
currentVersion,
136+
latestVersion,
137+
config.signingProtectionMapSizeKb ?? config.dataStoreMapSizeKb,
138+
),
139+
schemaVersionMismatchPolicy: 'throw',
140+
},
141+
);
127142

128143
const db = new LmdbSlashingProtectionDatabase(kvStore, dateProvider);
129144

0 commit comments

Comments
 (0)