From 7da7f7168a3326a2120bfc8d2a34fad725a42e0a Mon Sep 17 00:00:00 2001 From: NnamdiCyber Date: Sat, 27 Jun 2026 14:10:26 +0100 Subject: [PATCH] feat: implement investor notification system for score changes Emit ScoreChanged event with old/new scores and rates from update_impact_score and update_credit_quality_score. Add off-chain notification service with email+webhook dispatch, REST API for preferences, and full documentation. Closes #131 --- CONTRACTS.md | 12 +- README.md | 8 ++ docs/INTEGRATION.md | 1 + docs/NOTIFICATIONS.md | 186 +++++++++++++++++++++++++++ notification-service/.env.example | 27 ++++ notification-service/Dockerfile | 14 ++ notification-service/package.json | 29 +++++ notification-service/src/api.ts | 70 ++++++++++ notification-service/src/config.ts | 29 +++++ notification-service/src/db.ts | 146 +++++++++++++++++++++ notification-service/src/index.ts | 60 +++++++++ notification-service/src/listener.ts | 155 ++++++++++++++++++++++ notification-service/src/notifier.ts | 140 ++++++++++++++++++++ notification-service/src/types.ts | 71 ++++++++++ notification-service/tsconfig.json | 19 +++ project_registry/src/events.rs | 37 ++++++ project_registry/src/lib.rs | 34 ++++- project_registry/src/test.rs | 80 +++++++++++- 18 files changed, 1109 insertions(+), 9 deletions(-) create mode 100644 docs/NOTIFICATIONS.md create mode 100644 notification-service/.env.example create mode 100644 notification-service/Dockerfile create mode 100644 notification-service/package.json create mode 100644 notification-service/src/api.ts create mode 100644 notification-service/src/config.ts create mode 100644 notification-service/src/db.ts create mode 100644 notification-service/src/index.ts create mode 100644 notification-service/src/listener.ts create mode 100644 notification-service/src/notifier.ts create mode 100644 notification-service/src/types.ts create mode 100644 notification-service/tsconfig.json diff --git a/CONTRACTS.md b/CONTRACTS.md index 1e97698..068566a 100644 --- a/CONTRACTS.md +++ b/CONTRACTS.md @@ -19,8 +19,8 @@ Constructor args: `admin: Address, whitelister: Address` | `get_project(id)` | none | `id: u32` | `ProjectData` | — | | `total_projects()` | none | — | `u32` | — | | `get_all_projects()` | none | — | `Vec<(u32, ProjectData)>` | — | -| `update_impact_score(project_id, credit_quality, green_impact)` | admin (owner) | `project_id: u32, credit_quality: u32, green_impact: u32` | `()` | `ProjectUpdated { project_id, credit_quality, green_impact }` | -| `update_credit_quality_score(project_id, credit_quality)` | admin (owner) | `project_id: u32, credit_quality: u32` (0–100) | `()` | `CreditQualityUpdated { project_id, credit_quality }` | +| `update_impact_score(project_id, credit_quality, green_impact)` | admin (owner) | `project_id: u32, credit_quality: u32, green_impact: u32` | `()` | `ProjectUpdated`, `RateUpdated`, `ScoreChanged` | +| `update_credit_quality_score(project_id, credit_quality)` | admin (owner) | `project_id: u32, credit_quality: u32` (0–100) | `()` | `CreditQualityUpdated`, `ScoreChanged` | | `certify_project(caller, project_id, status)` | whitelister or admin | `caller: Address, project_id: u32, status: CertificationStatus` | `()` | `ProjectCertified { project_id, status }` | | `is_mature(project_id)` | none | `project_id: u32` | `bool` | — | | `create_proposal(proposer, description, voting_duration_secs)` | proposer | `proposer: Address, description: String, voting_duration_secs: u64` (≥ 86400) | `u32` (proposal\_id) | `ProposalCreated { proposal_id, proposer, voting_ends_at }` | @@ -54,10 +54,12 @@ pub struct Proposal { ### Score Functions Comparison -| Function | Scope | Emitted Event | +| Function | Scope | Emitted Events | |---|---|---| -| `update_impact_score` | Sets both `credit_quality` AND `green_impact` atomically | `ProjectUpdated` | -| `update_credit_quality_score` | Sets only `credit_quality`, leaves `green_impact` unchanged | `CreditQualityUpdated` | +| `update_impact_score` | Sets both `credit_quality` AND `green_impact` atomically | `ProjectUpdated`, `RateUpdated`, `ScoreChanged` | +| `update_credit_quality_score` | Sets only `credit_quality`, leaves `green_impact` unchanged | `CreditQualityUpdated`, `ScoreChanged` | + +The `ScoreChanged` event (#131) includes both old and new score values plus old and new interest rates, enabling off-chain notification services to calculate the exact delta without querying historical state. --- diff --git a/README.md b/README.md index 80cdeb2..df31bd8 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,12 @@ graph TD ## Contract Reference +### Notification System + +When impact scores change (via `update_impact_score` or `update_credit_quality_score`), the `ProjectRegistry` now emits a `ScoreChanged` event carrying both old and new score values. An off-chain notification service (`notification-service/`) monitors these events and dispatches email/webhook alerts to registered investors. + +For details, see [`docs/NOTIFICATIONS.md`](./docs/NOTIFICATIONS.md). + ### ProjectRegistry **Constructor** @@ -222,6 +228,8 @@ Every state-changing function emits a structured event. Topics are indexed by th |---|---|---|---| | `ProjectCreated` | `project_id` (u32) | `owner` (Address), `uri` (String) | `create_project()` | | `ProjectUpdated` | `project_id` (u32) | `credit_quality`, `green_impact` (u32) | `update_impact_score()` (only when values change) | +| `ScoreChanged` | `project_id` (u32) | `old_credit_quality`, `new_credit_quality`, `old_green_impact`, `new_green_impact`, `old_rate_bps`, `new_rate_bps` (u32) | `update_impact_score()`, `update_credit_quality_score()` (#131) | +| `CreditQualityUpdated` | `project_id` (u32) | `credit_quality` (u32) | `update_credit_quality_score()` (#6) | | `WhitelistSet` | `account` (Address) | `status` (bool) | `set_whitelist()` | | `ProjectCertified` | `project_id` (u32) | `status` (CertificationStatus) | `certify_project()` | | `ProposalCreated` | `proposal_id` (u32) | `proposer` (Address), `voting_ends_at` (u64) | `create_proposal()` | diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 2c76bb1..800a021 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -329,6 +329,7 @@ Key event topics by contract: | `InvestmentVault` | `insurance_claimed` | Default payout made | | `ProjectRegistry` | `project_created` | New project registered | | `ProjectRegistry` | `project_updated` | Impact scores updated | +| `ProjectRegistry` | `score_changed` | Score changed (includes old + new values) | | `ProjectRegistry` | `project_certified` | Certification status changed | | `ProjectRegistry` | `proposal_created` | Governance proposal opened | | `ProjectRegistry` | `vote_cast` | Vote recorded | diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md new file mode 100644 index 0000000..ca74286 --- /dev/null +++ b/docs/NOTIFICATIONS.md @@ -0,0 +1,186 @@ +# Notification System + +**Issue:** [#131](https://github.com/Heliobond/contracts/issues/131) + +The Heliobond notification system keeps investors informed when their invested projects' impact scores change. It consists of two parts: + +1. **Enhanced on-chain events** — The `ProjectRegistry` contract emits a `ScoreChanged` event with old and new score values. +2. **Off-chain notification service** — A Node.js service that monitors these events and dispatches email/webhook notifications to investors. + +--- + +## On-Chain Event: `ScoreChanged` + +The `ProjectRegistry` contract emits `ScoreChanged` whenever an impact score is updated via `update_impact_score` or `update_credit_quality_score`. The event carries both old and new values so off-chain consumers can calculate the precise delta. + +### Event Structure + +| Field | Type | Description | +|-------|------|-------------| +| `project_id` (topic) | `u32` | The project whose scores changed | +| `old_credit_quality` | `u32` | Previous credit quality (0–100) | +| `new_credit_quality` | `u32` | New credit quality (0–100) | +| `old_green_impact` | `u32` | Previous green impact (0–100) | +| `new_green_impact` | `u32` | New green impact (0–100) | +| `old_rate_bps` | `u32` | Previous interest rate in basis points (500–1000) | +| `new_rate_bps` | `u32` | New interest rate in basis points (500–1000) | + +### When It Fires + +- `update_impact_score(project_id, credit_quality, green_impact)` — when either score changes +- `update_credit_quality_score(project_id, credit_quality)` — when credit quality changes + +If the new values are identical to the old values, no event is emitted (no-op). + +### Example (Rust) + +```rust +// Initial scores: credit_quality = 0, green_impact = 0 → rate = 1000 bps +// After update: credit_quality = 80, green_impact = 60 → rate = 650 bps +contract.update_impact_score(&project_id, &80u32, &60u32); +// Emitted ScoreChanged { +// project_id: 1, +// old_credit_quality: 0, new_credit_quality: 80, +// old_green_impact: 0, new_green_impact: 60, +// old_rate_bps: 1000, new_rate_bps: 650, +// } +``` + +--- + +## Off-Chain Notification Service + +The notification service lives in `notification-service/`. It listens for `ScoreChanged` events from the `ProjectRegistry` contract and dispatches notifications to registered investors. + +### Architecture + +``` +Soroban RPC ──► Listener ──► Investor Index ──► Notifier + │ ├── Email (SMTP) + │ └── Webhook (HTTP POST) + │ + REST API ◄── Investors manage preferences +``` + +### Components + +| Component | File | Description | +|-----------|------|-------------| +| `Listener` | `src/listener.ts` | Polls Soroban RPC for `ScoreChanged` events | +| `Store` | `src/db.ts` | SQLite database for investor preferences and project-investor index | +| `Notifier` | `src/notifier.ts` | Dispatches email (nodemailer) and webhook (HTTP POST) notifications | +| `API` | `src/api.ts` | Express REST API for managing notification preferences | +| `Config` | `src/config.ts` | Environment-based configuration | + +### Quick Start + +```bash +cd notification-service +cp .env.example .env +# Edit .env with your Stellar RPC URL, contract IDs, and SMTP settings +npm install +npm run dev +``` + +### Configuration + +| Environment Variable | Default | Description | +|---------------------|---------|-------------| +| `STELLAR_RPC_URL` | `https://soroban-testnet.stellar.org` | Soroban RPC endpoint | +| `STELLAR_NETWORK_PASSPHRASE` | Testnet passphrase | Network passphrase | +| `REGISTRY_CONTRACT_ID` | (required) | Deployed `ProjectRegistry` contract ID | +| `VAULT_CONTRACT_ID` | (optional) | Deployed `InvestmentVault` contract ID | +| `DB_PATH` | `./data/notifications.db` | SQLite database path | +| `POLL_INTERVAL_MS` | `30000` | Event polling interval | +| `FROM_EMAIL` | — | Sender email address | +| `SMTP_HOST` | — | SMTP server hostname | +| `SMTP_PORT` | `587` | SMTP port | +| `SMTP_SECURE` | `false` | Use TLS for SMTP | +| `SMTP_USER` | — | SMTP username | +| `SMTP_PASS` | — | SMTP password | +| `API_PORT` | `3000` | REST API port | + +### REST API + +#### Manage Notification Preferences + +**GET `/preferences`** — List all registered preferences. + +**GET `/preferences/:address`** — Get preference for a specific investor address. + +**PUT `/preferences/:address`** — Create or update a preference. + +Request body: +```json +{ + "email": "investor@example.com", + "webhook_url": "https://my-app.com/heliobond-webhook", + "enabled": true, + "min_delta": 5 +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `email` | `string` | No | Email address for email notifications | +| `webhook_url` | `string` | No | HTTPS URL for webhook POST notifications | +| `enabled` | `boolean` | No (default: true) | Master toggle for notifications | +| `min_delta` | `number` | No (default: 1) | Minimum absolute score change (0–100) to trigger a notification | + +At least one of `email` or `webhook_url` must be provided. Both can be set simultaneously. + +**DELETE `/preferences/:address`** — Remove an investor's preferences. + +**GET `/health`** — Health check. + +### Webhook Payload + +When a score change triggers a webhook notification, the service sends an HTTP POST with the following JSON body: + +```json +{ + "event": "score_changed", + "project_id": 1, + "old_scores": { + "credit_quality": 60, + "green_impact": 40 + }, + "new_scores": { + "credit_quality": 85, + "green_impact": 40 + }, + "old_rate_bps": 750, + "new_rate_bps": 690, + "investor_address": "G...", + "timestamp": "2026-06-27T12:00:00.000Z" +} +``` + +The webhook endpoint should respond with HTTP 200/201 to acknowledge receipt. Retry logic is not yet implemented; the endpoint should be idempotent. + +### Building for Production + +```bash +cd notification-service +npm run build +npm start +``` + +### Docker + +```bash +cd notification-service +docker build -t heliobond-notification-service . +docker run -p 3000:3000 --env-file .env heliobond-notification-service +``` + +--- + +## Investor-Project Index + +The notification service maintains an SQLite table `investor_projects` that tracks which investors have holdings in which projects. This index is built by monitoring: + +- `Deposit` events from the `InvestmentVault` — identifies active investors +- `ProjectFunded` events — identifies which projects are funded + +This index allows the service to route score change notifications to only the relevant investors, rather than broadcasting to all registered users. diff --git a/notification-service/.env.example b/notification-service/.env.example new file mode 100644 index 0000000..d218a5d --- /dev/null +++ b/notification-service/.env.example @@ -0,0 +1,27 @@ +# Heliobond Notification Service — Environment Configuration +# Copy to .env and fill in your values. + +# Stellar Network +STELLAR_RPC_URL=https://soroban-testnet.stellar.org +STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 + +# Deployed Contract IDs +REGISTRY_CONTRACT_ID= +VAULT_CONTRACT_ID= + +# Database (SQLite, auto-created) +DB_PATH=./data/notifications.db + +# Polling interval in milliseconds +POLL_INTERVAL_MS=30000 + +# Email Transport (optional — omit to disable email) +FROM_EMAIL=notifications@heliobond.io +SMTP_HOST= +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASS= + +# REST API +API_PORT=3000 diff --git a/notification-service/Dockerfile b/notification-service/Dockerfile new file mode 100644 index 0000000..67ca321 --- /dev/null +++ b/notification-service/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package.json tsconfig.json ./ +RUN npm ci +COPY src/ ./src/ +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +COPY package.json ./ +RUN npm ci --omit=dev +COPY --from=builder /app/dist ./dist +EXPOSE 3000 +CMD ["node", "dist/index.js"] diff --git a/notification-service/package.json b/notification-service/package.json new file mode 100644 index 0000000..a9b6e9f --- /dev/null +++ b/notification-service/package.json @@ -0,0 +1,29 @@ +{ + "name": "@heliobond/notification-service", + "version": "0.1.0", + "private": true, + "description": "Off-chain notification service for Heliobond score change events", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts", + "lint": "eslint src/", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@stellar/stellar-sdk": "^12.0.0", + "better-sqlite3": "^11.0.0", + "nodemailer": "^6.9.0", + "express": "^4.18.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.0", + "@types/express": "^4.17.0", + "@types/nodemailer": "^6.4.0", + "@types/node": "^20.0.0", + "typescript": "^5.4.0", + "tsx": "^4.7.0", + "eslint": "^8.57.0" + } +} diff --git a/notification-service/src/api.ts b/notification-service/src/api.ts new file mode 100644 index 0000000..fb84e28 --- /dev/null +++ b/notification-service/src/api.ts @@ -0,0 +1,70 @@ +import express from "express"; +import { Store } from "./db"; +import { NotificationPreference } from "./types"; + +export function createApi(store: Store): express.Application { + const app = express(); + app.use(express.json()); + + // GET /health — health check + app.get("/health", (_req, res) => { + res.json({ status: "ok" }); + }); + + // GET /preferences — list all notification preferences + app.get("/preferences", (_req, res) => { + const prefs = store.listPreferences(); + res.json(prefs); + }); + + // GET /preferences/:address — get preference for a specific investor + app.get("/preferences/:address", (req, res) => { + const pref = store.getPreference(req.params.address); + if (!pref) { + res.status(404).json({ error: "preference not found" }); + return; + } + res.json(pref); + }); + + // PUT /preferences/:address — create or update an investor's preference + app.put("/preferences/:address", (req, res) => { + const { email, webhook_url, enabled, min_delta } = req.body; + const address = req.params.address; + + if (!address) { + res.status(400).json({ error: "address is required" }); + return; + } + + if (email && typeof email !== "string") { + res.status(400).json({ error: "email must be a string" }); + return; + } + + if (webhook_url && typeof webhook_url !== "string") { + res.status(400).json({ error: "webhook_url must be a string" }); + return; + } + + const pref: NotificationPreference = { + investor_address: address, + email: email || undefined, + webhook_url: webhook_url || undefined, + enabled: enabled !== false, + min_delta: typeof min_delta === "number" ? min_delta : 1, + updated_at: new Date().toISOString(), + }; + + store.upsertPreference(pref); + res.json(pref); + }); + + // DELETE /preferences/:address — remove an investor's preference + app.delete("/preferences/:address", (req, res) => { + store.deletePreference(req.params.address); + res.status(204).send(); + }); + + return app; +} diff --git a/notification-service/src/config.ts b/notification-service/src/config.ts new file mode 100644 index 0000000..ca61bae --- /dev/null +++ b/notification-service/src/config.ts @@ -0,0 +1,29 @@ +import { ServiceConfig } from "./types"; + +export function loadConfig(): ServiceConfig { + return { + rpc_url: process.env.STELLAR_RPC_URL || "https://soroban-testnet.stellar.org", + network_passphrase: + process.env.STELLAR_NETWORK_PASSPHRASE || + "Test SDF Network ; September 2015", + registry_contract_id: + process.env.REGISTRY_CONTRACT_ID || "", + vault_contract_id: + process.env.VAULT_CONTRACT_ID || "", + db_path: process.env.DB_PATH || "./data/notifications.db", + poll_interval_ms: parseInt(process.env.POLL_INTERVAL_MS || "30000", 10), + from_email: process.env.FROM_EMAIL, + email_transport: process.env.SMTP_HOST + ? { + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTP_PORT || "587", 10), + secure: process.env.SMTP_SECURE === "true", + auth: { + user: process.env.SMTP_USER || "", + pass: process.env.SMTP_PASS || "", + }, + } + : undefined, + api_port: parseInt(process.env.API_PORT || "3000", 10), + }; +} diff --git a/notification-service/src/db.ts b/notification-service/src/db.ts new file mode 100644 index 0000000..1045dfb --- /dev/null +++ b/notification-service/src/db.ts @@ -0,0 +1,146 @@ +import Database from "better-sqlite3"; +import { NotificationPreference, InvestorProjectRow } from "./types"; + +export class Store { + private db: Database.Database; + + constructor(path: string) { + this.db = new Database(path); + this.db.pragma("journal_mode = WAL"); + this.migrate(); + } + + private migrate(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS notification_preferences ( + investor_address TEXT PRIMARY KEY, + email TEXT, + webhook_url TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + min_delta INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS investor_projects ( + investor_address TEXT NOT NULL, + project_id INTEGER NOT NULL, + first_seen_ledger INTEGER NOT NULL, + last_seen_ledger INTEGER NOT NULL, + PRIMARY KEY (investor_address, project_id) + ); + + CREATE TABLE IF NOT EXISTS processed_ledgers ( + ledger INTEGER PRIMARY KEY + ); + + CREATE INDEX IF NOT EXISTS idx_investor_projects_project + ON investor_projects(project_id); + `); + } + + // ── Notification preferences ─────────────────────────────────────────── + + upsertPreference(pref: NotificationPreference): void { + const stmt = this.db.prepare(` + INSERT INTO notification_preferences + (investor_address, email, webhook_url, enabled, min_delta, updated_at) + VALUES (@investor_address, @email, @webhook_url, @enabled, @min_delta, @updated_at) + ON CONFLICT(investor_address) DO UPDATE SET + email = excluded.email, + webhook_url = excluded.webhook_url, + enabled = excluded.enabled, + min_delta = excluded.min_delta, + updated_at = excluded.updated_at + `); + stmt.run({ ...pref, enabled: pref.enabled ? 1 : 0 }); + } + + getPreference(address: string): NotificationPreference | undefined { + const row = this.db + .prepare("SELECT * FROM notification_preferences WHERE investor_address = ?") + .get(address) as Record | undefined; + if (!row) return undefined; + return this.rowToPreference(row); + } + + getAllEnabledPreferences(): NotificationPreference[] { + const rows = this.db + .prepare( + "SELECT * FROM notification_preferences WHERE enabled = 1 AND (email IS NOT NULL OR webhook_url IS NOT NULL)", + ) + .all() as Record[]; + return rows.map((r) => this.rowToPreference(r)); + } + + listPreferences(): NotificationPreference[] { + const rows = this.db + .prepare("SELECT * FROM notification_preferences ORDER BY updated_at DESC") + .all() as Record[]; + return rows.map((r) => this.rowToPreference(r)); + } + + deletePreference(address: string): void { + this.db + .prepare("DELETE FROM notification_preferences WHERE investor_address = ?") + .run(address); + } + + // ── Investor-project index ───────────────────────────────────────────── + + recordInvestment( + investor_address: string, + project_id: number, + ledger: number, + ): void { + this.db + .prepare( + `INSERT INTO investor_projects + (investor_address, project_id, first_seen_ledger, last_seen_ledger) + VALUES (?, ?, ?, ?) + ON CONFLICT(investor_address, project_id) DO UPDATE SET + last_seen_ledger = excluded.last_seen_ledger`, + ) + .run(investor_address, project_id, ledger, ledger); + } + + getInvestorsForProject(project_id: number): string[] { + const rows = this.db + .prepare( + "SELECT DISTINCT investor_address FROM investor_projects WHERE project_id = ?", + ) + .all(project_id) as { investor_address: string }[]; + return rows.map((r) => r.investor_address); + } + + // ── Ledger tracking ─────────────────────────────────────────────────── + + getLastProcessedLedger(): number { + const row = this.db + .prepare("SELECT MAX(ledger) as ledger FROM processed_ledgers") + .get() as { ledger: number | null }; + return row.ledger ?? 0; + } + + markLedgerProcessed(ledger: number): void { + this.db + .prepare("INSERT OR IGNORE INTO processed_ledgers (ledger) VALUES (?)") + .run(ledger); + } + + close(): void { + this.db.close(); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private rowToPreference(row: Record): NotificationPreference { + return { + investor_address: row.investor_address as string, + email: (row.email as string) || undefined, + webhook_url: (row.webhook_url as string) || undefined, + enabled: Boolean(row.enabled), + min_delta: row.min_delta as number, + updated_at: row.updated_at as string, + }; + } +} diff --git a/notification-service/src/index.ts b/notification-service/src/index.ts new file mode 100644 index 0000000..840e529 --- /dev/null +++ b/notification-service/src/index.ts @@ -0,0 +1,60 @@ +import { loadConfig } from "./config"; +import { Store } from "./db"; +import { Notifier } from "./notifier"; +import { createApi } from "./api"; +import { pollScoreChanges } from "./listener"; +import { ScoreChangedEvent } from "./types"; + +async function main(): Promise { + const config = loadConfig(); + + if (!config.registry_contract_id) { + console.error( + "FATAL: REGISTRY_CONTRACT_ID environment variable is required", + ); + process.exit(1); + } + + const store = new Store(config.db_path); + const notifier = new Notifier(config, store); + + // ── REST API ────────────────────────────────────────────────────────── + const app = createApi(store); + app.listen(config.api_port, () => { + console.log(`[api] Listening on port ${config.api_port}`); + }); + + // ── Event handler: score changed → notify investors ─────────────────── + const handleScoreChanged = async (event: ScoreChangedEvent): Promise => { + console.log( + `[handler] ScoreChanged: project #${event.project_id} ` + + `CQ:${event.old_credit_quality}→${event.new_credit_quality} ` + + `GI:${event.old_green_impact}→${event.new_green_impact} ` + + `rate:${event.old_rate_bps}→${event.new_rate_bps} bps`, + ); + + // Look up investors for this project + const investors = store.getInvestorsForProject(event.project_id); + if (investors.length === 0) { + console.log( + `[handler] No investors found for project #${event.project_id}`, + ); + return; + } + + await notifier.notifyInvestors(event, investors); + }; + + // ── Start event polling ────────────────────────────────────────────── + await pollScoreChanges( + config, + handleScoreChanged, + () => Promise.resolve(store.getLastProcessedLedger()), + async (ledger) => store.markLedgerProcessed(ledger), + ); +} + +main().catch((err) => { + console.error("FATAL:", err); + process.exit(1); +}); diff --git a/notification-service/src/listener.ts b/notification-service/src/listener.ts new file mode 100644 index 0000000..db8a3a8 --- /dev/null +++ b/notification-service/src/listener.ts @@ -0,0 +1,155 @@ +import { + SorobanRpc, + Contract, + scValToNative, + xdr, +} from "@stellar/stellar-sdk"; +import { ScoreChangedEvent, ServiceConfig } from "./types"; + +const SCORE_CHANGED_TOPIC = "ScoreChanged"; + +/** Decode a ScoreChanged event from a Soroban transaction event. */ +function decodeScoreChanged( + event: xdr.ContractEvent, + ledger: number, + timestamp: number, +): ScoreChangedEvent | null { + try { + const body = event.body(); + if (body.switch() !== xdr.ContractEventType.contractEventTypeV0) { + return null; + } + const v0 = body.v0(); + const topics = v0.topics(); + + // topics[0] = event name (Symbol), topics[1] = project_id (u32) + if (topics.length() < 2) return null; + const eventName = scValToNative(topics.get(0)); + if (eventName !== SCORE_CHANGED_TOPIC) return null; + + const projectId = Number(scValToNative(topics.get(1))); + const data = scValToNative(v0.data()); + + // data is {Vec: [old_cq, new_cq, old_gi, new_gi, old_rate, new_rate]} + if (!Array.isArray(data)) return null; + const [ + old_credit_quality, + new_credit_quality, + old_green_impact, + new_green_impact, + old_rate_bps, + new_rate_bps, + ] = data.map(Number); + + return { + project_id: projectId, + old_credit_quality, + new_credit_quality, + old_green_impact, + new_green_impact, + old_rate_bps, + new_rate_bps, + timestamp, + ledger, + }; + } catch { + return null; + } +} + +/** Fetch events from Soroban RPC for a range of ledgers. */ +async function fetchEvents( + server: SorobanRpc.Server, + contractId: string, + startLedger: number, + endLedger: number, +): Promise { + const results: ScoreChangedEvent[] = []; + + try { + const response = await server.getEvents({ + startLedger, + filters: [ + { + contractId, + type: "contract", + }, + ], + pagination: { + limit: 100, + }, + }); + + for (const event of response.events) { + if (!event.value) continue; + const decoded = decodeScoreChanged( + event.value, + event.ledger, + event.timestamp, + ); + if (decoded) { + results.push(decoded); + } + } + } catch (err) { + console.error("Error fetching events:", err); + } + + return results; +} + +/** Poll for new ScoreChanged events and invoke the callback. */ +export async function pollScoreChanges( + config: ServiceConfig, + onEvent: (event: ScoreChangedEvent) => Promise, + getLastLedger: () => Promise, + setLastLedger: (ledger: number) => Promise, +): Promise { + const server = new SorobanRpc.Server(config.rpc_url); + + console.log( + `[listener] Starting poll every ${config.poll_interval_ms}ms for contract ${config.registry_contract_id}`, + ); + + const poll = async () => { + try { + const latestLedger = await server.getLatestLedger(); + const lastProcessed = await getLastLedger(); + const startLedger = lastProcessed > 0 ? lastProcessed + 1 : latestLedger.sequence; + + if (startLedger > latestLedger.sequence) { + return; // caught up + } + + console.log( + `[listener] Scanning ledgers ${startLedger} → ${latestLedger.sequence}`, + ); + + const events = await fetchEvents( + server, + config.registry_contract_id, + startLedger, + latestLedger.sequence, + ); + + for (const ev of events) { + try { + await onEvent(ev); + } catch (err) { + console.error( + `[listener] Error processing event for project ${ev.project_id}:`, + err, + ); + } + } + + await setLastLedger(latestLedger.sequence); + } catch (err) { + console.error("[listener] Poll error:", err); + } + }; + + // Initial poll, then interval + await poll(); + setInterval(poll, config.poll_interval_ms); +} diff --git a/notification-service/src/notifier.ts b/notification-service/src/notifier.ts new file mode 100644 index 0000000..e80eb4c --- /dev/null +++ b/notification-service/src/notifier.ts @@ -0,0 +1,140 @@ +import nodemailer from "nodemailer"; +import { + ScoreChangedEvent, + NotificationPreference, + WebhookPayload, + ServiceConfig, +} from "./types"; +import { Store } from "./db"; + +export class Notifier { + private config: ServiceConfig; + private store: Store; + private transporter?: nodemailer.Transporter; + + constructor(config: ServiceConfig, store: Store) { + this.config = config; + this.store = store; + + if (config.email_transport) { + this.transporter = nodemailer.createTransport(config.email_transport); + console.log("[notifier] Email transport configured"); + } else { + console.log("[notifier] No email transport configured — email disabled"); + } + } + + /** Dispatch notifications to all investors who hold shares in the project. */ + async notifyInvestors( + event: ScoreChangedEvent, + investorAddresses: string[], + ): Promise { + const deltaCq = Math.abs( + event.new_credit_quality - event.old_credit_quality, + ); + const deltaGi = Math.abs( + event.new_green_impact - event.old_green_impact, + ); + const maxDelta = Math.max(deltaCq, deltaGi); + + for (const addr of investorAddresses) { + const pref = this.store.getPreference(addr); + if (!pref || !pref.enabled) continue; + if (maxDelta < pref.min_delta) continue; + + const hasEmail = !!(pref.email && this.transporter); + const hasWebhook = !!pref.webhook_url; + + if (!hasEmail && !hasWebhook) continue; + + const subject = `[Heliobond] Score change for project #${event.project_id}`; + const text = this.formatEmailText(event, addr); + + try { + if (hasEmail && this.transporter && pref.email) { + await this.sendEmail(pref.email, subject, text); + } + if (hasWebhook && pref.webhook_url) { + await this.sendWebhook(pref.webhook_url, event, addr); + } + } catch (err) { + console.error( + `[notifier] Failed to notify ${addr}:`, + err, + ); + } + } + } + + private async sendEmail( + to: string, + subject: string, + text: string, + ): Promise { + if (!this.transporter) return; + await this.transporter.sendMail({ + from: this.config.from_email, + to, + subject, + text, + }); + console.log(`[notifier] Email sent to ${to}`); + } + + private async sendWebhook( + url: string, + event: ScoreChangedEvent, + investorAddress: string, + ): Promise { + const payload: WebhookPayload = { + event: "score_changed", + project_id: event.project_id, + old_scores: { + credit_quality: event.old_credit_quality, + green_impact: event.old_green_impact, + }, + new_scores: { + credit_quality: event.new_credit_quality, + green_impact: event.new_green_impact, + }, + old_rate_bps: event.old_rate_bps, + new_rate_bps: event.new_rate_bps, + investor_address: investorAddress, + timestamp: new Date(event.timestamp * 1000).toISOString(), + }; + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error( + `Webhook returned ${response.status}: ${await response.text()}`, + ); + } + console.log(`[notifier] Webhook sent to ${url} (${response.status})`); + } + + private formatEmailText( + event: ScoreChangedEvent, + investorAddress: string, + ): string { + return [ + `Heliobond — Score Change Alert`, + ``, + `Project #${event.project_id} scores have been updated:`, + ``, + ` Credit Quality: ${event.old_credit_quality} → ${event.new_credit_quality}`, + ` Green Impact: ${event.old_green_impact} → ${event.new_green_impact}`, + ` Interest Rate: ${event.old_rate_bps} bps → ${event.new_rate_bps} bps`, + ``, + `Your address: ${investorAddress}`, + `Ledger: #${event.ledger}`, + ``, + `This affects your expected returns. Review your portfolio at`, + `https://heliobond.io/portfolio`, + ].join("\n"); + } +} diff --git a/notification-service/src/types.ts b/notification-service/src/types.ts new file mode 100644 index 0000000..0d016c1 --- /dev/null +++ b/notification-service/src/types.ts @@ -0,0 +1,71 @@ +/** Matches the on-chain ScoreChanged event from ProjectRegistry (#131). */ +export interface ScoreChangedEvent { + project_id: number; + old_credit_quality: number; + new_credit_quality: number; + old_green_impact: number; + new_green_impact: number; + old_rate_bps: number; + new_rate_bps: number; + /** Block timestamp when the event was emitted. */ + timestamp: number; + /** Stellar ledger sequence number. */ + ledger: number; +} + +/** Investor notification preference. */ +export interface NotificationPreference { + /** Stellar public address of the investor. */ + investor_address: string; + /** Email address for email notifications (optional). */ + email?: string; + /** Webhook URL for HTTP POST notifications (optional). */ + webhook_url?: string; + /** Whether score change notifications are enabled. */ + enabled: boolean; + /** Minimum absolute score change required to trigger a notification (0-100). */ + min_delta: number; + /** When this preference was created/updated. */ + updated_at: string; +} + +/** Payload sent to webhook URLs. */ +export interface WebhookPayload { + event: "score_changed"; + project_id: number; + old_scores: { credit_quality: number; green_impact: number }; + new_scores: { credit_quality: number; green_impact: number }; + old_rate_bps: number; + new_rate_bps: number; + investor_address: string; + timestamp: string; +} + +/** Database row for tracking which investors are in which projects. */ +export interface InvestorProjectRow { + investor_address: string; + project_id: number; + first_seen_ledger: number; + last_seen_ledger: number; +} + +/** Configuration for the notification service. */ +export interface ServiceConfig { + rpc_url: string; + network_passphrase: string; + registry_contract_id: string; + vault_contract_id: string; + db_path: string; + poll_interval_ms: number; + from_email?: string; + email_transport?: { + host: string; + port: number; + secure: boolean; + auth: { + user: string; + pass: string; + }; + }; + api_port: number; +} diff --git a/notification-service/tsconfig.json b/notification-service/tsconfig.json new file mode 100644 index 0000000..1951778 --- /dev/null +++ b/notification-service/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/project_registry/src/events.rs b/project_registry/src/events.rs index f6d0c2a..b2a41f1 100644 --- a/project_registry/src/events.rs +++ b/project_registry/src/events.rs @@ -210,3 +210,40 @@ pub struct ReputationUpdated { pub fn reputation_updated(env: &Env, creator: &Address, score: u32) { ReputationUpdated { creator: creator.clone(), score }.publish(env); } + +/// Emitted when a project's impact score changes (#131). +/// Includes both old and new values so off-chain notification services +/// can calculate the precise delta without querying historical state. +#[contractevent] +pub struct ScoreChanged { + #[topic] + pub project_id: u32, + pub old_credit_quality: u32, + pub new_credit_quality: u32, + pub old_green_impact: u32, + pub new_green_impact: u32, + pub old_rate_bps: u32, + pub new_rate_bps: u32, +} + +pub fn score_changed( + env: &Env, + project_id: u32, + old_credit_quality: u32, + new_credit_quality: u32, + old_green_impact: u32, + new_green_impact: u32, + old_rate_bps: u32, + new_rate_bps: u32, +) { + ScoreChanged { + project_id, + old_credit_quality, + new_credit_quality, + old_green_impact, + new_green_impact, + old_rate_bps, + new_rate_bps, + } + .publish(env); +} diff --git a/project_registry/src/lib.rs b/project_registry/src/lib.rs index 308d514..4181dc4 100644 --- a/project_registry/src/lib.rs +++ b/project_registry/src/lib.rs @@ -139,13 +139,29 @@ impl ProjectRegistry { return; } + let old_cq = project.credit_quality; + let old_gi = project.green_impact; + let old_rate = compute_rate(old_cq, old_gi); + project.credit_quality = credit_quality; project.green_impact = green_impact; + let new_rate = compute_rate(credit_quality, green_impact); + env.storage() .persistent() .set(&DataKey::Project(project_id), &project); events::project_updated(&env, project_id, credit_quality, green_impact); - events::rate_updated(&env, project_id, compute_rate(credit_quality, green_impact)); + events::rate_updated(&env, project_id, new_rate); + events::score_changed( + &env, + project_id, + old_cq, + credit_quality, + old_gi, + green_impact, + old_rate, + new_rate, + ); } /// Set the certification status of a project (whitelister or owner only) (#130). @@ -319,11 +335,27 @@ impl ProjectRegistry { .persistent() .get(&DataKey::Project(project_id)) .unwrap_or_else(|| panic!("project not found")); + let old_cq = project.credit_quality; + if old_cq == credit_quality { + return; + } + let old_rate = compute_rate(old_cq, project.green_impact); project.credit_quality = credit_quality; + let new_rate = compute_rate(credit_quality, project.green_impact); env.storage() .persistent() .set(&DataKey::Project(project_id), &project); events::credit_quality_updated(&env, project_id, credit_quality); + events::score_changed( + &env, + project_id, + old_cq, + credit_quality, + project.green_impact, + project.green_impact, + old_rate, + new_rate, + ); } /// Return a proposal by ID. diff --git a/project_registry/src/test.rs b/project_registry/src/test.rs index 15f3ba1..977aa67 100644 --- a/project_registry/src/test.rs +++ b/project_registry/src/test.rs @@ -237,6 +237,43 @@ fn test_update_credit_quality_independent_of_green_impact() { assert_eq!(project.green_impact, 80); // unchanged } +#[test] +fn test_credit_quality_score_changes_rate_correctly() { + let (env, _admin, _whitelister, client) = setup(); + let creator = Address::generate(&env); + client.set_whitelist(&creator, &true); + let id = client.create_project(&creator, &String::from_str(&env, "ipfs://Qm"), &0u64); + + // Set baseline: credit_quality=60, green_impact=40 → rate = avg(60,40)=50, + // discount=50*500/100=250, rate=1000-250=750 + client.update_impact_score(&id, &60u32, &40u32); + assert_eq!(client.get_interest_rate(&id), 750u32); + + // Update only credit_quality: 60 → 85 → new avg(85,40)=62, + // discount=62*500/100=310, rate=1000-310=690 + client.update_credit_quality_score(&id, &85u32); + assert_eq!(client.get_interest_rate(&id), 690u32); + // green_impact unchanged + assert_eq!(client.get_project(&id).green_impact, 40u32); +} + +#[test] +fn test_update_credit_quality_score_noop_identical_values() { + let (env, _admin, _whitelister, client) = setup(); + let creator = Address::generate(&env); + client.set_whitelist(&creator, &true); + let id = client.create_project(&creator, &String::from_str(&env, "ipfs://Qm"), &0u64); + client.update_credit_quality_score(&id, &75u32); + let project_before = client.get_project(&id); + + // Second call with identical score should be a no-op + client.update_credit_quality_score(&id, &75u32); + + let project_after = client.get_project(&id); + assert_eq!(project_before.credit_quality, project_after.credit_quality); + assert_eq!(project_before.green_impact, project_after.green_impact); +} + // ── URI length edge cases (#119) ────────────────────────────────────────────── #[test] @@ -438,15 +475,52 @@ fn test_update_impact_score_emits_event() { client.update_impact_score(&id, &80u32, &60u32); let events = env.events().all(); - // update_impact_score emits ProjectUpdated + RateUpdated = 2 events + // update_impact_score emits ProjectUpdated + RateUpdated + ScoreChanged = 3 events assert!( - events.len() >= count_before + 2, - "update_impact_score should emit at least two events" + events.len() >= count_before + 3, + "update_impact_score should emit at least three events" ); let (contract_id, _topics, _data) = &events[events.len() - 1]; assert_eq!(*contract_id, client.address); } +#[test] +fn test_score_changed_event_contains_old_and_new_values() { + let (env, _admin, _whitelister, client) = setup(); + let creator = Address::generate(&env); + client.set_whitelist(&creator, &true); + let id = client.create_project(&creator, &String::from_str(&env, "ipfs://Qm"), &0u64); + // Initial scores are 0, 0 → rate = 1000 + + client.update_impact_score(&id, &80u32, &60u32); + + let events = env.events().all(); + // Last event should be ScoreChanged + let (_contract_id, topics, data) = &events[events.len() - 1]; + // Topics: [Symbol("ScoreChanged"), project_id (u32)] + assert!( + topics.len() >= 2, + "ScoreChanged should have at least 2 topics" + ); + // Data: old_cq, new_cq, old_gi, new_gi, old_rate, new_rate + // All u32 — decode from ScVal + let vals: Vec = data + .clone() + .try_into_val::>(&env) + .unwrap() + .iter() + .collect(); + assert_eq!(vals.len(), 6, "ScoreChanged data should have 6 fields"); + // old_cq=0, new_cq=80, old_gi=0, new_gi=60, old_rate=1000, new_rate=650 + let expected_rate = 650u32; // avg = (80+60)/2 = 70, discount = 70*500/100 = 350, rate = 1000-350 = 650 + assert_eq!(vals[0], 0, "old_credit_quality should be 0"); + assert_eq!(vals[1], 80, "new_credit_quality should be 80"); + assert_eq!(vals[2], 0, "old_green_impact should be 0"); + assert_eq!(vals[3], 60, "new_green_impact should be 60"); + assert_eq!(vals[4], 1000, "old_rate_bps should be 1000"); + assert_eq!(vals[5], expected_rate, "new_rate_bps should match computed rate"); +} + #[test] fn test_certify_project_emits_event() { let (env, _admin, whitelister, client) = setup();