From 57c3390f260946814aeb810b7332feaea7e26b2e Mon Sep 17 00:00:00 2001 From: 3alpha <15694175+3alpha@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:51:30 +0200 Subject: [PATCH] fix: restore validator keys and simplify settings updates --- .../launchpad/routes/feeRecipients/route.ts | 3 + .../apiServers/launchpad/startLaunchpadApi.ts | 1 + .../apiServers/ui/calls/importValidators.ts | 4 +- .../src/modules/apiServers/ui/calls/index.ts | 1 + .../apiServers/ui/calls/updateValidators.ts | 158 ++++++++++++------ .../src/modules/config/importBuilderIds.ts | 12 +- .../modules/cron/reloadValidators/index.ts | 4 +- .../setActiveValidatorsLoadedInBrain.ts | 2 +- packages/brain/src/modules/db/index.ts | 48 +++--- packages/brain/src/modules/db/types.ts | 4 +- ...ActiveValidatorsLoadedInBrain.unit.test.ts | 6 +- .../builderConfig/builderConfig.unit.test.ts | 41 ++++- .../config/importBuilderIds.unit.test.ts | 26 ++- .../test/unit/modules/db/index.unit.test.ts | 58 ++++++- .../unit/modules/keyManagement.unit.test.ts | 1 + 15 files changed, 249 insertions(+), 120 deletions(-) diff --git a/packages/brain/src/modules/apiServers/launchpad/routes/feeRecipients/route.ts b/packages/brain/src/modules/apiServers/launchpad/routes/feeRecipients/route.ts index 1f40d0c1..89e60cd0 100644 --- a/packages/brain/src/modules/apiServers/launchpad/routes/feeRecipients/route.ts +++ b/packages/brain/src/modules/apiServers/launchpad/routes/feeRecipients/route.ts @@ -41,12 +41,14 @@ import { ValidatorApi } from "../../../../apiClients/validator/index.js"; export const createFeeRecipientsRouter = ({ brainDb, signerApi, + signerUrl, validatorApi, beaconchainApi, reloadValidatorsCronTask }: { brainDb: BrainDataBase; signerApi: Web3SignerApi; + signerUrl: string; validatorApi: ValidatorApi; beaconchainApi: BeaconchainApi; reloadValidatorsCronTask: CronJob; @@ -168,6 +170,7 @@ export const createFeeRecipientsRouter = ({ brainDb, validatorApi, customValidatorUpdateRequest: validatorsToUpdate.map(({ pubkey, feeRecipient }) => ({ pubkey, feeRecipient })), + signerUrl, requestFrom: "api" }); diff --git a/packages/brain/src/modules/apiServers/launchpad/startLaunchpadApi.ts b/packages/brain/src/modules/apiServers/launchpad/startLaunchpadApi.ts index 83cf3070..3bae4ca1 100644 --- a/packages/brain/src/modules/apiServers/launchpad/startLaunchpadApi.ts +++ b/packages/brain/src/modules/apiServers/launchpad/startLaunchpadApi.ts @@ -40,6 +40,7 @@ export function startLaunchpadApi({ createFeeRecipientsRouter({ brainDb, signerApi, + signerUrl, validatorApi, beaconchainApi, reloadValidatorsCronTask diff --git a/packages/brain/src/modules/apiServers/ui/calls/importValidators.ts b/packages/brain/src/modules/apiServers/ui/calls/importValidators.ts index 6b920a74..14b6dd78 100644 --- a/packages/brain/src/modules/apiServers/ui/calls/importValidators.ts +++ b/packages/brain/src/modules/apiServers/ui/calls/importValidators.ts @@ -169,9 +169,7 @@ export async function importValidators({ brainDb.deleteValidators(rejectedNewKeys); if (confirmedKeys.length > 0) - brainDb.updateValidators({ - validators: Object.fromEntries(confirmedKeys.map((pubkey) => [pubkey, { importPending: undefined }])) - }); + brainDb.updateValidators(confirmedKeys.map((pubkey) => ({ pubkey, importPending: undefined }))); if (validatorsToPost.length === 0) return results; diff --git a/packages/brain/src/modules/apiServers/ui/calls/index.ts b/packages/brain/src/modules/apiServers/ui/calls/index.ts index af1528c0..a2457f1a 100644 --- a/packages/brain/src/modules/apiServers/ui/calls/index.ts +++ b/packages/brain/src/modules/apiServers/ui/calls/index.ts @@ -77,6 +77,7 @@ export const createRpcMethods = ({ brainDb, validatorApi, customValidatorUpdateRequest, + signerUrl, requestFrom }), getValidators: async () => diff --git a/packages/brain/src/modules/apiServers/ui/calls/updateValidators.ts b/packages/brain/src/modules/apiServers/ui/calls/updateValidators.ts index ac01b427..73e92ece 100644 --- a/packages/brain/src/modules/apiServers/ui/calls/updateValidators.ts +++ b/packages/brain/src/modules/apiServers/ui/calls/updateValidators.ts @@ -1,73 +1,129 @@ -import { withValidatorOperationLock } from "../../../db/withValidatorOperationLock.js"; import { prefix0xPubkey, isFeeRecipientEditable, ActionRequestOrigin } from "@stakingbrain/common"; -import logger from "../../../../modules/logger/index.js"; +import { withValidatorOperationLock } from "../../../db/withValidatorOperationLock.js"; +import logger from "../../../logger/index.js"; import { CustomValidatorUpdateRequest } from "./types.js"; -import { StakingBrainDbUpdate } from "../../../../modules/db/types.js"; +import { StakingBrainDb } from "../../../db/types.js"; import { CronJob } from "../../../cron/cron.js"; import { BrainDataBase } from "../../../db/index.js"; import { ValidatorApi } from "../../../apiClients/validator/index.js"; import { applyValidatorBuilderConfig } from "../../../cron/reloadValidators/postValidatorsBuilderConfigsFromDb.js"; +type UpdateValidatorsOptions = { + reloadValidatorsCronTask: CronJob; + brainDb: BrainDataBase; + validatorApi: ValidatorApi; + signerUrl: string; + customValidatorUpdateRequest: CustomValidatorUpdateRequest[]; + requestFrom?: ActionRequestOrigin; +}; + /** Persist desired settings first. Periodic reload retries client failures. */ export async function updateValidators({ reloadValidatorsCronTask, brainDb, validatorApi, - customValidatorUpdateRequest, + signerUrl, + customValidatorUpdateRequest: requests, requestFrom -}: { - reloadValidatorsCronTask: CronJob; - brainDb: BrainDataBase; - validatorApi: ValidatorApi; - customValidatorUpdateRequest: CustomValidatorUpdateRequest[]; - requestFrom?: ActionRequestOrigin; -}): Promise { +}: UpdateValidatorsOptions): Promise { return withValidatorOperationLock(brainDb, async () => { reloadValidatorsCronTask.stop(); try { - if (!Array.isArray(customValidatorUpdateRequest) || customValidatorUpdateRequest.length === 0) - throw new Error("Select at least one validator to update"); - const dbData = brainDb.getData(); - const updates: StakingBrainDbUpdate = {}; - for (const validator of customValidatorUpdateRequest) { - if (!validator || typeof validator.pubkey !== "string") throw new Error("Validator public key is required"); - const pubkey = prefix0xPubkey(validator.pubkey); - if (!dbData[pubkey]) throw new Error(`Validator ${pubkey} is not in the database`); - if (updates[pubkey]) throw new Error(`Duplicate validator ${pubkey}`); - if (Object.keys(validator).some((field) => !["pubkey", "feeRecipient", "builderConfig"].includes(field))) - throw new Error("Unsupported validator setting"); - const update: StakingBrainDbUpdate[string] = {}; - if ("feeRecipient" in validator && isFeeRecipientEditable(dbData[pubkey].tag, requestFrom)) - update.feeRecipient = validator.feeRecipient; - if ("builderConfig" in validator) update.builderConfig = validator.builderConfig; - if (Object.keys(update).length > 0) updates[pubkey] = update; - } - if (Object.keys(updates).length === 0) throw new Error("The fee recipient can't be updated for these validators"); + const currentValidators = brainDb.getData(); + const updates = prepareUpdates(requests, currentValidators, requestFrom); - // Validate the entire batch before changing any persisted settings. - brainDb.updateValidators({ validators: updates }); - const failures: string[] = []; - for (const [pubkey, update] of Object.entries(updates)) { - if (update.feeRecipient !== undefined) - await validatorApi.setFeeRecipient(update.feeRecipient, pubkey).catch((err) => { - logger.error("Error setting validator feeRecipient", err); - failures.push(`Fee recipient application failed for ${pubkey}`); - }); - if ("builderConfig" in update) { - try { - await applyValidatorBuilderConfig(validatorApi, pubkey, update.builderConfig); - } catch (error) { - logger.error(error); - failures.push(error.message); - } - } - } - if (failures.length > 0) - throw new Error( - `Settings saved; client configuration application failed and will be retried on reload. ${failures.join("; ")}` - ); + // The database validates the entire batch before persisting any settings. + brainDb.updateValidators(updates); + const importedKeys = await importMissingBuilderKeys(validatorApi, signerUrl, updates); + await applyUpdates(validatorApi, updates, currentValidators, importedKeys); } finally { reloadValidatorsCronTask.start(); } }); } + +function prepareUpdates( + requests: CustomValidatorUpdateRequest[], + currentValidators: StakingBrainDb, + requestFrom?: ActionRequestOrigin +): CustomValidatorUpdateRequest[] { + if (!Array.isArray(requests) || requests.length === 0) throw new Error("Select at least one validator to update"); + + const updates: CustomValidatorUpdateRequest[] = []; + const seen = new Set(); + for (const request of requests) { + if (!request || typeof request.pubkey !== "string") throw new Error("Validator public key is required"); + const pubkey = prefix0xPubkey(request.pubkey); + const current = currentValidators[pubkey]; + if (!current) throw new Error(`Validator ${pubkey} is not in the database`); + if (seen.has(pubkey)) throw new Error(`Duplicate validator ${pubkey}`); + if (Object.keys(request).some((field) => !["pubkey", "feeRecipient", "builderConfig"].includes(field))) + throw new Error("Unsupported validator setting"); + + const { feeRecipient, builderConfig } = request; + const update: CustomValidatorUpdateRequest = { pubkey }; + if (feeRecipient !== undefined && isFeeRecipientEditable(current.tag, requestFrom)) + update.feeRecipient = feeRecipient; + if (builderConfig !== undefined) update.builderConfig = builderConfig; + if (update.feeRecipient !== undefined || update.builderConfig !== undefined) { + updates.push(update); + seen.add(pubkey); + } + } + + if (updates.length === 0) throw new Error("The fee recipient can't be updated for these validators"); + return updates; +} + +/** A restart may have cleared keys before the next cron reload. */ +async function importMissingBuilderKeys( + validatorApi: ValidatorApi, + signerUrl: string, + updates: readonly CustomValidatorUpdateRequest[] +): Promise> { + const builderPubkeys = updates.filter(({ builderConfig }) => builderConfig !== undefined).map(({ pubkey }) => pubkey); + if (builderPubkeys.length === 0) return new Set(); + + const remoteKeys = await validatorApi.getRemoteKeys(); + const loaded = new Set(remoteKeys.data.map(({ pubkey }) => pubkey.toLowerCase())); + const missing = builderPubkeys.filter((pubkey) => !loaded.has(pubkey.toLowerCase())); + if (missing.length > 0) + await validatorApi.postRemoteKeys({ remote_keys: missing.map((pubkey) => ({ pubkey, url: signerUrl })) }); + return new Set(missing); +} + +async function applyUpdates( + validatorApi: ValidatorApi, + updates: readonly CustomValidatorUpdateRequest[], + currentValidators: StakingBrainDb, + importedKeys: Set +): Promise { + const failures: string[] = []; + for (const update of updates) { + const { pubkey } = update; + let feeRecipient = update.feeRecipient; + if (feeRecipient === undefined && importedKeys.has(pubkey)) feeRecipient = currentValidators[pubkey].feeRecipient; + + if (feeRecipient !== undefined) { + try { + await validatorApi.setFeeRecipient(feeRecipient, pubkey); + } catch (error) { + logger.error("Error setting validator feeRecipient", error); + failures.push(`Fee recipient application failed for ${pubkey}`); + } + } + if (update.builderConfig !== undefined) { + try { + await applyValidatorBuilderConfig(validatorApi, pubkey, update.builderConfig); + } catch (error) { + logger.error(error); + failures.push(error.message); + } + } + } + + if (failures.length > 0) + throw new Error( + `Settings saved; client configuration application failed and will be retried on reload. ${failures.join("; ")}` + ); +} diff --git a/packages/brain/src/modules/config/importBuilderIds.ts b/packages/brain/src/modules/config/importBuilderIds.ts index 0e6dc818..39f8f8ce 100644 --- a/packages/brain/src/modules/config/importBuilderIds.ts +++ b/packages/brain/src/modules/config/importBuilderIds.ts @@ -35,14 +35,12 @@ export function importBuilderIds({ }; const validators = Object.entries(brainDb.getData()); - const updates = Object.fromEntries( - validators - .filter(([, details]) => details.builderConfig === undefined) - .map(([pubkey]) => [pubkey, { builderConfig }]) - ); - const imported = Object.keys(updates).length; + const updates = validators + .filter(([, details]) => details.builderConfig === undefined) + .map(([pubkey]) => ({ pubkey, builderConfig })); + const imported = updates.length; const skipped = validators.length - imported; - if (imported > 0) brainDb.updateValidators({ validators: updates }); + if (imported > 0) brainDb.updateValidators(updates); // Mark completion only after saving. Retries preserve existing settings and edits. fs.writeFileSync(markerPath, "", { mode: 0o600, flag: "wx" }); diff --git a/packages/brain/src/modules/cron/reloadValidators/index.ts b/packages/brain/src/modules/cron/reloadValidators/index.ts index 37072175..b456b5af 100644 --- a/packages/brain/src/modules/cron/reloadValidators/index.ts +++ b/packages/brain/src/modules/cron/reloadValidators/index.ts @@ -49,9 +49,7 @@ export async function reloadValidators( (pubkey) => data[pubkey].importPending && signerKeys.has(pubkey.toLowerCase()) ); if (confirmedImports.length > 0) - brainDb.updateValidators({ - validators: Object.fromEntries(confirmedImports.map((pubkey) => [pubkey, { importPending: undefined }])) - }); + brainDb.updateValidators(confirmedImports.map((pubkey) => ({ pubkey, importPending: undefined }))); // Preserve pending records, but do not activate keys the signer has not confirmed. const activeData = Object.fromEntries( Object.entries(brainDb.getData()).filter(([pubkey]) => signerKeys.has(pubkey.toLowerCase())) diff --git a/packages/brain/src/modules/cron/trackValidatorsPerformance/setActiveValidatorsLoadedInBrain.ts b/packages/brain/src/modules/cron/trackValidatorsPerformance/setActiveValidatorsLoadedInBrain.ts index 05c70e56..ff296cf3 100644 --- a/packages/brain/src/modules/cron/trackValidatorsPerformance/setActiveValidatorsLoadedInBrain.ts +++ b/packages/brain/src/modules/cron/trackValidatorsPerformance/setActiveValidatorsLoadedInBrain.ts @@ -78,7 +78,7 @@ async function getValidatorIndexesAndSaveInDb({ const { pubkey } = validatorData.validator; const index = validatorData.index; validatorIndexes.push(index); - brainDb.updateValidators({ validators: { [pubkey]: { index: parseInt(index) } } }); + brainDb.updateValidators([{ pubkey, index: parseInt(index) }]); } } diff --git a/packages/brain/src/modules/db/index.ts b/packages/brain/src/modules/db/index.ts index f90bbad4..fbf9fddb 100644 --- a/packages/brain/src/modules/db/index.ts +++ b/packages/brain/src/modules/db/index.ts @@ -1,4 +1,4 @@ -import { StakingBrainDb, StakingBrainDbUpdate } from "./types.js"; +import { StakingBrainDb, ValidatorUpdate } from "./types.js"; import { LowSync } from "lowdb"; import { JSONFileSync } from "lowdb/node"; import fs from "fs"; @@ -115,26 +115,25 @@ export class BrainDataBase extends LowSync { /** * Update supplied validator settings without erasing omitted settings. */ - public updateValidators({ validators }: { validators: StakingBrainDbUpdate }): void { + public updateValidators(validators: readonly ValidatorUpdate[]): void { try { this.validateDb(); this.validateUpdateValidators(validators); const updatedData = { ...this.data }; - if (this.data) - for (const pubkey of Object.keys(validators)) { - if (!this.data[pubkey]) { - // Remove pubkeys that don't exist - logger.warn(`Pubkey ${pubkey} not found in the database`); - delete validators[pubkey]; - } else { - updatedData[pubkey] = { ...this.data[pubkey], ...validators[pubkey] }; - } + for (const { pubkey, ...changes } of validators) { + if (!this.data[pubkey]) { + logger.warn(`Pubkey ${pubkey} not found in the database`); + continue; } + updatedData[pubkey] = { ...this.data[pubkey], ...changes }; + } this.ensureDbMaxSize(updatedData, true); this.data = updatedData; this.write(); } catch (e) { - throw new BrainDbError(`Unable to update pubkeys ${Object.keys(validators).join(", ")}. ${e.message}`); + throw new BrainDbError( + `Unable to update pubkeys ${validators.map(({ pubkey }) => pubkey).join(", ")}. ${e.message}` + ); } } @@ -285,10 +284,11 @@ export class BrainDataBase extends LowSync { this.read(); } - private validateUpdateValidators(validators: StakingBrainDbUpdate): void { + private validateUpdateValidators(validators: readonly ValidatorUpdate[]): void { const errors: string[] = []; - Object.keys(validators).forEach((pubkey) => { - const pubkeyDetails = validators[pubkey]; + const seen = new Set(); + for (const validator of validators) { + const { pubkey } = validator; // create substring of pubkey to be used in error message const pubkeySubstr = shortenPubkey(pubkey); @@ -296,21 +296,19 @@ export class BrainDataBase extends LowSync { // Validate Ethereum address if (!isValidBlsPubkey(pubkey)) errors.push(`\n pubkey ${pubkeySubstr}: bls is invalid`); - if (!pubkeyDetails) { - errors.push(`\n pubkey ${pubkeySubstr}: pubkey details are missing`); - return; - } + if (seen.has(pubkey)) errors.push(`\n pubkey ${pubkeySubstr}: duplicate update`); + seen.add(pubkey); // FeeRecipient - if ("feeRecipient" in pubkeyDetails) { - if (typeof pubkeyDetails.feeRecipient !== "string") + if ("feeRecipient" in validator) { + if (typeof validator.feeRecipient !== "string") errors.push(`\n pubkey ${pubkeySubstr}: feeRecipient address is invalid, must be in string format`); - if (typeof pubkeyDetails.feeRecipient !== "string" || !isValidEcdsaPubkey(pubkeyDetails.feeRecipient)) + if (typeof validator.feeRecipient !== "string" || !isValidEcdsaPubkey(validator.feeRecipient)) errors.push(`\n pubkey ${pubkeySubstr}: fee recipient is invalid`); } - if ("builderConfig" in pubkeyDetails && pubkeyDetails.builderConfig !== null) - validateBuilderConfig(pubkeyDetails.builderConfig); - }); + if ("builderConfig" in validator && validator.builderConfig !== null) + validateBuilderConfig(validator.builderConfig); + } if (errors.length > 0) throw new BrainDbError(errors.join("\n")); } diff --git a/packages/brain/src/modules/db/types.ts b/packages/brain/src/modules/db/types.ts index 4a885288..ba178359 100644 --- a/packages/brain/src/modules/db/types.ts +++ b/packages/brain/src/modules/db/types.ts @@ -12,8 +12,8 @@ export interface StakingBrainDb { [pubkey: string]: PubkeyDetails; } -export interface StakingBrainDbUpdate { - [pubkey: string]: Partial>; +export interface ValidatorUpdate extends Partial> { + pubkey: string; } export interface CustomValidators extends PubkeyDetails { diff --git a/packages/brain/test/unit/modules/ cron/trackValidatorsPerformance/getActiveValidatorsLoadedInBrain.unit.test.ts b/packages/brain/test/unit/modules/ cron/trackValidatorsPerformance/getActiveValidatorsLoadedInBrain.unit.test.ts index 605a7cce..471f5eed 100644 --- a/packages/brain/test/unit/modules/ cron/trackValidatorsPerformance/getActiveValidatorsLoadedInBrain.unit.test.ts +++ b/packages/brain/test/unit/modules/ cron/trackValidatorsPerformance/getActiveValidatorsLoadedInBrain.unit.test.ts @@ -8,7 +8,7 @@ import { ValidatorStatus } from "../../../../../src/modules/apiClients/types.js"; import { BrainDataBase } from "../../../../../src/modules/db/index.js"; -import { StakingBrainDb } from "../../../../../src/modules/db/types.js"; +import { StakingBrainDb, ValidatorUpdate } from "../../../../../src/modules/db/types.js"; import { setActiveValidatorsLoadedInBrain } from "../../../../../src/modules/cron/trackValidatorsPerformance/setActiveValidatorsLoadedInBrain.js"; import { ConsensusClient, ExecutionClient, Network } from "@stakingbrain/common"; @@ -130,8 +130,8 @@ class BrainDataBaseMock extends BrainDataBase { return this.data; } - updateValidators({ validators }: { validators: StakingBrainDb }) { - this.data = { ...this.data, ...validators }; + updateValidators(validators: readonly ValidatorUpdate[]) { + for (const { pubkey, ...changes } of validators) this.data[pubkey] = { ...this.data[pubkey], ...changes }; } } diff --git a/packages/brain/test/unit/modules/builderConfig/builderConfig.unit.test.ts b/packages/brain/test/unit/modules/builderConfig/builderConfig.unit.test.ts index f7c7d20a..371636dd 100644 --- a/packages/brain/test/unit/modules/builderConfig/builderConfig.unit.test.ts +++ b/packages/brain/test/unit/modules/builderConfig/builderConfig.unit.test.ts @@ -83,6 +83,7 @@ describe("Builder configuration management", () => { ) }); api = new RecordingValidator(); + api.remoteKeys = [...keys]; cron = sinon.createStubInstance(CronJob); sinon.stub(logger, "error"); }); @@ -96,7 +97,30 @@ describe("Builder configuration management", () => { validatorApi: ValidatorApi, reloadValidatorsCronTask: CronJob, requests: Parameters[0]["customValidatorUpdateRequest"] - ) => updateValidators({ brainDb, validatorApi, reloadValidatorsCronTask, customValidatorUpdateRequest: requests }); + ) => + updateValidators({ + brainDb, + validatorApi, + signerUrl: "http://signer:9000", + reloadValidatorsCronTask, + customValidatorUpdateRequest: requests + }); + + it("adds missing keys before applying builder settings after a validator restart", async () => { + api.remoteKeys = [keys[1]]; + await update(db, api, cron, [ + { pubkey: keys[0], builderConfig: config }, + { pubkey: keys[1], builderConfig: {} } + ]); + const imports = api.calls.filter(({ endpoint, method }) => endpoint.endsWith("remotekeys") && method === "POST"); + expect(imports).to.have.length(1); + expect(JSON.parse(imports[0].body!)).to.deep.equal({ + remote_keys: [{ pubkey: keys[0], url: "http://signer:9000" }] + }); + const writes = api.calls.filter(({ endpoint }) => endpoint.includes(keys[0])); + expect(writes.map(({ endpoint }) => endpoint.split("/").at(-1))).to.deep.equal(["feerecipient", "builder_config"]); + expect(api.getBuilderConfigStatus(keys[0])?.status).to.equal("applied"); + }); it("serializes sparse replacement and uint64 strings and supports GET/DELETE", async () => { await api.setBuilderConfig(config, keys[0].slice(2)); @@ -123,7 +147,7 @@ describe("Builder configuration management", () => { await update(db, api, cron, [{ pubkey: keys[0].slice(2), builderConfig: config }]); const reopened = new BrainDataBase(path.join(directory, "db.json")); expect(reopened.getData()[keys[0]].builderConfig).to.deep.equal(config); - reopened.updateValidators({ validators: { [keys[0]]: { index: 77 } } }); + reopened.updateValidators([{ pubkey: keys[0], index: 77 }]); await update(reopened, api, cron, [{ pubkey: keys[0], feeRecipient: recipient }]); expect(reopened.getData()[keys[0]]).to.include({ index: 77, feeRecipient: recipient }); expect(reopened.getData()[keys[0]].builderConfig).to.deep.equal(config); @@ -156,7 +180,7 @@ describe("Builder configuration management", () => { }); it("finishes an in-flight reload before applying a newer reset", async () => { - db.updateValidators({ validators: Object.fromEntries(keys.map((key) => [key, { builderConfig: config }])) }); + db.updateValidators(keys.map((pubkey) => ({ pubkey, builderConfig: config }))); const signer = sinon.createStubInstance(Web3SignerApi); signer.upcheck.resolves(); signer.listRemoteKeys.resolves({ @@ -370,7 +394,10 @@ describe("Builder configuration management", () => { }); it("reimports keys after restarts, retries failures/resets and replays sparse overrides on every reload", async () => { - db.updateValidators({ validators: { [keys[0]]: { builderConfig: config }, [keys[1]]: { builderConfig: null } } }); + db.updateValidators([ + { pubkey: keys[0], builderConfig: config }, + { pubkey: keys[1], builderConfig: null } + ]); const signer = sinon.createStubInstance(Web3SignerApi); signer.upcheck.resolves(); signer.listRemoteKeys.resolves({ @@ -411,7 +438,7 @@ describe("Builder configuration management", () => { it("preserves Smooth payout destination and protocol fee-recipient restrictions", async () => { const smooth = MEV_SP_ADDRESS_MAINNET; - db.updateValidators({ validators: { [keys[0]]: { feeRecipient: smooth } } }); + db.updateValidators([{ pubkey: keys[0], feeRecipient: smooth }]); await update(db, api, cron, [{ pubkey: keys[0], builderConfig: { builders: [], builder_boost_factor: "0" } }]); expect(db.getData()[keys[0]].feeRecipient).to.equal(smooth); // Other protocols may also configure builders without gaining fee-recipient editing rights. @@ -496,9 +523,7 @@ describe("Builder configuration management", () => { })) }; const before = fs.readFileSync(path.join(directory, "db.json"), "utf8"); - expect(() => - db.updateValidators({ validators: Object.fromEntries(manyKeys.map((key) => [key, { builderConfig: large }])) }) - ).to.throw("too big"); + expect(() => db.updateValidators(manyKeys.map((pubkey) => ({ pubkey, builderConfig: large })))).to.throw("too big"); expect(fs.readFileSync(path.join(directory, "db.json"), "utf8")).to.equal(before); expect(db.getData()[manyKeys[0]]).not.to.have.property("builderConfig"); }); diff --git a/packages/brain/test/unit/modules/config/importBuilderIds.unit.test.ts b/packages/brain/test/unit/modules/config/importBuilderIds.unit.test.ts index f6ca571c..acbe9b83 100644 --- a/packages/brain/test/unit/modules/config/importBuilderIds.unit.test.ts +++ b/packages/brain/test/unit/modules/config/importBuilderIds.unit.test.ts @@ -49,12 +49,10 @@ describe("One-time MEV-Boost builder settings import", () => { }); it("persists exact settings for unmanaged validators and preserves existing choices and resets", () => { - brainDb.updateValidators({ - validators: { - [keys[1]]: { builderConfig: {} }, - [keys[2]]: { builderConfig: null } - } - }); + brainDb.updateValidators([ + { pubkey: keys[1], builderConfig: {} }, + { pubkey: keys[2], builderConfig: null } + ]); const before = brainDb.getData(); expect(run(brainDb, markerPath)).to.deep.equal({ imported: 1, skipped: 2 }); const persisted = new BrainDataBase(dbPath).getData(); @@ -67,12 +65,10 @@ describe("One-time MEV-Boost builder settings import", () => { it("ignores retained, changed and invalid IDs after a restart and never configures future keys", () => { expect(run(brainDb, markerPath)).to.deep.equal({ imported: 3, skipped: 0 }); - brainDb.updateValidators({ - validators: { - [keys[0]]: { builderConfig: { builders: [] } }, - [keys[1]]: { builderConfig: null } - } - }); + brainDb.updateValidators([ + { pubkey: keys[0], builderConfig: { builders: [] } }, + { pubkey: keys[1], builderConfig: null } + ]); brainDb.addValidators({ validators: { [keys[3]]: { tag: "solo", feeRecipient: recipient, automaticImport: false } } }); @@ -96,9 +92,7 @@ describe("One-time MEV-Boost builder settings import", () => { }); it("completes without rewriting the database when every validator already has settings", () => { - brainDb.updateValidators({ - validators: Object.fromEntries(keys.slice(0, 3).map((pubkey) => [pubkey, { builderConfig: null }])) - }); + brainDb.updateValidators(keys.slice(0, 3).map((pubkey) => ({ pubkey, builderConfig: null }))); const update = sinon.spy(brainDb, "updateValidators"); expect(run(brainDb, markerPath)).to.deep.equal({ imported: 0, skipped: 3 }); expect(update.called).to.equal(false); @@ -146,7 +140,7 @@ describe("One-time MEV-Boost builder settings import", () => { expect(() => run(brainDb, markerPath)).to.throw(); expect(fs.existsSync(markerPath)).to.equal(false); expect(brainDb.getData()[keys[0]].builderConfig).to.deep.equal(config); - brainDb.updateValidators({ validators: { [keys[0]]: { builderConfig: { builders: [] } } } }); + brainDb.updateValidators([{ pubkey: keys[0], builderConfig: { builders: [] } }]); fs.mkdirSync(path.dirname(markerPath)); expect(run(new BrainDataBase(dbPath), markerPath)).to.deep.equal({ imported: 0, skipped: 3 }); expect(brainDb.getData()[keys[0]].builderConfig).to.deep.equal({ builders: [] }); diff --git a/packages/brain/test/unit/modules/db/index.unit.test.ts b/packages/brain/test/unit/modules/db/index.unit.test.ts index 3980d031..09df5fcf 100644 --- a/packages/brain/test/unit/modules/db/index.unit.test.ts +++ b/packages/brain/test/unit/modules/db/index.unit.test.ts @@ -7,7 +7,7 @@ import { BrainDataBase } from "../../../../src/modules/db/index.js"; import { Web3SignerApi, ValidatorApi } from "../../../../src/modules/apiClients/index.js"; import logger from "../../../../src/modules/logger/index.js"; -describe("Database initialization", () => { +describe("Validator database", () => { let directory: string; let filename: string; beforeEach(() => { @@ -52,6 +52,62 @@ describe("Database initialization", () => { expect(validator.getBuilderConfig.called).to.equal(false); }); + it("applies partial update records without persisting pubkeys or erasing omitted settings", () => { + const pubkey = "0x" + "ab".repeat(48); + const original = { + tag: "solo" as const, + feeRecipient: "0x" + "cd".repeat(20), + automaticImport: false, + importPending: true, + index: 42, + builderConfig: { min_bid: "100" } + }; + const db = new BrainDataBase(filename); + db.addValidators({ validators: { [pubkey]: original } }); + + db.updateValidators([{ pubkey, importPending: undefined, builderConfig: null }]); + const saved = new BrainDataBase(filename).getData()[pubkey]; + expect(saved).to.deep.equal({ + tag: original.tag, + feeRecipient: original.feeRecipient, + automaticImport: original.automaticImport, + index: original.index, + builderConfig: null + }); + + db.updateValidators([{ pubkey, builderConfig: { builders: [] } }]); + expect(db.getData()[pubkey].builderConfig).to.deep.equal({ builders: [] }); + expect(fs.statSync(filename).mode & 0o777).to.equal(0o600); + }); + + it("rejects invalid or duplicate update records before writing any part of the batch", () => { + const pubkeys = ["ab", "cd"].map((byte) => "0x" + byte.repeat(48)); + const feeRecipient = "0x" + "11".repeat(20); + const db = new BrainDataBase(filename); + db.addValidators({ + validators: Object.fromEntries( + pubkeys.map((pubkey) => [pubkey, { tag: "solo", feeRecipient, automaticImport: false }]) + ) + }); + const before = fs.readFileSync(filename, "utf8"); + + expect(() => + db.updateValidators([ + { pubkey: pubkeys[0], builderConfig: { builders: [] } }, + { pubkey: pubkeys[1], feeRecipient: "invalid" } + ]) + ).to.throw("fee recipient is invalid"); + expect(fs.readFileSync(filename, "utf8")).to.equal(before); + + expect(() => + db.updateValidators([ + { pubkey: pubkeys[0], index: 1 }, + { pubkey: pubkeys[0], index: 2 } + ]) + ).to.throw("duplicate update"); + expect(fs.readFileSync(filename, "utf8")).to.equal(before); + }); + it("refuses corrupted JSON without erasing validator metadata", async () => { fs.writeFileSync(filename, "{invalid JSON"); const db = new BrainDataBase(filename); diff --git a/packages/brain/test/unit/modules/keyManagement.unit.test.ts b/packages/brain/test/unit/modules/keyManagement.unit.test.ts index f7f62104..356c41f7 100644 --- a/packages/brain/test/unit/modules/keyManagement.unit.test.ts +++ b/packages/brain/test/unit/modules/keyManagement.unit.test.ts @@ -242,6 +242,7 @@ describe("Key management partial failures", () => { const result = await updateValidators({ brainDb, validatorApi, + signerUrl: "http://signer:9000", reloadValidatorsCronTask, customValidatorUpdateRequest: keys.map((pubkey) => ({ pubkey, feeRecipient: recipient })) }).catch((error: Error) => error);