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. 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}`); +}); 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 + } + } +}