Skip to content
Open
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

---

Expand Down
178 changes: 178 additions & 0 deletions docs/notification-service.md
Original file line number Diff line number Diff line change
@@ -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 <subscriber-id>
```

---

## 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
```
20 changes: 20 additions & 0 deletions notification-service/.env.example
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions notification-service/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
84 changes: 84 additions & 0 deletions notification-service/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 <id>
* 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<void> {
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 <id>'); 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);
});
47 changes: 47 additions & 0 deletions notification-service/src/notifier.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<void> {
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<void> {
await axios.post(url, { event }, { timeout: 10_000 });
}

export async function notify(subscriber: Subscriber, event: GovernanceEvent): Promise<void> {
const tasks: Promise<void>[] = [];
if (subscriber.email) tasks.push(sendEmail(subscriber.email, event));
if (subscriber.webhookUrl) tasks.push(sendWebhook(subscriber.webhookUrl, event));
await Promise.all(tasks);
}
Loading