From 6017b4734f9ed1398ce09507430ef1123971c3b2 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 26 Feb 2026 17:58:15 +0100 Subject: [PATCH 1/3] feat(chainhook): add event predicate for mainnet tip indexing Configure Chainhook predicate to listen for all print events from the deployed tipstream contract. Events are delivered via HTTP POST to the callback server with bearer token authentication. --- chainhook/tipstream-events.chainhook.json | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 chainhook/tipstream-events.chainhook.json diff --git a/chainhook/tipstream-events.chainhook.json b/chainhook/tipstream-events.chainhook.json new file mode 100644 index 00000000..b465e090 --- /dev/null +++ b/chainhook/tipstream-events.chainhook.json @@ -0,0 +1,24 @@ +{ + "uuid": "tipstream-chainhook-mainnet", + "name": "TipStream Event Indexer", + "version": 1, + "chain": "stacks", + "networks": { + "mainnet": { + "if_this": { + "scope": "print_event", + "contract_identifier": "SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.tipstream", + "contains": "event" + }, + "then_that": { + "http_post": { + "url": "${CHAINHOOK_CALLBACK_URL:-http://localhost:3100}/api/chainhook/events", + "authorization_header": "Bearer ${CHAINHOOK_AUTH_TOKEN}" + } + }, + "start_block": 195000, + "expire_after_occurrence": null, + "decode_clarity_values": true + } + } +} From 842f806726fac411c583c3efbab7d49ec1106c75 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 26 Feb 2026 17:58:27 +0100 Subject: [PATCH 2/3] feat(chainhook): add callback server with REST API endpoints Implement a lightweight HTTP server that receives Chainhook webhook payloads, extracts contract events, and stores them in a JSON file. Expose REST endpoints for querying tips by ID, by user address, with pagination, and for aggregated platform statistics. --- chainhook/package.json | 12 +++ chainhook/server.js | 181 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 chainhook/package.json create mode 100644 chainhook/server.js diff --git a/chainhook/package.json b/chainhook/package.json new file mode 100644 index 00000000..901991d6 --- /dev/null +++ b/chainhook/package.json @@ -0,0 +1,12 @@ +{ + "name": "tipstream-chainhook", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=18" + } +} diff --git a/chainhook/server.js b/chainhook/server.js new file mode 100644 index 00000000..1d2f72b1 --- /dev/null +++ b/chainhook/server.js @@ -0,0 +1,181 @@ +import http from "node:http"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +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"); + +if (!existsSync(DATA_DIR)) { + mkdirSync(DATA_DIR, { recursive: true }); +} + +function loadEvents() { + if (!existsSync(DB_FILE)) return []; + try { + return JSON.parse(readFileSync(DB_FILE, "utf-8")); + } catch { + return []; + } +} + +function saveEvents(events) { + writeFileSync(DB_FILE, JSON.stringify(events, null, 2)); +} + +function parseBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString())); + } catch (err) { + reject(err); + } + }); + req.on("error", reject); + }); +} + +function extractEvents(payload) { + const events = []; + const apply = payload.apply || []; + for (const block of apply) { + const transactions = block.transactions || []; + for (const tx of transactions) { + const metadata = tx.metadata || {}; + const receipt = metadata.receipt || {}; + const printEvents = receipt.events || []; + + for (const evt of printEvents) { + if (evt.type !== "SmartContractEvent" && evt.type !== "print_event") continue; + const data = evt.data || evt.contract_event || {}; + const value = data.value || data.raw_value; + if (!value) continue; + + events.push({ + txId: tx.transaction_identifier?.hash || "", + blockHeight: block.block_identifier?.index || 0, + timestamp: block.timestamp || Date.now(), + contract: data.contract_identifier || "", + event: value, + }); + } + } + } + return events; +} + +function sendJson(res, statusCode, data) { + res.writeHead(statusCode, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); +} + +function parseTipEvent(event) { + const val = event.event; + if (!val || typeof val !== "object") return null; + if (val.event !== "tip-sent") return null; + return { + tipId: val["tip-id"], + sender: val.sender, + recipient: val.recipient, + amount: val.amount, + fee: val.fee, + netAmount: val["net-amount"], + txId: event.txId, + blockHeight: event.blockHeight, + timestamp: event.timestamp, + }; +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + const path = url.pathname; + + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + return res.end(); + } + + if (req.method === "POST" && path === "/api/chainhook/events") { + if (AUTH_TOKEN) { + const auth = req.headers.authorization || ""; + if (auth !== `Bearer ${AUTH_TOKEN}`) { + return sendJson(res, 401, { error: "unauthorized" }); + } + } + + try { + const payload = await parseBody(req); + const newEvents = extractEvents(payload); + if (newEvents.length > 0) { + const stored = loadEvents(); + 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) { + console.error("Failed to process chainhook payload:", err.message); + return sendJson(res, 400, { error: "invalid payload" }); + } + } + + if (req.method === "GET" && path === "/api/tips") { + const limit = parseInt(url.searchParams.get("limit") || "20", 10); + const offset = parseInt(url.searchParams.get("offset") || "0", 10); + const allEvents = loadEvents(); + const tips = allEvents + .map(parseTipEvent) + .filter(Boolean) + .reverse(); + const paged = tips.slice(offset, offset + limit); + return sendJson(res, 200, { tips: paged, total: tips.length }); + } + + if (req.method === "GET" && path.startsWith("/api/tips/user/")) { + const address = path.split("/api/tips/user/")[1]; + const allEvents = loadEvents(); + const tips = allEvents + .map(parseTipEvent) + .filter((t) => t && (t.sender === address || t.recipient === address)) + .reverse(); + return sendJson(res, 200, { tips, total: tips.length }); + } + + if (req.method === "GET" && path.match(/^\/api\/tips\/\d+$/)) { + const tipId = parseInt(path.split("/api/tips/")[1], 10); + const allEvents = loadEvents(); + const tip = allEvents.map(parseTipEvent).find((t) => t && t.tipId === tipId); + if (!tip) return sendJson(res, 404, { error: "tip not found" }); + return sendJson(res, 200, tip); + } + + if (req.method === "GET" && path === "/api/stats") { + const allEvents = loadEvents(); + const tips = allEvents.map(parseTipEvent).filter(Boolean); + const totalVolume = tips.reduce((sum, t) => sum + (t.amount || 0), 0); + const totalFees = tips.reduce((sum, t) => sum + (t.fee || 0), 0); + return sendJson(res, 200, { + totalTips: tips.length, + totalVolume, + totalFees, + uniqueSenders: new Set(tips.map((t) => t.sender)).size, + uniqueRecipients: new Set(tips.map((t) => t.recipient)).size, + }); + } + + sendJson(res, 404, { error: "not found" }); +}); + +server.listen(PORT, () => { + console.log(`Chainhook callback server running on port ${PORT}`); +}); From 1f4adb2d24e6ddbc225840ff27b271a0613fd7d1 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 26 Feb 2026 17:58:38 +0100 Subject: [PATCH 3/3] docs(chainhook): add integration guide and API documentation Document setup instructions, environment variables, predicate configuration, indexed event types, API endpoints, and frontend integration patterns for the Chainhook event indexer. --- chainhook/README.md | 104 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 chainhook/README.md diff --git a/chainhook/README.md b/chainhook/README.md new file mode 100644 index 00000000..13a71f6f --- /dev/null +++ b/chainhook/README.md @@ -0,0 +1,104 @@ +# Chainhook Integration + +## Overview + +TipStream uses [Chainhook](https://docs.hiro.so/stacks/chainhook) to listen for real-time print events from the tipstream contract on Stacks. Events are delivered via HTTP POST to a callback server that indexes them in a database, replacing the need for frontend polling. + +## Architecture + +``` +Stacks Blockchain + │ + ▼ + Chainhook Node + (event listener) + │ + ▼ + Callback Server + (POST /api/chainhook/events) + │ + ▼ + SQLite / PostgreSQL + │ + ▼ + REST API ──► Frontend +``` + +## Predicate Configuration + +The predicate at `chainhook/tipstream-events.chainhook.json` listens for all `print_event` calls from the deployed tipstream contract. It filters events containing the `event` key, which is emitted by every public function in the contract. + +### Indexed Events + +| Event Type | Source Function | Key Fields | +|------------|----------------|------------| +| `tip-sent` | `send-tip` | tip-id, sender, recipient, amount, fee, net-amount | +| `profile-updated` | `update-profile` | user, display-name | +| `user-blocked` | `toggle-block-user` | blocker, blocked, is-blocked | +| `contract-paused` | `set-paused` | paused | +| `fee-updated` | `set-fee-basis-points` | new-fee | +| `fee-change-proposed` | `propose-fee-change` | new-fee, effective-height | +| `fee-change-executed` | `execute-fee-change` | new-fee | +| `fee-change-cancelled` | `cancel-fee-change` | - | +| `pause-change-proposed` | `propose-pause-change` | paused, effective-height | +| `pause-change-executed` | `execute-pause-change` | paused | +| `ownership-proposed` | `propose-new-owner` | current-owner, proposed-owner | +| `ownership-transferred` | `accept-ownership` | new-owner | +| `multisig-updated` | `set-multisig` | multisig | + +## Setup + +### Prerequisites + +- [Chainhook CLI](https://github.com/hirosystems/chainhook) installed +- Node.js 18+ + +### Environment Variables + +```bash +CHAINHOOK_CALLBACK_URL=http://localhost:3100 # Callback server URL +CHAINHOOK_AUTH_TOKEN=your-secret-token # Bearer token for webhook auth +DATABASE_URL=sqlite://./data/tipstream.db # Database connection string +``` + +### Running Locally + +1. Start the callback server: + ```bash + cd chainhook + npm install + node server.js + ``` + +2. Register the predicate with Chainhook: + ```bash + chainhook predicates register chainhook/tipstream-events.chainhook.json --mainnet + ``` + +3. The server will receive events at `POST /api/chainhook/events` as they occur on-chain. + +### Production Deployment + +For production, deploy the callback server behind HTTPS and replace the `CHAINHOOK_CALLBACK_URL` with the public endpoint. Use PostgreSQL instead of SQLite for concurrent access. + +## API Endpoints + +The callback server exposes these read endpoints once events are indexed: + +| Endpoint | Description | +|----------|-------------| +| `GET /api/tips?limit=20&offset=0` | Recent tips with pagination | +| `GET /api/tips/:id` | Single tip by ID | +| `GET /api/tips/user/:address` | Tips sent or received by a user | +| `GET /api/stats` | Aggregated platform statistics | + +## Frontend Integration + +Replace the existing polling logic in `RecentTips.jsx` with direct API calls: + +```javascript +const response = await fetch(`${CHAINHOOK_API_URL}/api/tips?limit=20`); +const tips = await response.json(); +``` + +For real-time updates, the server can be extended with WebSocket or Server-Sent Events (SSE) support.