Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
05019d0
feat: add write queue for serializing file operations
Mosas2000 Mar 14, 2026
98befad
feat: add withEventLock function for queued operations
Mosas2000 Mar 14, 2026
6e2b19a
refactor: export withEventLock for testing
Mosas2000 Mar 14, 2026
8ad02f2
fix: wrap event storage in withEventLock to prevent race conditions
Mosas2000 Mar 14, 2026
6dd133e
docs: add JSDoc to withEventLock
Mosas2000 Mar 14, 2026
3f261d0
docs: add JSDoc to loadEvents
Mosas2000 Mar 14, 2026
e981f57
docs: add JSDoc to saveEvents with lock requirement note
Mosas2000 Mar 14, 2026
ee2ae5f
docs: annotate DATA_DIR constant
Mosas2000 Mar 14, 2026
3e60080
docs: annotate DB_FILE constant
Mosas2000 Mar 14, 2026
824f8a4
docs: annotate PORT constant
Mosas2000 Mar 14, 2026
f33e7cb
docs: annotate AUTH_TOKEN constant
Mosas2000 Mar 14, 2026
85fcef6
fix: log errors from queued operations instead of swallowing
Mosas2000 Mar 14, 2026
a06dc4b
test: add __test_resetQueue helper for test isolation
Mosas2000 Mar 14, 2026
04a86e8
docs: add comment for CORS preflight handler
Mosas2000 Mar 14, 2026
dbfc13f
docs: add comment for bearer token auth check
Mosas2000 Mar 14, 2026
0f81552
docs: add route comment for POST chainhook events endpoint
Mosas2000 Mar 14, 2026
9ff046c
docs: add route comment for GET /api/tips endpoint
Mosas2000 Mar 14, 2026
4d9b433
docs: add route comment for GET /api/tips/user endpoint
Mosas2000 Mar 14, 2026
a1f8f7b
docs: add route comment for GET /api/tips/:id endpoint
Mosas2000 Mar 14, 2026
eae8859
docs: add route comment for GET /api/stats endpoint
Mosas2000 Mar 14, 2026
9b24904
docs: add route comment for GET /api/admin/events endpoint
Mosas2000 Mar 14, 2026
d5f2b96
docs: add route comment for GET /api/admin/bypasses endpoint
Mosas2000 Mar 14, 2026
4724633
feat: add Access-Control-Max-Age header to reduce preflight requests
Mosas2000 Mar 14, 2026
bc7eb28
docs: add comment for validation import
Mosas2000 Mar 14, 2026
7643650
docs: add comment for bypass-detection import
Mosas2000 Mar 14, 2026
1547176
feat: include request path in 404 response for debugging
Mosas2000 Mar 14, 2026
95f94d3
feat: log auth status on server startup
Mosas2000 Mar 14, 2026
9f88f1f
docs: add description to chainhook package.json
Mosas2000 Mar 14, 2026
fd04f06
docs: explain why write queue is needed in single-threaded Node
Mosas2000 Mar 14, 2026
623cffb
refactor: export loadEvents for integration test access
Mosas2000 Mar 14, 2026
e069128
style: ensure trailing newline in server.js
Mosas2000 Mar 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion chainhook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@
},
"engines": {
"node": ">=18"
}
},
"description": "Chainhook webhook listener for TipStream on-chain events"
}
87 changes: 67 additions & 20 deletions chainhook/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,45 @@ import http from "node:http";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
// Timelock bypass detection utilities
import { detectBypass, parseAdminEvent, formatBypassAlert } from "./bypass-detection.js";
// Input validation utilities
import { MAX_BODY_SIZE, isValidStacksAddress, sanitizeQueryInt } from "./validation.js";

const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3100;
const AUTH_TOKEN = process.env.CHAINHOOK_AUTH_TOKEN || "";
const DATA_DIR = join(__dirname, "data");
const DB_FILE = join(DATA_DIR, "events.json");
const PORT = process.env.PORT || 3100; // default webhook listener port
const AUTH_TOKEN = process.env.CHAINHOOK_AUTH_TOKEN || ""; // optional bearer token
const DATA_DIR = join(__dirname, "data"); // persistent storage directory
const DB_FILE = join(DATA_DIR, "events.json"); // JSON event store

if (!existsSync(DATA_DIR)) {
mkdirSync(DATA_DIR, { recursive: true });
}

// Serialized write queue. Node.js is single-threaded but async handlers
// can interleave between await points. This queue ensures file operations
// are atomic by chaining them sequentially.
let writeQueue = Promise.resolve();

/**
* Serialize access to the events file.
* Chains the provided function onto a promise queue so that only one
* read-modify-write cycle runs at a time.
* @param {() => void} fn - Synchronous function that reads and writes events.
* @returns {Promise<void>}
*/
function withEventLock(fn) {
writeQueue = writeQueue.then(fn).catch((err) => {
console.error('Event lock operation failed:', err.message);
});
return writeQueue;
}

/**
* Load all stored events from the JSON file.
* Returns an empty array if the file does not exist or is corrupted.
* @returns {Array<object>}
*/
function loadEvents() {
if (!existsSync(DB_FILE)) return [];
try {
Expand All @@ -24,6 +50,11 @@ function loadEvents() {
}
}

/**
* Persist events to the JSON file.
* Must only be called within withEventLock to avoid race conditions.
* @param {Array<object>} events
*/
function saveEvents(events) {
writeFileSync(DB_FILE, JSON.stringify(events, null, 2));
}
Expand Down Expand Up @@ -127,7 +158,11 @@ function parseTipEvent(event) {
};
}

export { parseBody, extractEvents, parseTipEvent, sendJson };
function __test_resetQueue() {
writeQueue = Promise.resolve();
}

export { parseBody, extractEvents, parseTipEvent, sendJson, withEventLock, loadEvents, __test_resetQueue };

const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
Expand All @@ -136,19 +171,23 @@ const server = http.createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Max-Age", "86400");

// Handle CORS preflight requests
if (req.method === "OPTIONS") {
res.writeHead(204);
return res.end();
}

// POST /api/chainhook/events -- ingest webhook payloads
if (req.method === "POST" && path === "/api/chainhook/events") {
// Early rejection based on Content-Length header
const contentLength = parseInt(req.headers["content-length"], 10);
if (contentLength > MAX_BODY_SIZE) {
return sendJson(res, 413, { error: "payload too large" });
}

// Verify bearer token when AUTH_TOKEN is configured
if (AUTH_TOKEN) {
const auth = req.headers.authorization || "";
if (auth !== `Bearer ${AUTH_TOKEN}`) {
Expand All @@ -160,23 +199,24 @@ const server = http.createServer(async (req, res) => {
const payload = await parseBody(req);
const newEvents = extractEvents(payload);
if (newEvents.length > 0) {
const stored = loadEvents();
await withEventLock(() => {
const stored = loadEvents();

// Check for timelock bypass events
for (const evt of newEvents) {
const detection = detectBypass(evt, stored.slice(-50));
if (detection.isBypass) {
console.warn(formatBypassAlert(detection, evt));
}
const adminEvt = parseAdminEvent(evt);
if (adminEvt) {
console.log(`Admin event: ${adminEvt.eventType} at block ${adminEvt.blockHeight}`);
for (const evt of newEvents) {
const detection = detectBypass(evt, stored.slice(-50));
if (detection.isBypass) {
console.warn(formatBypassAlert(detection, evt));
}
const adminEvt = parseAdminEvent(evt);
if (adminEvt) {
console.log(`Admin event: ${adminEvt.eventType} at block ${adminEvt.blockHeight}`);
}
}
}

stored.push(...newEvents);
saveEvents(stored);
console.log(`Indexed ${newEvents.length} events (total: ${stored.length})`);
stored.push(...newEvents);
saveEvents(stored);
console.log(`Indexed ${newEvents.length} events (total: ${stored.length})`);
});
}
return sendJson(res, 200, { ok: true, indexed: newEvents.length });
} catch (err) {
Expand All @@ -188,6 +228,7 @@ const server = http.createServer(async (req, res) => {
}
}

// GET /api/tips -- paginated list of parsed tips
if (req.method === "GET" && path === "/api/tips") {
const limit = sanitizeQueryInt(url.searchParams.get("limit") || "20", 1, 100);
const offset = sanitizeQueryInt(url.searchParams.get("offset") || "0", 0, Number.MAX_SAFE_INTEGER);
Expand All @@ -208,6 +249,7 @@ const server = http.createServer(async (req, res) => {
return sendJson(res, 200, { tips: paged, total: tips.length });
}

// GET /api/tips/user/:address -- tips sent or received by address
if (req.method === "GET" && path.startsWith("/api/tips/user/")) {
const address = path.split("/api/tips/user/")[1];
if (!isValidStacksAddress(address)) {
Expand All @@ -221,6 +263,7 @@ const server = http.createServer(async (req, res) => {
return sendJson(res, 200, { tips, total: tips.length });
}

// GET /api/tips/:id -- single tip by numeric ID
if (req.method === "GET" && path.match(/^\/api\/tips\/\d+$/)) {
const tipId = parseInt(path.split("/api/tips/")[1], 10);
if (isNaN(tipId) || tipId < 0) {
Expand All @@ -232,6 +275,7 @@ const server = http.createServer(async (req, res) => {
return sendJson(res, 200, tip);
}

// GET /api/stats -- aggregate tip statistics
if (req.method === "GET" && path === "/api/stats") {
const allEvents = loadEvents();
const tips = allEvents.map(parseTipEvent).filter(Boolean);
Expand All @@ -246,6 +290,7 @@ const server = http.createServer(async (req, res) => {
});
}

// GET /api/admin/events -- admin event log
if (req.method === "GET" && path === "/api/admin/events") {
const allEvents = loadEvents();
const adminEvents = allEvents
Expand All @@ -255,6 +300,7 @@ const server = http.createServer(async (req, res) => {
return sendJson(res, 200, { events: adminEvents, total: adminEvents.length });
}

// GET /api/admin/bypasses -- detected timelock bypass events
if (req.method === "GET" && path === "/api/admin/bypasses") {
const allEvents = loadEvents();
const bypasses = [];
Expand All @@ -272,7 +318,7 @@ const server = http.createServer(async (req, res) => {
return sendJson(res, 200, { bypasses, total: bypasses.length });
}

sendJson(res, 404, { error: "not found" });
sendJson(res, 404, { error: "not found", path: path });
});

export { server };
Expand All @@ -285,5 +331,6 @@ const isMain =
if (isMain) {
server.listen(PORT, () => {
console.log(`Chainhook callback server running on port ${PORT}`);
console.log(`Auth: ${AUTH_TOKEN ? "enabled" : "disabled"}`);
});
}
Loading