Skip to content

Commit 9a9d811

Browse files
committed
add custom renderer support and renderer test sends
1 parent 5407b30 commit 9a9d811

22 files changed

Lines changed: 1551 additions & 455 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"dotenv": "^16.1.4",
3939
"esbuild": "^0.19.5",
4040
"ethers": "^6.5.1",
41-
"graphql": "^16.11.0",
41+
"graphql": "^15.10.1",
4242
"graphql-request": "^6",
4343
"js-yaml": "^4.1.0",
4444
"lodash": "^4.17.21",

pnpm-lock.yaml

Lines changed: 222 additions & 270 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

squid.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ deploy:
3838
- LOKI_URL
3939
- LOKI_USER
4040
- LOKI_API_KEY
41+
- SQUID_TEST_RENDER_TOKEN
4142
processor:
4243
- name: mainnet-processor
4344
cmd: ['sqd', 'process:prod']

src/alert-config/config-loader.ts

Lines changed: 80 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
1-
import fs from 'node:fs'
2-
import path from 'node:path'
31
// @ts-expect-error pg has no type declarations in this project
42
import pg from 'pg'
53

64
import type { Severity, Topic } from '@notify/const'
75

8-
import type { AlertRule, FilterExpression } from './types'
6+
import type { AlertRule, FilterExpression, RendererRecord } from './types'
97

10-
const { Client, Pool } = pg
8+
const { Pool } = pg
119

1210
let pool: InstanceType<typeof Pool> | null = null
1311
let cachedRules: AlertRule[] = []
12+
let cachedRenderers: RendererRecord[] = []
1413
let lastRuleRefresh = 0
1514

1615
const REFRESH_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes
@@ -24,77 +23,6 @@ const getPool = (): InstanceType<typeof Pool> => {
2423
return pool
2524
}
2625

27-
function readSqlFile(filename: string): string {
28-
const localPath = path.resolve(__dirname, filename)
29-
if (fs.existsSync(localPath)) return fs.readFileSync(localPath, 'utf-8')
30-
return fs.readFileSync(path.resolve(__dirname, '..', '..', 'src', 'alert-config', filename), 'utf-8')
31-
}
32-
33-
function splitStatements(sql: string): string[] {
34-
return sql
35-
.split(';\n')
36-
.map((s) =>
37-
s
38-
.split('\n')
39-
.filter((line) => !line.trimStart().startsWith('--'))
40-
.join('\n')
41-
.trim(),
42-
)
43-
.filter((s) => s.length > 0)
44-
}
45-
46-
/**
47-
* Initialize the alert config database.
48-
* Creates the database if needed, runs migration, and seeds data.
49-
* Call once at startup before using any other alert-config functions.
50-
* Only runs when ALERT_CONFIG_DB_MIGRATION=true.
51-
*/
52-
export const initAlertConfigDb = async (): Promise<void> => {
53-
if (process.env.ALERT_CONFIG_DB_MIGRATION !== 'true') return
54-
55-
const url = process.env.ALERT_CONFIG_DB_URL
56-
if (!url) return
57-
58-
// Parse the DB name from the URL to create it if needed
59-
const parsed = new URL(url)
60-
const dbName = parsed.pathname.slice(1) // remove leading /
61-
const adminUrl = `${parsed.protocol}//${parsed.username}:${parsed.password}@${parsed.host}/postgres`
62-
63-
// Create database if it doesn't exist
64-
const adminClient = new Client({ connectionString: adminUrl })
65-
try {
66-
await adminClient.connect()
67-
const { rows } = await adminClient.query('SELECT 1 FROM pg_database WHERE datname = $1', [dbName])
68-
if (rows.length === 0) {
69-
await adminClient.query(`CREATE DATABASE "${dbName}"`)
70-
console.log(`Alert config: created database "${dbName}"`)
71-
}
72-
} finally {
73-
await adminClient.end()
74-
}
75-
76-
// Check if schema already exists
77-
const p = getPool()
78-
const { rows } = await p.query("SELECT 1 FROM information_schema.tables WHERE table_name = 'alert_rule'")
79-
if (rows.length > 0) return // Already migrated
80-
81-
// Run migration
82-
await p.query(readSqlFile('migration.sql'))
83-
console.log('Alert config: migration complete')
84-
85-
// Seed rules
86-
for (const stmt of splitStatements(readSqlFile('seed-rules.sql'))) {
87-
await p.query(stmt)
88-
}
89-
console.log('Alert config: seed rules loaded')
90-
91-
// Seed ABIs
92-
for (const stmt of splitStatements(readSqlFile('seed-abis.sql'))) {
93-
await p.query(stmt)
94-
}
95-
console.log('Alert config: ABI seed loaded')
96-
}
97-
9826
const loadRules = async (): Promise<AlertRule[]> => {
9927
const { rows } = await getPool().query('SELECT * FROM alert_rule WHERE enabled = true')
10028
return rows.map(
@@ -125,6 +53,26 @@ const loadRules = async (): Promise<AlertRule[]> => {
12553
)
12654
}
12755

56+
const loadRenderers = async (): Promise<RendererRecord[]> => {
57+
const tableCheck = await getPool().query("SELECT 1 FROM information_schema.tables WHERE table_name = 'renderer'")
58+
if (tableCheck.rows.length === 0) return []
59+
const { rows } = await getPool().query('SELECT * FROM renderer ORDER BY name ASC')
60+
return rows.map(
61+
(row: any): RendererRecord => ({
62+
id: row.id,
63+
name: row.name,
64+
mode: row.mode,
65+
chainId: row.chain_id,
66+
matchType: row.match_type,
67+
abiNames: row.abi_names,
68+
contractAddresses: row.contract_addresses,
69+
topic0: row.topic0,
70+
sighash: row.sighash,
71+
configJson: row.config_json ?? {},
72+
}),
73+
)
74+
}
75+
12876
/**
12977
* Get all enabled alert rules. Refreshes every 5 minutes.
13078
*/
@@ -134,6 +82,20 @@ export const getAlertRules = async (): Promise<AlertRule[]> => {
13482
return cachedRules
13583
}
13684

85+
export const getRenderers = async (): Promise<RendererRecord[]> => {
86+
if (!process.env.ALERT_CONFIG_DB_URL) return []
87+
await refreshRulesIfStale()
88+
return cachedRenderers
89+
}
90+
91+
export const refreshAlertConfig = async (): Promise<void> => {
92+
if (!process.env.ALERT_CONFIG_DB_URL) return
93+
cachedRules = await loadRules()
94+
cachedRenderers = await loadRenderers()
95+
lastRuleRefresh = Date.now()
96+
console.log(`Alert config: force refreshed ${cachedRules.length} rules and ${cachedRenderers.length} renderers`)
97+
}
98+
13799
/**
138100
* Refresh alert rules periodically. Changes take effect without redeploy.
139101
* First load must succeed or the process exits — we can't run without rules.
@@ -144,8 +106,9 @@ const refreshRulesIfStale = async (): Promise<void> => {
144106
const isFirstLoad = lastRuleRefresh === 0
145107
try {
146108
cachedRules = await loadRules()
109+
cachedRenderers = await loadRenderers()
147110
lastRuleRefresh = Date.now()
148-
console.log(`Alert config: loaded ${cachedRules.length} rules`)
111+
console.log(`Alert config: loaded ${cachedRules.length} rules and ${cachedRenderers.length} renderers`)
149112
} catch (err) {
150113
if (isFirstLoad) {
151114
console.error('FATAL: Failed to load alert rules on startup:', err)
@@ -155,6 +118,47 @@ const refreshRulesIfStale = async (): Promise<void> => {
155118
}
156119
}
157120

121+
export const findMatchingRenderer = (params: {
122+
chainId: number
123+
matchType: 'event' | 'trace'
124+
contractAddress?: string | null
125+
topic0?: string | null
126+
sighash?: string | null
127+
}): RendererRecord | null => {
128+
const contractAddress = params.contractAddress?.toLowerCase() ?? null
129+
const topic0 = params.topic0?.toLowerCase() ?? null
130+
const sighash = params.sighash?.toLowerCase() ?? null
131+
132+
const candidates = cachedRenderers.filter((renderer) => {
133+
if (renderer.matchType !== params.matchType) return false
134+
if (renderer.chainId != null && renderer.chainId !== params.chainId) return false
135+
136+
if (renderer.contractAddresses?.length) {
137+
if (!contractAddress) return false
138+
if (!renderer.contractAddresses.some((value) => value.toLowerCase() === contractAddress)) return false
139+
}
140+
141+
if (params.matchType === 'event' && renderer.topic0) {
142+
if (!topic0 || renderer.topic0.toLowerCase() !== topic0) return false
143+
}
144+
145+
if (params.matchType === 'trace' && renderer.sighash) {
146+
if (!sighash || renderer.sighash.toLowerCase() !== sighash) return false
147+
}
148+
149+
return true
150+
})
151+
152+
if (candidates.length === 0) return null
153+
154+
const score = (renderer: RendererRecord) =>
155+
(renderer.chainId != null ? 4 : 0) +
156+
(renderer.contractAddresses?.length ? 2 : 0) +
157+
(renderer.topic0 || renderer.sighash ? 1 : 0)
158+
159+
return candidates.sort((a, b) => score(b) - score(a) || a.name.localeCompare(b.name))[0] ?? null
160+
}
161+
158162
/**
159163
* Find all rules matching an event record.
160164
*/

src/alert-config/index.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,21 @@
11
export { evaluateFilter } from './evaluate-filter'
2-
export { initAlertConfigDb, findMatchingEventRules, findMatchingTraceRules, getAlertRules } from './config-loader'
3-
export type { AlertRule, ComparisonOp, FieldCondition, FilterExpression, GroupCondition } from './types'
2+
export {
3+
findMatchingEventRules,
4+
findMatchingTraceRules,
5+
findMatchingRenderer,
6+
getAlertRules,
7+
getRenderers,
8+
refreshAlertConfig,
9+
} from './config-loader'
10+
export { initAlertConfigDb } from './migration'
11+
export type {
12+
AlertRule,
13+
ComparisonOp,
14+
FieldCondition,
15+
FilterExpression,
16+
GroupCondition,
17+
RendererRecord,
18+
TemplateFieldFormat,
19+
TemplateRendererConfig,
20+
TemplateRendererField,
21+
} from './types'

src/alert-config/migration.sql

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ INSERT INTO chain VALUES (1, 'mainnet'), (8453, 'base'), (146, 'sonic');
1212
-- Valid notification topics (maps to Discord webhooks)
1313
CREATE TYPE severity_level AS ENUM ('low', 'medium', 'high', 'critical', 'broken', 'highlight');
1414
CREATE TYPE match_type AS ENUM ('event', 'trace');
15+
CREATE TYPE renderer_type AS ENUM ('default', 'template');
1516

1617
-- Valid topics — add rows here as new products launch
1718
CREATE TABLE topic (
@@ -134,6 +135,33 @@ CREATE TABLE abi (
134135
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
135136
);
136137

138+
-- ─── Renderer ────────────────────────────────────────────────────────────────
139+
-- Additive template renderer overrides for specific contract + selector matches.
140+
141+
CREATE TABLE renderer (
142+
id TEXT PRIMARY KEY CHECK (is_slug(id)),
143+
name TEXT NOT NULL,
144+
mode renderer_type NOT NULL DEFAULT 'template',
145+
chain_id INTEGER REFERENCES chain(id),
146+
match_type match_type NOT NULL,
147+
contract_addresses TEXT[] CHECK (array_all_match(contract_addresses, 'address')),
148+
topic0 TEXT CHECK (topic0 IS NULL OR is_topic_hash(topic0)),
149+
sighash TEXT CHECK (sighash IS NULL OR is_sighash(sighash)),
150+
config_json JSONB NOT NULL DEFAULT '{}'::jsonb,
151+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
152+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
153+
154+
CONSTRAINT renderer_selector_fields CHECK (
155+
(match_type = 'event' AND sighash IS NULL) OR
156+
(match_type = 'trace' AND topic0 IS NULL)
157+
)
158+
);
159+
160+
CREATE INDEX idx_renderer_chain_id ON renderer (chain_id);
161+
CREATE INDEX idx_renderer_match_type ON renderer (match_type);
162+
CREATE INDEX idx_renderer_topic0 ON renderer (topic0);
163+
CREATE INDEX idx_renderer_sighash ON renderer (sighash);
164+
137165
-- ─── Alert Rule ───────────────────────────────────────────────────────────────
138166

139167
CREATE TABLE alert_rule (
@@ -206,3 +234,6 @@ CREATE TRIGGER alert_rule_updated_at
206234
BEFORE UPDATE ON alert_rule
207235
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
208236

237+
CREATE TRIGGER renderer_updated_at
238+
BEFORE UPDATE ON renderer
239+
FOR EACH ROW EXECUTE FUNCTION update_updated_at();

0 commit comments

Comments
 (0)