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
12 changes: 7 additions & 5 deletions CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }` |
Expand Down Expand Up @@ -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.

---

Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down Expand Up @@ -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()` |
Expand Down
1 change: 1 addition & 0 deletions docs/INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
186 changes: 186 additions & 0 deletions docs/NOTIFICATIONS.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions notification-service/.env.example
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions notification-service/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
29 changes: 29 additions & 0 deletions notification-service/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
70 changes: 70 additions & 0 deletions notification-service/src/api.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading