-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathindex.ts
More file actions
375 lines (339 loc) · 13.1 KB
/
index.ts
File metadata and controls
375 lines (339 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
import { StakingBrainDb, StakingBrainDbUpdate, PubkeyDetails } from "./types.js";
import { LowSync } from "lowdb";
import { JSONFileSync } from "lowdb/node";
import fs from "fs";
import logger from "../logger/index.js";
import { Web3SignerApi, ValidatorApi } from "../apiClients/index.js";
import { params } from "../../params.js";
import { isValidTag } from "./utils.js";
import { shortenPubkey, isValidBlsPubkey, isValidEcdsaPubkey } from "@stakingbrain/common";
import { isEmpty } from "lodash-es";
import { BrainDbError } from "./error.js";
// TODO:
// The db must have a initial check and maybe should be added on every function to check whenever it is corrupted or not. It should be validated with a JSON schema
// Implement backup system
/**
* BrainDataBase is a wrapper around lowdb to manage the database.
* Properties parent object:
* - data: The database
* - adapter: The adapter
* Methods parent object:
* - read: Read the database
* - write: Write the database
* Caveats:
* - The lowdb.write() method already takes into account if the pubkey exists or not
*/
export class BrainDataBase extends LowSync<StakingBrainDb> {
private dbName: string;
constructor(dbName: string) {
// JSONFileSync adapters will set db.data to empty object if file dbName doesn't exist.
super(new JSONFileSync<StakingBrainDb>(dbName), {});
this.dbName = dbName;
}
/**
* Returns the database content.
* - If the database is empty, it will return an empty object
* - If the database is corrupted, it will erase it and create a new empty database with the correct permissions
*
* @returns an object in format StakingBrainDb (it could be an empty object {})
*/
public getData(): StakingBrainDb {
this.validateDb();
return this.data as StakingBrainDb;
}
/**
* Initializes the database: IMPORTANT! this method must not throw an error since it will be called from index.ts
* - If the database file doesn't exist, it will attempt to perform a migration
* - If the migration fails, it will create a new empty database
* - If the database file exists, it will validate it
*/
public async initialize(signerApi: Web3SignerApi, validatorApi: ValidatorApi): Promise<void> {
try {
// Important! .read() method must be called before accessing brainDb.data otherwise it will be empty object
this.read();
// If db.json doesn't exist, db.data will be an empty object
if (isEmpty(this.data)) await this.databaseMigration(signerApi, validatorApi);
else this.setOwnerWriteRead();
} catch (e) {
logger.error(`unable to initialize the db ${this.dbName}`, e);
this.validateDb();
}
}
/**
* Closes the database
*/
public close(): void {
this.setOwnerRead();
}
/**
* Adds 1 or more public keys and their details to the database
*/
public addValidators({ validators }: { validators: StakingBrainDb }): void {
try {
this.validateDb();
// Remove pubkeys that already exist
if (this.data)
for (const pubkey of Object.keys(validators))
if (this.data[pubkey]) {
logger.warn(`Pubkey ${pubkey} already in the database`);
delete validators[pubkey];
}
this.ensureDbMaxSize(validators);
this.validateAddValidators(validators);
this.data = { ...this.data, ...validators };
this.write();
} catch (e) {
throw new BrainDbError(`Unable to add pubkeys ${Object.keys(validators).join(", ")}. ${e.message}`);
}
}
/**
* Updates 1 or more validators in db. The fields available to update are feeRecipient, index, and status
*/
public updateValidators({ validators }: { validators: StakingBrainDbUpdate }): void {
try {
this.validateDb();
this.validateUpdateValidators(validators);
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 {
this.data[pubkey].feeRecipient = validators[pubkey].feeRecipient;
// Optional fields. Only update if provided so we dont overwrite existing data with undefined
// Index cant change once defined by ethereum and status should change only a few times in a validator lifetime
if (validators[pubkey].index !== undefined) {
this.data[pubkey].index = validators[pubkey].index;
}
if (validators[pubkey].status !== undefined) {
this.data[pubkey].status = validators[pubkey].status;
}
}
}
this.write();
} catch (e) {
throw new BrainDbError(`Unable to update pubkeys ${Object.keys(validators).join(", ")}. ${e.message}`);
}
}
/**
* Deletes 1 or more public keys and its details from the database
* @param pubkeys - The public keys to delete
*/
public deleteValidators(pubkeys: string[]): void {
try {
this.validateDb();
if (!this.data) return;
for (const pubkey of pubkeys) {
if (!this.data[pubkey]) {
logger.warn(`Pubkey ${pubkey} not found in the database`);
} else delete this.data[pubkey];
this.write();
}
} catch (e) {
throw new BrainDbError(`Unable to delete pubkeys ${Object.keys(pubkeys).join(", ")}. ${e.message}`);
}
}
// PRIVATE METHODS //
/**
* Cleans the database:
* - Writes an empty object to the database
* - On error deletes the database file and creates a new one
*/
private pruneDatabase(): void {
try {
this.data = {};
this.write();
} catch (e) {
logger.error(`Unable to prune database. Creating a new one...`, e);
if (fs.existsSync(this.dbName)) fs.unlinkSync(this.dbName);
this.createJsonFileAndPermissions();
}
}
/**
* Set write permissions to the database file
*/
private setOwnerWriteRead(): void {
fs.chmodSync(this.dbName, 0o600);
}
/**
* Set read permissions to the database file
*/
private setOwnerRead(): void {
fs.chmodSync(this.dbName, 0o400);
}
/**
* Validates the database it is in the correct format:
* - Creates JSON file if it doesn't exist
* - Deletes the database if it is corrupted and creates a new one
*/
private validateDb(): void {
try {
this.read();
if (isEmpty(this.data)) {
logger.warn(`Database file ${this.dbName} not found. Creating it...`);
this.createJsonFileAndPermissions();
}
} catch (e) {
logger.error(`The database is corrupted. Cleaning database`, e);
this.pruneDatabase();
this.read();
}
}
/**
* Ensures the DB never exceeds the max size.
* Average db size for:
* - 2000 pubkeys: 476KB
* - 1000 pubkeys: 240KB
* - 500 pubkeys: 120KB
* - 100 pubkeys: 24KB
* - 50 pubkeys: 12KB
* - 10 pubkeys: 4KB
* - 1 pubkey: 4KB
* Average pubkey size to be added: 213 bytes
*/
private ensureDbMaxSize(validators: StakingBrainDb): void {
const MAX_DB_SIZE = 6 * 1024 * 1024;
const dbSize = fs.statSync(this.dbName).size;
const pubkeysSize = Buffer.byteLength(JSON.stringify(validators));
if (dbSize + pubkeysSize > MAX_DB_SIZE)
throw new BrainDbError(
`The database is too big. Max size is ${MAX_DB_SIZE} bytes. Current size is ${dbSize} bytes. Data to be added is ${pubkeysSize} bytes. `
);
}
/**
* Performs the database migration for the first run:
* - Fetches the public keys from the signer API
* - Fetches the fee recipient from the validator API (if not available uses the default fee recipient, no error is thrown)
* - Adds the public keys to the database
*
* @throws Error if signer API is not available
*
* @param signerApi - The signer API
* @param validatorApi - The validator API
*/
private async databaseMigration(signerApi: Web3SignerApi, validatorApi: ValidatorApi): Promise<void> {
let retries = 0;
while (retries < 10) {
try {
logger.info(`Database file ${this.dbName} not found. Attemping to perform migration...`);
// Create json file
this.createJsonFileAndPermissions();
// Fetch public keys from signer API
const pubkeys = (await signerApi.listRemoteKeys()).data.map((keystore) => keystore.validating_pubkey);
if (pubkeys.length === 0) {
logger.info(`No public keys found in the signer API`);
return;
} else logger.info(`Found ${pubkeys.length} public keys to migrate`);
let feeRecipient = "";
await validatorApi
.getFeeRecipient(pubkeys[0])
.then((response) => {
feeRecipient = response.data.ethaddress;
})
.catch((e) => {
logger.error(`Unable to fetch fee recipient for ${pubkeys[0]}. Setting default ${params.burnAddress}}`, e);
// TODO: consider setting MEV fee recipient.
feeRecipient = params.burnAddress;
});
logger.info(`The fee recipient to be used in the migration is ${feeRecipient}`);
this.addValidators({
validators: pubkeys.reduce(
(acc, pubkey) => {
acc[pubkey] = {
tag: params.defaultTag,
feeRecipient,
automaticImport: false
};
return acc;
},
{} as { [pubkey: string]: PubkeyDetails }
)
});
logger.info(`Database migration completed`);
return;
} catch (e) {
if (retries < 30) {
retries++;
logger.error(`Unable to perform database migration. Retrying in 6 seconds...`, e);
await new Promise((resolve) => {
logger.info(`Retrying database migration for ${(retries + 1).toString()} time...`);
setTimeout(resolve, 6 * 1000);
});
} else {
throw new BrainDbError(`Unable to perform database migration. ${e.message}`);
}
}
}
}
/**
* Creates a new database file if does not exist and sets the correct permissions
*/
private createJsonFileAndPermissions(): void {
fs.writeFileSync(this.dbName, "{}");
this.setOwnerWriteRead();
this.read();
}
private validateUpdateValidators(validators: StakingBrainDbUpdate): void {
const errors: string[] = [];
Object.keys(validators).forEach((pubkey) => {
const pubkeyDetails = validators[pubkey];
// create substring of pubkey to be used in error message
const pubkeySubstr = shortenPubkey(pubkey);
// 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;
}
// FeeRecipient
if (!pubkeyDetails.feeRecipient) {
errors.push(`\n pubkey ${pubkeySubstr}: feeRecipient address is missing`);
} else {
if (typeof pubkeyDetails.feeRecipient !== "string")
errors.push(`\n pubkey ${pubkeySubstr}: feeRecipient address is invalid, must be in string format`);
if (!isValidEcdsaPubkey(pubkeyDetails.feeRecipient))
errors.push(`\n pubkey ${pubkeySubstr}: fee recipient is invalid`);
}
});
}
private validateAddValidators(validators: StakingBrainDb): void {
const errors: string[] = [];
Object.keys(validators).forEach((pubkey) => {
const pubkeyDetails = validators[pubkey];
// create substring of pubkey to be used in error message
const pubkeySubstr = shortenPubkey(pubkey);
// 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;
}
// Tag
if (!pubkeyDetails.tag) {
errors.push(`\n pubkey ${pubkeySubstr}: tag is missing`);
} else {
if (typeof pubkeyDetails.tag !== "string")
errors.push(`\n pubkey ${pubkeySubstr}: tag is invalid, must be in string format`);
if (!isValidTag(pubkeyDetails.tag)) errors.push(`\n pubkey ${pubkeySubstr}: tag is invalid`);
}
// FeeRecipient
if (!pubkeyDetails.feeRecipient) {
errors.push(`\n pubkey ${pubkeySubstr}: feeRecipient address is missing`);
} else {
if (typeof pubkeyDetails.feeRecipient !== "string")
errors.push(`\n pubkey ${pubkeySubstr}: feeRecipient address is invalid, must be in string format`);
if (!isValidEcdsaPubkey(pubkeyDetails.feeRecipient))
errors.push(`\n pubkey ${pubkeySubstr}: fee recipient is invalid`);
}
// AutomaticImport
if (typeof pubkeyDetails.automaticImport === "undefined") {
errors.push(`\n pubkey ${pubkeySubstr}: automaticImport is missing`);
} else {
if (typeof validators[pubkey].automaticImport !== "boolean")
errors.push(`\n pubkey ${pubkeySubstr}: automaticImport is invalid, must be in boolean format`);
}
});
if (errors.length > 0) throw new BrainDbError(errors.join("\n"));
}
}