Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
104 changes: 104 additions & 0 deletions chainhook/README.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions chainhook/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "tipstream-chainhook",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"engines": {
"node": ">=18"
}
}
181 changes: 181 additions & 0 deletions chainhook/server.js
Original file line number Diff line number Diff line change
@@ -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}`);
});
24 changes: 24 additions & 0 deletions chainhook/tipstream-events.chainhook.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading