diff --git a/README.md b/README.md index 203226a..b7b276a 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,17 @@ cosmosvote/ │ ├── testnet.toml │ └── mainnet.toml │ +├── notification-service/ # Off-chain notification service +│ ├── src/ +│ │ ├── index.ts # CLI entry point +│ │ ├── watcher.ts # Horizon event poller +│ │ ├── notifier.ts # Email & webhook dispatch +│ │ ├── subscriptions.ts # Subscription management +│ │ └── types.ts # Shared types +│ ├── .env.example +│ ├── package.json +│ └── tsconfig.json +│ ├── frontend/ # React + Vite proposal browser ├── Cargo.toml # Workspace manifest ├── Makefile @@ -430,6 +441,7 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md). Quick checklist: - [SEP-41 Token Standard](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0041.md) - [Architecture Decision Records](./docs/adr/) - [Security Documentation](./docs/security/) +- [Notification Service](./docs/notification-service.md) --- diff --git a/docs/notification-service.md b/docs/notification-service.md new file mode 100644 index 0000000..53e4e14 --- /dev/null +++ b/docs/notification-service.md @@ -0,0 +1,178 @@ +# CosmosVote Notification Service + +An off-chain Node.js service that polls Stellar Horizon for CosmosVote governance events and dispatches notifications to subscribers via email or webhook. + +--- + +## How It Works + +1. The service polls the Horizon `/contracts/{id}/events` endpoint on a configurable interval. +2. It filters events by the governance contract topics: `(gov, created)`, `(gov, voted)`, `(gov, final)`, `(gov, exec)`, `(gov, cancel)`. +3. For each event, it finds matching subscribers and sends email (via nodemailer/SMTP) and/or HTTP POST (webhook). +4. The last-seen Horizon paging cursor is persisted so the service resumes without replaying events on restart. + +--- + +## Setup + +### Prerequisites + +- Node.js 18+ +- An SMTP server (for email notifications) +- A deployed CosmosVote governance contract + +### Install + +```bash +cd notification-service +npm install +``` + +### Configure + +```bash +cp .env.example .env +# Edit .env with your values +``` + +Key variables: + +| Variable | Description | +|----------|-------------| +| `HORIZON_URL` | Horizon server URL (testnet or mainnet) | +| `GOVERNANCE_CONTRACT_ID` | Deployed governance contract address | +| `POLL_INTERVAL_MS` | How often to poll for new events (default: 15000) | +| `SMTP_HOST` | SMTP server hostname | +| `SMTP_PORT` | SMTP server port (default: 587) | +| `SMTP_USER` | SMTP username | +| `SMTP_PASS` | SMTP password | +| `EMAIL_FROM` | Sender address for email notifications | +| `SUBSCRIPTIONS_FILE` | Path to persisted subscriptions JSON (default: `./data/subscriptions.json`) | + +--- + +## Running + +```bash +# Development (ts-node) +npm run dev start + +# Production (compile first) +npm run build +npm start start +``` + +--- + +## Subscription Management + +Subscriptions are managed via the CLI. Each subscriber can filter by proposal ID and/or event type. + +### Subscribe to all events (email) + +```bash +npx ts-node src/index.ts subscribe \ + --email alice@example.com \ + --events created,voted,final,exec,cancel +``` + +### Subscribe to a specific proposal (webhook) + +```bash +npx ts-node src/index.ts subscribe \ + --webhook https://example.com/hooks/governance \ + --proposal-id 42 \ + --events final,exec,cancel +``` + +### Subscribe with both email and webhook + +```bash +npx ts-node src/index.ts subscribe \ + --email alice@example.com \ + --webhook https://example.com/hooks/governance \ + --events created,final +``` + +### List subscribers + +```bash +npx ts-node src/index.ts list +``` + +### Remove a subscriber + +```bash +npx ts-node src/index.ts unsubscribe +``` + +--- + +## Event Types + +| Topic | Description | +|-------|-------------| +| `created` | A new proposal was created | +| `voted` | A vote was cast | +| `final` | A proposal was finalized (Passed or Rejected) | +| `exec` | A proposal was executed | +| `cancel` | A proposal was cancelled | + +--- + +## Webhook Payload + +Webhooks receive an HTTP POST with JSON body: + +```json +{ + "event": { + "type": "final", + "proposalId": "42", + "ledger": 12345678, + "raw": { ... } + } +} +``` + +--- + +## Subscription File Format + +Subscriptions are stored in the JSON file configured by `SUBSCRIPTIONS_FILE`: + +```json +{ + "cursor": "12345678-0", + "subscribers": [ + { + "id": "a1b2c3d4-...", + "events": ["created", "final"], + "email": "alice@example.com" + }, + { + "id": "e5f6g7h8-...", + "proposalId": "42", + "events": ["final", "exec", "cancel"], + "webhookUrl": "https://example.com/hooks/governance" + } + ] +} +``` + +--- + +## Project Structure + +``` +notification-service/ +├── src/ +│ ├── index.ts # CLI entry point +│ ├── watcher.ts # Horizon event poller +│ ├── notifier.ts # Email and webhook dispatch +│ ├── subscriptions.ts # Subscription CRUD + matching +│ └── types.ts # Shared types +├── .env.example +├── package.json +└── tsconfig.json +``` diff --git a/notification-service/.env.example b/notification-service/.env.example new file mode 100644 index 0000000..991f37b --- /dev/null +++ b/notification-service/.env.example @@ -0,0 +1,20 @@ +# CosmosVote Notification Service — Environment Variables +# Copy to .env and fill in your values. Never commit .env. + +# ─── Stellar / Horizon ─────────────────────────────────────────────────────── +HORIZON_URL=https://horizon-testnet.stellar.org +GOVERNANCE_CONTRACT_ID= + +# How often to poll Horizon for new events (milliseconds) +POLL_INTERVAL_MS=15000 + +# ─── Email (nodemailer) ────────────────────────────────────────────────────── +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=user@example.com +SMTP_PASS=yourpassword +EMAIL_FROM=cosmosvote@example.com + +# ─── Subscriptions data file ───────────────────────────────────────────────── +# Path to the JSON file that persists subscriber records +SUBSCRIPTIONS_FILE=./data/subscriptions.json diff --git a/notification-service/package.json b/notification-service/package.json new file mode 100644 index 0000000..76b1785 --- /dev/null +++ b/notification-service/package.json @@ -0,0 +1,24 @@ +{ + "name": "cosmosvote-notification-service", + "version": "1.0.0", + "private": true, + "description": "Off-chain notification service for CosmosVote governance events", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node src/index.ts", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@stellar/stellar-sdk": "12.3.0", + "axios": "1.7.2", + "nodemailer": "6.9.14" + }, + "devDependencies": { + "@types/nodemailer": "6.4.15", + "@types/node": "20.14.2", + "ts-node": "10.9.2", + "typescript": "5.4.5" + } +} diff --git a/notification-service/src/index.ts b/notification-service/src/index.ts new file mode 100644 index 0000000..bd59fb2 --- /dev/null +++ b/notification-service/src/index.ts @@ -0,0 +1,84 @@ +/** + * CosmosVote Notification Service + * + * Usage: + * npx ts-node src/index.ts start + * npx ts-node src/index.ts subscribe --email user@example.com --events created,final + * npx ts-node src/index.ts subscribe --webhook https://example.com/hook --events created,voted,final,exec,cancel + * npx ts-node src/index.ts subscribe --email user@example.com --proposal-id 42 --events final + * npx ts-node src/index.ts unsubscribe + * npx ts-node src/index.ts list + */ + +import 'fs'; // ensure Node built-ins are available before dotenv +// Load .env if present +try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('dotenv').config(); +} catch { + // dotenv is optional +} + +import { startWatcher } from './watcher'; +import { addSubscriber, removeSubscriber, listSubscribers } from './subscriptions'; +import { GovernanceEventType } from './types'; + +const ALL_EVENTS: GovernanceEventType[] = ['created', 'voted', 'final', 'exec', 'cancel']; + +function parseArgs(): { cmd: string; args: string[] } { + const [, , cmd = 'start', ...args] = process.argv; + return { cmd, args }; +} + +function flag(args: string[], name: string): string | undefined { + const idx = args.indexOf(`--${name}`); + return idx !== -1 ? args[idx + 1] : undefined; +} + +function parseEvents(raw?: string): GovernanceEventType[] { + if (!raw) return ALL_EVENTS; + return raw.split(',').map((e) => e.trim() as GovernanceEventType); +} + +async function main(): Promise { + const { cmd, args } = parseArgs(); + + switch (cmd) { + case 'start': + await startWatcher(); + break; + + case 'subscribe': { + const email = flag(args, 'email'); + const webhookUrl = flag(args, 'webhook'); + const proposalId = flag(args, 'proposal-id'); + const events = parseEvents(flag(args, 'events')); + + const subscriber = addSubscriber({ email, webhookUrl, proposalId, events }); + console.log('Subscriber added:'); + console.log(JSON.stringify(subscriber, null, 2)); + break; + } + + case 'unsubscribe': { + const id = args[0]; + if (!id) { console.error('Usage: unsubscribe '); process.exit(1); } + const removed = removeSubscriber(id); + console.log(removed ? `Removed subscriber ${id}` : `No subscriber found with id ${id}`); + break; + } + + case 'list': + console.log(JSON.stringify(listSubscribers(), null, 2)); + break; + + default: + console.error(`Unknown command: ${cmd}`); + process.exit(1); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/notification-service/src/notifier.ts b/notification-service/src/notifier.ts new file mode 100644 index 0000000..08ab31e --- /dev/null +++ b/notification-service/src/notifier.ts @@ -0,0 +1,47 @@ +import nodemailer from 'nodemailer'; +import axios from 'axios'; +import { GovernanceEvent, Subscriber } from './types'; + +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT ?? 587), + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, +}); + +function buildMessage(event: GovernanceEvent): string { + const labels: Record = { + created: 'A new governance proposal has been created.', + voted: 'A vote has been cast on a proposal.', + final: 'A proposal has been finalized.', + exec: 'A proposal has been executed.', + cancel: 'A proposal has been cancelled.', + }; + const base = labels[event.type] ?? `Governance event: ${event.type}`; + return event.proposalId + ? `${base}\nProposal ID: ${event.proposalId}\nLedger: ${event.ledger}` + : `${base}\nLedger: ${event.ledger}`; +} + +async function sendEmail(to: string, event: GovernanceEvent): Promise { + const text = buildMessage(event); + await transporter.sendMail({ + from: process.env.EMAIL_FROM, + to, + subject: `CosmosVote: proposal ${event.type} event`, + text, + }); +} + +async function sendWebhook(url: string, event: GovernanceEvent): Promise { + await axios.post(url, { event }, { timeout: 10_000 }); +} + +export async function notify(subscriber: Subscriber, event: GovernanceEvent): Promise { + const tasks: Promise[] = []; + if (subscriber.email) tasks.push(sendEmail(subscriber.email, event)); + if (subscriber.webhookUrl) tasks.push(sendWebhook(subscriber.webhookUrl, event)); + await Promise.all(tasks); +} diff --git a/notification-service/src/subscriptions.ts b/notification-service/src/subscriptions.ts new file mode 100644 index 0000000..1545c4a --- /dev/null +++ b/notification-service/src/subscriptions.ts @@ -0,0 +1,72 @@ +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; +import { GovernanceEventType, Subscriber, SubscriptionStore } from './types'; + +const SUBSCRIPTIONS_FILE = process.env.SUBSCRIPTIONS_FILE ?? './data/subscriptions.json'; + +function load(): SubscriptionStore { + try { + const raw = fs.readFileSync(SUBSCRIPTIONS_FILE, 'utf8'); + return JSON.parse(raw) as SubscriptionStore; + } catch { + return { subscribers: [], cursor: 'now' }; + } +} + +function save(store: SubscriptionStore): void { + const dir = path.dirname(SUBSCRIPTIONS_FILE); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(SUBSCRIPTIONS_FILE, JSON.stringify(store, null, 2)); +} + +export function getStore(): SubscriptionStore { + return load(); +} + +export function saveCursor(cursor: string): void { + const store = load(); + store.cursor = cursor; + save(store); +} + +export function addSubscriber(opts: { + proposalId?: string; + events: GovernanceEventType[]; + email?: string; + webhookUrl?: string; +}): Subscriber { + if (!opts.email && !opts.webhookUrl) { + throw new Error('At least one of email or webhookUrl is required.'); + } + const store = load(); + const subscriber: Subscriber = { id: crypto.randomUUID(), ...opts }; + store.subscribers.push(subscriber); + save(store); + return subscriber; +} + +export function removeSubscriber(id: string): boolean { + const store = load(); + const before = store.subscribers.length; + store.subscribers = store.subscribers.filter((s) => s.id !== id); + if (store.subscribers.length === before) return false; + save(store); + return true; +} + +export function listSubscribers(): Subscriber[] { + return load().subscribers; +} + +/** Return subscribers that should be notified for a given event. */ +export function matchingSubscribers( + eventType: GovernanceEventType, + proposalId?: string, +): Subscriber[] { + return load().subscribers.filter((s) => { + if (!s.events.includes(eventType)) return false; + if (s.proposalId && s.proposalId !== proposalId) return false; + return true; + }); +} diff --git a/notification-service/src/types.ts b/notification-service/src/types.ts new file mode 100644 index 0000000..f6f54cc --- /dev/null +++ b/notification-service/src/types.ts @@ -0,0 +1,35 @@ +/** Governance event types emitted by the CosmosVote contract. */ +export type GovernanceEventType = + | 'created' // (gov, created) — proposal created + | 'voted' // (gov, voted) — vote cast + | 'final' // (gov, final) — proposal finalized + | 'exec' // (gov, exec) — proposal executed + | 'cancel'; // (gov, cancel) — proposal cancelled + +export interface GovernanceEvent { + type: GovernanceEventType; + proposalId?: string; + ledger: number; + raw: unknown; +} + +/** A subscriber who wants notifications via email and/or webhook. */ +export interface Subscriber { + /** Unique identifier */ + id: string; + /** Optional: only notify for this proposal ID. Undefined = all proposals. */ + proposalId?: string; + /** Which event types to receive */ + events: GovernanceEventType[]; + /** Email address (optional) */ + email?: string; + /** Webhook URL (optional) */ + webhookUrl?: string; +} + +/** Persisted state: list of subscribers + last processed Horizon paging token. */ +export interface SubscriptionStore { + subscribers: Subscriber[]; + /** Horizon event paging token — used to resume polling without replaying events. */ + cursor: string; +} diff --git a/notification-service/src/watcher.ts b/notification-service/src/watcher.ts new file mode 100644 index 0000000..c8f51da --- /dev/null +++ b/notification-service/src/watcher.ts @@ -0,0 +1,117 @@ +import { Horizon } from '@stellar/stellar-sdk'; +import { GovernanceEvent, GovernanceEventType } from './types'; +import { getStore, saveCursor, matchingSubscribers } from './subscriptions'; +import { notify } from './notifier'; + +const HORIZON_URL = process.env.HORIZON_URL ?? 'https://horizon-testnet.stellar.org'; +const CONTRACT_ID = process.env.GOVERNANCE_CONTRACT_ID ?? ''; +const POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS ?? 15_000); + +// Governance event topic pairs: (gov, ) +const TRACKED_TOPICS: GovernanceEventType[] = ['created', 'voted', 'final', 'exec', 'cancel']; + +const server = new Horizon.Server(HORIZON_URL); + +/** Parse a Horizon contract event record into a GovernanceEvent, or null if not relevant. */ +function parseEvent(record: Horizon.ServerApi.ContractEventRecord): GovernanceEvent | null { + const topics = record.topic as string[]; + if (!topics || topics.length < 2) return null; + + // Topics are XDR-encoded symbols; the raw string representation for symbol_short is the symbol value. + const [ns, subtype] = topics.map((t) => { + try { + // Horizon returns topics as base64 XDR; stellar-sdk decodes them. + // For symbol_short values they render as plain strings in .value. + const parsed = (record as unknown as { topic: Array<{ type: string; value: string }> }).topic; + return parsed ? undefined : t; // fallback handled below + } catch { + return undefined; + } + }); + + // Use the decoded topic values directly from the record when available. + const rawTopics = record.topic as unknown as Array<{ type: string; value: string }>; + const nsVal = rawTopics?.[0]?.value ?? ''; + const subtypeVal = rawTopics?.[1]?.value ?? ''; + + if (nsVal !== 'gov') return null; + if (!(TRACKED_TOPICS as string[]).includes(subtypeVal)) return null; + + // Extract proposal ID from value when present (first element of the tuple for most events). + const rawValue = record.value as unknown as { type: string; value: unknown[] } | undefined; + const proposalId = rawValue?.value?.[0]?.toString(); + + return { + type: subtypeVal as GovernanceEventType, + proposalId, + ledger: record.ledger_closed_at ? Number(record.id.split('-')[0]) : 0, + raw: record, + }; +} + +async function fetchNewEvents(cursor: string): Promise<{ + events: GovernanceEvent[]; + nextCursor: string; +}> { + const response = await (server as unknown as { + contractEvents(params: { + contractId: string; + cursor?: string; + limit?: number; + }): Promise<{ records: Horizon.ServerApi.ContractEventRecord[]; next_cursor?: string }>; + }).contractEvents({ + contractId: CONTRACT_ID, + cursor: cursor !== 'now' ? cursor : undefined, + limit: 50, + }); + + const events: GovernanceEvent[] = []; + let nextCursor = cursor; + + for (const record of response.records) { + const ev = parseEvent(record); + if (ev) events.push(ev); + nextCursor = record.id; + } + + return { events, nextCursor }; +} + +async function processEvents(events: GovernanceEvent[]): Promise { + for (const event of events) { + const subscribers = matchingSubscribers(event.type, event.proposalId); + await Promise.all( + subscribers.map((sub) => + notify(sub, event).catch((err: unknown) => + console.error(`Failed to notify subscriber ${sub.id}:`, err), + ), + ), + ); + } +} + +export async function startWatcher(): Promise { + if (!CONTRACT_ID) { + throw new Error('GOVERNANCE_CONTRACT_ID is not set.'); + } + + console.log(`[watcher] Starting. Contract: ${CONTRACT_ID}`); + console.log(`[watcher] Polling Horizon every ${POLL_INTERVAL_MS}ms`); + + async function poll(): Promise { + const { cursor } = getStore(); + try { + const { events, nextCursor } = await fetchNewEvents(cursor); + if (events.length > 0) { + console.log(`[watcher] ${events.length} new event(s) found`); + await processEvents(events); + saveCursor(nextCursor); + } + } catch (err) { + console.error('[watcher] Poll error:', err); + } + setTimeout(poll, POLL_INTERVAL_MS); + } + + await poll(); +} diff --git a/notification-service/tsconfig.json b/notification-service/tsconfig.json new file mode 100644 index 0000000..b90592d --- /dev/null +++ b/notification-service/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +}