Skip to content

Commit 886d078

Browse files
authored
fix(api): harden is_listed filtering and cleanup (#141)
1 parent 9b31922 commit 886d078

File tree

6 files changed

+55
-135
lines changed

6 files changed

+55
-135
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,24 @@ Testnet:
229229
pnpm db:apply:cron-runs:testnet
230230
```
231231

232+
Required schema:
233+
234+
- `validators.is_listed` must exist in all remote D1 databases.
235+
236+
If the column is missing, apply it manually:
237+
238+
Mainnet:
239+
240+
```bash
241+
pnpm db:apply:is-listed:mainnet
242+
```
243+
244+
Testnet:
245+
246+
```bash
247+
pnpm db:apply:is-listed:testnet
248+
```
249+
232250
**Environments** (configured in `wrangler.json`):
233251

234252
| Environment | Dashboard URL | Trigger |

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
"db:delete": "drizzle-kit drop",
2828
"db:apply:cron-runs:mainnet": "wrangler d1 execute validators-api-mainnet --remote --yes --command \"CREATE TABLE IF NOT EXISTS cron_runs (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, cron text NOT NULL, network text NOT NULL, git_branch text, started_at text NOT NULL, finished_at text, status text NOT NULL, error_message text, meta text);\" && wrangler d1 execute validators-api-mainnet --remote --yes --command \"CREATE INDEX IF NOT EXISTS idx_cron_runs_started_at ON cron_runs (started_at);\"",
2929
"db:apply:cron-runs:testnet": "wrangler d1 execute validators-api-testnet --remote --yes --command \"CREATE TABLE IF NOT EXISTS cron_runs (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, cron text NOT NULL, network text NOT NULL, git_branch text, started_at text NOT NULL, finished_at text, status text NOT NULL, error_message text, meta text);\" && wrangler d1 execute validators-api-testnet --remote --yes --command \"CREATE INDEX IF NOT EXISTS idx_cron_runs_started_at ON cron_runs (started_at);\"",
30+
"db:apply:is-listed:mainnet": "wrangler d1 execute validators-api-mainnet --remote --yes --command \"ALTER TABLE validators ADD COLUMN is_listed integer;\"",
31+
"db:apply:is-listed:testnet": "wrangler d1 execute validators-api-testnet --remote --yes --command \"ALTER TABLE validators ADD COLUMN is_listed integer;\"",
3032
"release": "bumpp -r && nr -r publish",
3133
"validate:json-files": "tsx scripts/validate-json-files.ts",
3234
"validators:bundle:generate": "tsx scripts/generate-validators-bundle.ts"

server/utils/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export interface SnapshotEpochValidators {
2525
type Nullable<T> = {
2626
[K in keyof T]: T[K] | null
2727
}
28-
export type FetchedValidator = Omit<Validator, 'logo' | 'contact'> & Pick<Activity, 'balance' | 'stakers'> & {
28+
export type FetchedValidator = Omit<Validator, 'logo' | 'contact' | 'isListed'> & Pick<Activity, 'balance' | 'stakers'> & {
2929
logo?: string
3030
score: Nullable<Pick<Score, 'total' | 'availability' | 'reliability' | 'dominance' | 'epochNumber'>>
3131
dominanceRatio: number | null

server/utils/validator-listing.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
const UNKNOWN_VALIDATOR_NAME = 'Unknown validator'
2-
31
interface ValidatorListState {
42
isListed: boolean | null
5-
name: string
63
}
74

85
interface ValidatorAddress {
@@ -13,10 +10,8 @@ interface StoredValidatorAddressState extends ValidatorAddress {
1310
isListed: boolean | null
1411
}
1512

16-
export function isKnownValidatorProfile({ isListed, name }: ValidatorListState) {
17-
if (typeof isListed === 'boolean')
18-
return isListed
19-
return name.toLowerCase() !== UNKNOWN_VALIDATOR_NAME.toLowerCase()
13+
export function isKnownValidatorProfile({ isListed }: ValidatorListState) {
14+
return isListed === true
2015
}
2116

2217
export function getUnlistedAddresses(storedAddresses: string[], bundledAddresses: Set<string>) {

server/utils/validator-public.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export function stripInternalValidatorFields<T extends { isListed?: boolean | null }>(validator: T) {
2+
const { isListed: _isListed, ...publicValidator } = validator
3+
return publicValidator
4+
}

server/utils/validators.ts

Lines changed: 28 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,12 @@ import { fetchSnapshotEpoch } from '~~/packages/nimiq-validator-trustscore/src/f
1010
import { tables, useDrizzle } from './drizzle'
1111
import { handleValidatorLogo } from './logo'
1212
import { defaultValidatorJSON } from './schemas'
13-
import { PayoutType } from './types'
1413
import { getUnlistedActiveValidatorAddresses, isKnownValidatorProfile } from './validator-listing'
14+
import { stripInternalValidatorFields } from './validator-public'
1515

1616
export const getStoredValidatorsId = () => useDrizzle().select({ id: tables.validators.id }).from(tables.validators).execute().then(r => r.map(v => v.id))
1717
export const getStoredValidatorsAddress = () => useDrizzle().select({ address: tables.validators.address }).from(tables.validators).execute().then(r => r.map(v => v.address))
18-
19-
function isMissingIsListedColumnError(error: unknown) {
20-
const message = String(error)
21-
return message.includes('is_listed')
22-
&& (message.includes('no such column') || message.includes('has no column named'))
23-
}
24-
25-
let hasWarnedMissingIsListedColumn = false
26-
27-
const validatorFieldsWithoutListState = {
18+
const validatorFieldsWithListState = {
2819
id: tables.validators.id,
2920
name: tables.validators.name,
3021
address: tables.validators.address,
@@ -38,55 +29,21 @@ const validatorFieldsWithoutListState = {
3829
accentColor: tables.validators.accentColor,
3930
website: tables.validators.website,
4031
contact: tables.validators.contact,
41-
}
42-
const validatorFieldsWithListState = {
43-
...validatorFieldsWithoutListState,
4432
isListed: tables.validators.isListed,
4533
}
4634

47-
async function selectValidatorsWithOptionalListState(filters: SQLWrapper[] = []) {
48-
try {
49-
const query = useDrizzle().select(validatorFieldsWithListState).from(tables.validators)
50-
return filters.length > 0
51-
? await query.where(and(...filters)).execute()
52-
: await query.execute()
53-
}
54-
catch (error) {
55-
if (!isMissingIsListedColumnError(error))
56-
throw error
57-
58-
if (!hasWarnedMissingIsListedColumn) {
59-
hasWarnedMissingIsListedColumn = true
60-
consola.warn('`validators.is_listed` column is missing, reading validators without list state until migration is applied')
61-
}
62-
63-
const query = useDrizzle().select(validatorFieldsWithoutListState).from(tables.validators)
64-
const rows = filters.length > 0
65-
? await query.where(and(...filters)).execute()
66-
: await query.execute()
67-
return rows.map(row => ({ ...row, isListed: null as boolean | null }))
68-
}
35+
async function selectValidatorsWithListState(filters: SQLWrapper[] = []) {
36+
const query = useDrizzle().select(validatorFieldsWithListState).from(tables.validators)
37+
return filters.length > 0
38+
? await query.where(and(...filters)).execute()
39+
: await query.execute()
6940
}
7041

7142
export async function getStoredValidatorsListState() {
72-
try {
73-
return await useDrizzle()
74-
.select({ address: tables.validators.address, isListed: tables.validators.isListed })
75-
.from(tables.validators)
76-
.execute()
77-
}
78-
catch (error) {
79-
if (!isMissingIsListedColumnError(error))
80-
throw error
81-
82-
if (!hasWarnedMissingIsListedColumn) {
83-
hasWarnedMissingIsListedColumn = true
84-
consola.warn('`validators.is_listed` column is missing, using backwards-compatible fallback until migration is applied')
85-
}
86-
87-
const addresses = await getStoredValidatorsAddress()
88-
return addresses.map(address => ({ address, isListed: null }))
89-
}
43+
return useDrizzle()
44+
.select({ address: tables.validators.address, isListed: tables.validators.isListed })
45+
.from(tables.validators)
46+
.execute()
9047
}
9148

9249
const validators = new Map<string, number>()
@@ -140,57 +97,27 @@ export async function storeValidator(address: string, rest: ValidatorJSON = defa
14097
consola.info(`${upsert ? 'Updating' : 'Storing'} validator ${address}`)
14198

14299
const brandingParameters = await handleValidatorLogo(address, rest)
143-
const valuesWithoutListState = { ...rest, ...brandingParameters }
144-
const valuesWithListState = { ...valuesWithoutListState, isListed }
100+
const values = { ...rest, ...brandingParameters, isListed }
145101

146102
try {
147103
if (validatorId) {
148104
await useDrizzle()
149105
.update(tables.validators)
150-
.set(valuesWithListState)
106+
.set(values)
151107
.where(eq(tables.validators.id, validatorId))
152108
.execute()
153109
}
154110
else {
155111
validatorId = await useDrizzle()
156112
.insert(tables.validators)
157-
.values({ ...valuesWithListState, address })
113+
.values({ ...values, address })
158114
.returning()
159115
.get()
160116
.then(r => r.id)
161117
}
162118
}
163-
catch (e) {
164-
if (!isMissingIsListedColumnError(e)) {
165-
consola.error(`There was an error while writing ${address} into the database`, e)
166-
}
167-
else {
168-
if (!hasWarnedMissingIsListedColumn) {
169-
hasWarnedMissingIsListedColumn = true
170-
consola.warn('`validators.is_listed` column is missing, storing validators without list state until migration is applied')
171-
}
172-
173-
try {
174-
if (validatorId) {
175-
await useDrizzle()
176-
.update(tables.validators)
177-
.set(valuesWithoutListState)
178-
.where(eq(tables.validators.id, validatorId))
179-
.execute()
180-
}
181-
else {
182-
validatorId = await useDrizzle()
183-
.insert(tables.validators)
184-
.values({ ...valuesWithoutListState, address })
185-
.returning()
186-
.get()
187-
.then(r => r.id)
188-
}
189-
}
190-
catch (fallbackError) {
191-
consola.error(`There was an error while writing ${address} into the database`, fallbackError)
192-
}
193-
}
119+
catch (error) {
120+
consola.error(`There was an error while writing ${address} into the database`, error)
194121
}
195122

196123
validators.set(address, validatorId!)
@@ -199,39 +126,11 @@ export async function storeValidator(address: string, rest: ValidatorJSON = defa
199126

200127
export async function markValidatorsAsUnlisted(addresses: string[]) {
201128
await Promise.all(addresses.map(async (address) => {
202-
const brandingParameters = await handleValidatorLogo(address, defaultValidatorJSON)
203-
const valuesWithoutListState = {
204-
name: 'Unknown validator',
205-
description: null,
206-
fee: null,
207-
payoutType: PayoutType.None,
208-
payoutSchedule: '',
209-
isMaintainedByNimiq: false,
210-
website: null,
211-
contact: null,
212-
...brandingParameters,
213-
}
214-
215-
try {
216-
await useDrizzle()
217-
.update(tables.validators)
218-
.set({
219-
...valuesWithoutListState,
220-
isListed: false,
221-
})
222-
.where(eq(tables.validators.address, address))
223-
.execute()
224-
}
225-
catch (error) {
226-
if (!isMissingIsListedColumnError(error))
227-
throw error
228-
229-
await useDrizzle()
230-
.update(tables.validators)
231-
.set(valuesWithoutListState)
232-
.where(eq(tables.validators.address, address))
233-
.execute()
234-
}
129+
await useDrizzle()
130+
.update(tables.validators)
131+
.set({ isListed: false })
132+
.where(eq(tables.validators.address, address))
133+
.execute()
235134
}))
236135
}
237136

@@ -251,7 +150,7 @@ export async function fetchValidators(_event: H3Event, params: FetchValidatorsOp
251150
filters.push(eq(tables.validators.payoutType, payoutType))
252151

253152
try {
254-
const dbValidators = await selectValidatorsWithOptionalListState(filters)
153+
const dbValidators = await selectValidatorsWithListState(filters)
255154

256155
const visibleValidators = onlyKnown ? dbValidators.filter(isKnownValidatorProfile) : dbValidators
257156
const validatorIds = visibleValidators.map(v => v.id)
@@ -320,7 +219,8 @@ export async function fetchValidators(_event: H3Event, params: FetchValidatorsOp
320219
const activityByValidatorId = new Map(activityRows.map(row => [row.validatorId, row]))
321220

322221
const validators = visibleValidators.map((validator) => {
323-
const { logo, contact, hasDefaultLogo, ...rest } = validator
222+
const { logo, hasDefaultLogo } = validator
223+
const rest = stripInternalValidatorFields(validator)
324224
const scoreRow = scoresByValidatorId.get(validator.id)
325225
const activityRow = activityByValidatorId.get(validator.id)
326226

@@ -378,13 +278,13 @@ export const cachedFetchValidators = defineCachedFunction((_event: H3Event, para
378278
})
379279

380280
export interface FetchValidatorOptions { address: string, range: Range }
381-
export type FetchedValidatorDetails = Validator & { activity: Activity[], scores: Score[], score?: Score }
281+
export type FetchedValidatorDetails = Omit<Validator, 'isListed'> & { activity: Activity[], scores: Score[], score?: Score }
382282

383283
export async function fetchValidator(_event: H3Event, params: FetchValidatorOptions): Result<FetchedValidatorDetails> {
384284
const { address, range: { fromEpoch, toEpoch } } = params
385285

386286
try {
387-
const validator = (await selectValidatorsWithOptionalListState([eq(tables.validators.address, address)])).at(0)
287+
const validator = (await selectValidatorsWithListState([eq(tables.validators.address, address)])).at(0)
388288

389289
if (!validator)
390290
return [false, `Validator with address ${address} not found`, undefined]
@@ -421,7 +321,8 @@ export async function fetchValidator(_event: H3Event, params: FetchValidatorOpti
421321
))
422322
.execute()
423323

424-
return [true, undefined, { ...validator, scores, activity, score }]
324+
const publicValidator = stripInternalValidatorFields(validator)
325+
return [true, undefined, { ...publicValidator, scores, activity, score }]
425326
}
426327
catch (error) {
427328
consola.error(`Error fetching validator ${address}: ${error}`)

0 commit comments

Comments
 (0)