Skip to content

Commit bfe6192

Browse files
AIQnetLabclaude
andcommitted
fix: explorer — server-side filtering, TX timestamps, 5s polling
- Server-side type filtering + pagination (was client-side, broke pagination) - Fix sync-service to use TX-level timestamp with block timestamp fallback - Add backfill script for transactions with missing timestamps - Explicit SQL column list instead of SELECT * for better performance - Restore 5s polling interval for real-time blockchain explorer updates - Add display-type-to-DB mapping for accurate filter queries Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 73f7bfd commit bfe6192

5 files changed

Lines changed: 213 additions & 66 deletions

File tree

applications/qnet-explorer/frontend/lib/db.ts

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,24 @@ export async function getTransactionByHash(hash: string): Promise<TransactionRow
147147
return result.rows[0] || null;
148148
}
149149

150+
// Map display type names back to raw DB tx_type values
151+
const DISPLAY_TYPE_TO_DB: Record<string, string[]> = {
152+
'Transfer': ['Transfer', 'BatchTransfers'],
153+
'Reward': ['RewardDistribution', 'BatchRewardClaims', 'SystemReward', 'SystemRewards', 'SystemEmission', 'Emission', 'Reward'],
154+
'Swap': ['Swap'],
155+
'Heartbeat': ['HeartbeatCommitment', 'PingCommitmentWithSampling', 'LightNodeEligibilityBitmap', 'BitmapCommitment', 'PingAttestation'],
156+
'Registration': ['NodeRegistration', 'Registration'],
157+
'Activation': ['NodeActivation', 'BatchNodeActivations'],
158+
'Contract': ['ContractDeploy', 'ContractCall'],
159+
'System': ['CreateAccount', 'System'],
160+
};
161+
150162
export async function getTransactions(
151163
page: number = 1,
152164
perPage: number = 50,
153165
sortOrder: 'asc' | 'desc' = 'desc',
154-
typeFilter?: string
166+
typeFilter?: string,
167+
displayTypes?: string[]
155168
): Promise<{ transactions: TransactionRow[]; total: number; currentHeight: number }> {
156169
// Validate and sanitize inputs
157170
if (!Number.isInteger(page) || page < 1) {
@@ -163,39 +176,57 @@ export async function getTransactions(
163176
if (sortOrder !== 'asc' && sortOrder !== 'desc') {
164177
throw new Error('Invalid sortOrder: must be "asc" or "desc"');
165178
}
166-
179+
167180
const offset = (page - 1) * perPage;
168-
let whereClause = '';
169-
const params: unknown[] = [perPage, offset];
170-
let paramIndex = 3;
181+
let whereClauseMain = '';
182+
let whereClauseCount = '';
183+
const filterValues: unknown[] = [];
171184

172185
if (typeFilter && typeFilter !== 'All') {
173-
// Validate typeFilter to prevent SQL injection (even though params protect us)
174186
if (!/^[a-zA-Z0-9_\s-]+$/.test(typeFilter)) {
175187
throw new Error('Invalid typeFilter format');
176188
}
177-
whereClause = `WHERE tx_type = $${paramIndex}`;
178-
params.push(typeFilter);
179-
paramIndex++;
189+
filterValues.push(typeFilter);
190+
whereClauseMain = `WHERE tx_type = $3`;
191+
whereClauseCount = `WHERE tx_type = $1`;
192+
} else if (displayTypes && displayTypes.length > 0) {
193+
const dbTypes: string[] = [];
194+
for (const dt of displayTypes) {
195+
const mapped = DISPLAY_TYPE_TO_DB[dt];
196+
if (mapped) dbTypes.push(...mapped);
197+
}
198+
if (dbTypes.length > 0) {
199+
filterValues.push(...dbTypes);
200+
const mainPlaceholders = dbTypes.map((_, i) => `$${3 + i}`).join(', ');
201+
const countPlaceholders = dbTypes.map((_, i) => `$${1 + i}`).join(', ');
202+
whereClauseMain = `WHERE tx_type IN (${mainPlaceholders})`;
203+
whereClauseCount = `WHERE tx_type IN (${countPlaceholders})`;
204+
}
180205
}
181206

182207
const orderBy = sortOrder === 'desc' ? 'DESC' : 'ASC';
183-
208+
184209
const transactionsQuery = `
185-
SELECT * FROM transactions
186-
${whereClause}
187-
ORDER BY block ${orderBy}, timestamp ${orderBy}
210+
SELECT hash, tx_type, from_address, to_address, amount, block, timestamp,
211+
nonce, gas_price, gas_limit, signature, public_key,
212+
is_quantum_signed, dilithium_signature, dilithium_public_key, data, status
213+
FROM transactions
214+
${whereClauseMain}
215+
ORDER BY block ${orderBy}, timestamp ${orderBy}, tx_type ${orderBy}, hash ${orderBy}
188216
LIMIT $1 OFFSET $2
189217
`;
190218

191219
const countQuery = `
192-
SELECT COUNT(*) as total FROM transactions
193-
${whereClause}
220+
SELECT COUNT(*) as total FROM transactions
221+
${whereClauseCount}
194222
`;
195223

224+
const mainParams = [perPage, offset, ...filterValues];
225+
const countParams = [...filterValues];
226+
196227
const [transactionsResult, countResult, syncStateResult] = await Promise.all([
197-
query<TransactionRow>(transactionsQuery, params),
198-
query<{ total: string }>(countQuery, params.slice(2)),
228+
query<TransactionRow>(transactionsQuery, mainParams),
229+
query<{ total: string }>(countQuery, countParams.length > 0 ? countParams : undefined),
199230
query<{ last_height: string | null }>('SELECT last_height FROM sync_state ORDER BY updated_at DESC LIMIT 1')
200231
]);
201232

applications/qnet-explorer/frontend/lib/sync-service.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,11 @@ function transformTransaction(
253253
return null;
254254
}
255255

256-
// Always use blockTimestamp (authoritative, set by producer node) — never trust client timestamp
257-
let rawTs = blockTimestamp || 0;
256+
// v3.53: Use TX-level timestamp first (most accurate), block timestamp as fallback
257+
// API returns timestamp on both block and individual TX objects
258+
const txTs = Number(tx.timestamp) || 0;
259+
const fallbackTs = blockTimestamp || 0;
260+
let rawTs = (txTs > 0 ? txTs : fallbackTs);
258261
if (!Number.isFinite(rawTs) || rawTs < 0) {
259262
warn('[Sync] Invalid timestamp, fallback to 0:', rawTs);
260263
rawTs = 0;
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* Backfill timestamps for transactions that have timestamp=0 in the database.
3+
* Fetches real timestamps from the QNet API node.
4+
*
5+
* Usage: npx tsx scripts/backfill-timestamps.ts
6+
*/
7+
8+
// All config via environment variables — no hardcoded credentials
9+
const API_URL = process.env.QNET_API_URL;
10+
const API_KEY = process.env.QNET_API_KEY || '';
11+
12+
if (!API_URL) {
13+
console.error('ERROR: QNET_API_URL environment variable is required');
14+
process.exit(1);
15+
}
16+
17+
const DB_CONFIG = {
18+
host: process.env.PGHOST || 'localhost',
19+
port: parseInt(process.env.PGPORT || '5432'),
20+
database: process.env.PGDATABASE,
21+
user: process.env.PGUSER,
22+
password: process.env.PGPASSWORD,
23+
};
24+
25+
if (!DB_CONFIG.database || !DB_CONFIG.user || !DB_CONFIG.password) {
26+
console.error('ERROR: PGDATABASE, PGUSER, PGPASSWORD environment variables are required');
27+
process.exit(1);
28+
}
29+
30+
async function main() {
31+
const { Pool } = await import('pg');
32+
const pool = new Pool(DB_CONFIG);
33+
34+
try {
35+
// Get all distinct blocks that have transactions with timestamp=0
36+
const { rows: blocks } = await pool.query(
37+
'SELECT DISTINCT block FROM transactions WHERE timestamp = 0 ORDER BY block'
38+
);
39+
40+
console.log(`Found ${blocks.length} blocks with timestamp=0 transactions`);
41+
42+
let totalUpdated = 0;
43+
44+
for (const { block: height } of blocks) {
45+
console.log(`\nFetching block ${height} from API...`);
46+
47+
try {
48+
const res = await fetch(`${API_URL}/api/v1/microblock/${height}`, {
49+
headers: {
50+
'Content-Type': 'application/json',
51+
'X-API-Key': API_KEY,
52+
},
53+
signal: AbortSignal.timeout(30000),
54+
});
55+
56+
if (!res.ok) {
57+
console.error(` Failed to fetch block ${height}: HTTP ${res.status}`);
58+
continue;
59+
}
60+
61+
const data = await res.json();
62+
const block = data.block || data;
63+
const blockTimestamp = Number(block.timestamp) || 0;
64+
const txs = block.transactions || block.txs || [];
65+
66+
console.log(` Block timestamp: ${blockTimestamp}, ${txs.length} transactions`);
67+
68+
// Build a map of tx hash -> timestamp
69+
const txTimestamps = new Map<string, number>();
70+
for (const tx of txs) {
71+
const hash = String(tx.hash || '');
72+
const txTs = Number(tx.timestamp) || 0;
73+
// Use TX timestamp first, block timestamp as fallback
74+
const ts = txTs > 0 ? txTs : blockTimestamp;
75+
if (hash && ts > 0) {
76+
// Normalize to milliseconds
77+
const tsMs = ts > 1e12 ? ts : ts * 1000;
78+
txTimestamps.set(hash, tsMs);
79+
}
80+
}
81+
82+
// Update transactions in DB
83+
const { rows: affectedTxs } = await pool.query(
84+
'SELECT hash FROM transactions WHERE block = $1 AND timestamp = 0',
85+
[height]
86+
);
87+
88+
for (const { hash } of affectedTxs) {
89+
let newTs = txTimestamps.get(hash);
90+
91+
// If TX not found in API response, use block timestamp
92+
if (!newTs && blockTimestamp > 0) {
93+
newTs = blockTimestamp > 1e12 ? blockTimestamp : blockTimestamp * 1000;
94+
}
95+
96+
if (newTs && newTs > 0) {
97+
await pool.query(
98+
'UPDATE transactions SET timestamp = $1 WHERE hash = $2 AND timestamp = 0',
99+
[newTs, hash]
100+
);
101+
totalUpdated++;
102+
console.log(` Updated ${hash.substring(0, 16)}... → timestamp=${newTs}`);
103+
} else {
104+
console.warn(` No timestamp found for ${hash.substring(0, 16)}...`);
105+
}
106+
}
107+
} catch (err) {
108+
console.error(` Error processing block ${height}:`, err);
109+
}
110+
}
111+
112+
console.log(`\nDone! Updated ${totalUpdated} transactions.`);
113+
114+
// Verify
115+
const { rows: [{ count }] } = await pool.query(
116+
'SELECT COUNT(*) as count FROM transactions WHERE timestamp = 0'
117+
);
118+
console.log(`Remaining transactions with timestamp=0: ${count}`);
119+
120+
} finally {
121+
await pool.end();
122+
}
123+
}
124+
125+
main().catch(console.error);

applications/qnet-explorer/frontend/src/app/api/activity/route.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getTransactions } from '../../../../lib/db';
33
import { rateLimit, getClientIdentifier } from '../../../../lib/rate-limit';
44

55
export const dynamic = 'force-dynamic';
6-
export const revalidate = 0;
6+
export const revalidate = 10; // Cache for 10 seconds
77

88
// Rate limiting: 100 requests per minute per IP
99
const RATE_LIMIT_MAX = 100;
@@ -145,7 +145,7 @@ export async function GET(request: NextRequest) {
145145
sortOrder = sortParam as 'asc' | 'desc';
146146
}
147147

148-
// Validate type filter
148+
// Validate type filter (single type, raw DB name)
149149
let typeFilter: string | undefined = undefined;
150150
if (typeParam) {
151151
if (!/^[a-zA-Z0-9_\s-]+$/.test(typeParam)) {
@@ -157,8 +157,16 @@ export async function GET(request: NextRequest) {
157157
typeFilter = typeParam;
158158
}
159159

160+
// Support multiple display-type filters (e.g. types=Transfer,Reward)
161+
const typesParam = searchParams.get('types');
162+
let displayTypes: string[] | undefined = undefined;
163+
if (typesParam) {
164+
displayTypes = typesParam.split(',').filter(t => /^[a-zA-Z]+$/.test(t.trim())).map(t => t.trim());
165+
if (displayTypes.length === 0) displayTypes = undefined;
166+
}
167+
160168
// Get transactions from PostgreSQL
161-
const { transactions, total, currentHeight } = await getTransactions(page, perPage, sortOrder, typeFilter);
169+
const { transactions, total, currentHeight } = await getTransactions(page, perPage, sortOrder, typeFilter, displayTypes);
162170

163171
// Map to response format - return ALL fields
164172
// Note: perPage is already validated to max 500, so we use all transactions

0 commit comments

Comments
 (0)