168 lines
6.3 KiB
TypeScript
168 lines
6.3 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* scripts/now-in-handler-sql-smoke.ts
|
|
*
|
|
* Structural Defense #37 — NOW()-in-handler-SQL sentinel
|
|
* (cp85 Lesson #1 candidate, promoted in same cp).
|
|
*
|
|
* Catches the cp85-A1 bug class: indexer handler SQL using
|
|
* `NOW()` (Postgres CURRENT_TIMESTAMP at execution) instead of
|
|
* `ctx.blockTime` (chain-deterministic block time).
|
|
*
|
|
* Why this is a bug: handlers are run twice — once in real time
|
|
* as ops arrive on the Blurt chain, once on replay when a
|
|
* fresh-DB indexer catches up from chain history. `NOW()` at
|
|
* replay time evaluates to the replay machine's wall-clock, not
|
|
* the original block time. Comparisons like
|
|
* `expires_at > NOW()` or `effective_at <= NOW()` produce
|
|
* different result sets at replay vs real-time, leading to
|
|
* divergent DB state between operators replaying the same
|
|
* chain history.
|
|
*
|
|
* The cp85-A1 fix replaced 6 `NOW()` references in
|
|
* `featureBid.ts` with `$N` parameters bound to `ctx.blockTime`.
|
|
* `strangerFee.ts:148` carries an explicit prior-art comment
|
|
* on the same anti-pattern.
|
|
*
|
|
* What this smoke catches:
|
|
*
|
|
* Any line in any handler file (`apps/indexer/src/indexer/handlers/*.ts`)
|
|
* that contains `NOW()` inside what appears to be SQL — namely,
|
|
* inside backtick-delimited template strings.
|
|
*
|
|
* Exemption marker:
|
|
*
|
|
* A line containing `// now-in-handler-sql: SAFE <reason>`
|
|
* exempts the line. Use sparingly and only when the SQL is
|
|
* explicitly NOT part of chain-deterministic state (e.g., a
|
|
* one-off operator query, a non-replayed maintenance query).
|
|
*
|
|
* Each handler file = one scenario; each violating line = one
|
|
* failure entry.
|
|
*/
|
|
|
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const REPO = resolve(HERE, '..');
|
|
const HANDLERS_DIR = join(REPO, 'apps/indexer/src/indexer/handlers');
|
|
|
|
console.log('\n── NOW()-in-handler-SQL sentinel-grep smoke ────────────\n');
|
|
|
|
interface Violation {
|
|
file: string;
|
|
line: number;
|
|
text: string;
|
|
}
|
|
|
|
const violations: Violation[] = [];
|
|
|
|
const handlerFiles = readdirSync(HANDLERS_DIR)
|
|
.filter((f) => f.endsWith('.ts'))
|
|
.sort();
|
|
|
|
console.log(` handlers scanned: ${handlerFiles.length}`);
|
|
|
|
// State machine: track whether we're inside a backtick template
|
|
// literal as we walk each line. A backtick toggles the
|
|
// "inside-template" state. We only flag NOW() that appears
|
|
// while inside-template (i.e., in SQL).
|
|
for (const filename of handlerFiles) {
|
|
const path = join(HANDLERS_DIR, filename);
|
|
const text = readFileSync(path, 'utf8');
|
|
const lines = text.split('\n');
|
|
|
|
let inTemplate = false;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i]!;
|
|
// Strip line comments before backtick-counting — a `//` comment
|
|
// containing a backtick should not toggle the template state.
|
|
// (Block comments are not handled; they're rare on the same
|
|
// line as backtick toggling and producing false positives
|
|
// would be worse than missing them.)
|
|
const commentStart = line.indexOf('//');
|
|
const codeLine = commentStart >= 0 ? line.slice(0, commentStart) : line;
|
|
|
|
// Count unescaped backticks in the codeLine.
|
|
// An odd count means the template state flips at end of line.
|
|
let backtickCount = 0;
|
|
for (let j = 0; j < codeLine.length; j++) {
|
|
if (codeLine[j] === '`' && codeLine[j - 1] !== '\\') {
|
|
backtickCount++;
|
|
}
|
|
}
|
|
|
|
// Now scan codeLine for NOW(): if we're inside a template
|
|
// at any point on this line, flag it. More precise: NOW()
|
|
// on a line where we ARE in a template (either entered
|
|
// earlier or started here) is a hit.
|
|
const hasNowCall = /\bNOW\s*\(\s*\)/.test(codeLine);
|
|
|
|
if (hasNowCall) {
|
|
// Determine if NOW() position is inside-template:
|
|
// Walk through codeLine char-by-char, track local template
|
|
// state from the line-start state, and check whether the
|
|
// NOW() match position is reached while inside.
|
|
let localInTemplate = inTemplate;
|
|
const nowMatch = /\bNOW\s*\(\s*\)/.exec(codeLine);
|
|
if (nowMatch === null) continue;
|
|
const nowPos = nowMatch.index;
|
|
for (let j = 0; j < nowPos; j++) {
|
|
if (codeLine[j] === '`' && codeLine[j - 1] !== '\\') {
|
|
localInTemplate = !localInTemplate;
|
|
}
|
|
}
|
|
if (localInTemplate) {
|
|
// Check for exemption marker on the same line.
|
|
if (/\/\/\s*now-in-handler-sql:\s*SAFE\b/.test(line)) {
|
|
// Exempted. Skip.
|
|
} else {
|
|
violations.push({
|
|
file: filename,
|
|
line: i + 1,
|
|
text: line.trim()
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update line-end template state for next iteration.
|
|
if (backtickCount % 2 === 1) {
|
|
inTemplate = !inTemplate;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
console.log(`\n ✗ ${violations.length} NOW()-in-handler-SQL violation(s):`);
|
|
for (const v of violations) {
|
|
console.log(` - apps/indexer/src/indexer/handlers/${v.file}:${v.line}`);
|
|
console.log(` ${v.text.slice(0, 100)}`);
|
|
}
|
|
console.log(
|
|
`\n Why this fails: handler SQL must be replay-deterministic.` +
|
|
`\n NOW() evaluates to the replay machine's wall-clock at` +
|
|
`\n replay time, producing divergent state between operators.` +
|
|
`\n See cp85-A1 (featureBid.ts) and strangerFee.ts:148 for` +
|
|
`\n prior incidents.` +
|
|
`\n\n Fix: replace NOW() with $N parameter bound to ctx.blockTime.` +
|
|
`\n Example:` +
|
|
`\n // Before:` +
|
|
`\n \`WHERE expires_at > NOW()\`,` +
|
|
`\n [otherParam]` +
|
|
`\n // After:` +
|
|
`\n \`WHERE expires_at > $1\`,` +
|
|
`\n [ctx.blockTime, otherParam]` +
|
|
`\n\n Exempt: append \`// now-in-handler-sql: SAFE <reason>\` on the same line` +
|
|
`\n when the SQL is genuinely non-chain-state (one-off operator query, etc).`
|
|
);
|
|
console.log('\n──────────────────────────────────────────────────────');
|
|
console.log(`✗ ${violations.length}/${handlerFiles.length} scenarios failed`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(` ✓ no NOW() inside SQL in any of ${handlerFiles.length} handler file(s)`);
|
|
console.log('\n──────────────────────────────────────────────────────');
|
|
console.log(`✓ all ${handlerFiles.length} scenarios passed`);
|