The off-chain orchestration layer — manages users, ingests sensor data, schedules oracle submissions, and serves the Angular frontend.
- Overview
- Architecture
- Module Reference
- Database Schema
- API Reference
- Queue Architecture
- Stellar Integration
- WebSocket Events
- Authentication Flow
- Environment Configuration
- Deployment Guide
- Testing Strategy
- Monitoring & Observability
- Security
- Roadmap
- Contributing
- Contact
- License
The backend handles everything off-chain that the Soroban contracts don't need to worry about. It acts as:
- API Gateway — REST endpoints for the Angular frontend
- Data Pipeline — Receives sensor readings, validates them, queues them for on-chain submission
- User Manager — Authentication via Stellar wallets, role-based access control
- Orchestrator — Coordinates the credit lifecycle across multiple Soroban contracts
- Indexer — Listens to on-chain events and stores indexed data in PostgreSQL
- Scheduler — Cron jobs for oracle submissions, certificate generation, data aggregation
- ❌ Not a blockchain node — It talks to Stellar via RPC, it doesn't validate blocks
- ❌ Not a wallet — User private keys never touch this server
- ❌ Not a data source of truth — Final settlement happens on Soroban; this is a cache/indexer
┌────────────────────────────────────────────────────────────────────────┐
│ NestJS Application │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Middleware Pipeline │ │
│ │ Helmet │ CORS │ Rate Limiter │ Request Logger │ Auth Guard │ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Controller Layer │ │
│ │ ┌───────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────┐ ┌──────┐ │ │
│ │ │ Auth │ │ Users │ │Project │ │ Sensor │ │Credit│ │Oracle│ │ │
│ │ │ Ctrl │ │ Ctrl │ │ Ctrl │ │ Ctrl │ │ Ctrl │ │ Ctrl │ │ │
│ │ └───────┘ └────────┘ └────────┘ └────────┘ └──────┘ └──────┘ │ │
│ │ ┌────────┐ ┌────────┐ ┌─────────────────────────────┐ │ │
│ │ │Govern. │ │Analyt. │ │ WebSocket Gateway (sensors) │ │ │
│ │ │ Ctrl │ │ Ctrl │ │ │ │ │
│ │ └────────┘ └────────┘ └─────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Service Layer │ │
│ │ ┌────────┐ ┌────────┐ ┌────────────────┐ ┌─────────────────┐ │ │
│ │ │ Auth │ │ Project│ │ Sensor │ │ Credit │ │ │
│ │ │ Service│ │ Service│ │ Service │ │ Service │ │ │
│ │ └────────┘ └────────┘ └────────────────┘ └─────────────────┘ │ │
│ │ ┌────────┐ ┌────────┐ ┌────────────────┐ ┌─────────────────┐ │ │
│ │ │ Oracle │ │Govern. │ │ Stellar │ │ Notification │ │ │
│ │ │ Service│ │Service │ │ Service │ │ Service │ │ │
│ │ └────────┘ └────────┘ └────────────────┘ └─────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Integration Layer │ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ │
│ │ │ TypeORM │ │ Bull Queue │ │ Stellar SDK (Soroban) │ │ │
│ │ │ (PostgreSQL)│ │ (Redis) │ │ (RPC + event streaming) │ │ │
│ │ └─────────────┘ └──────────────┘ └──────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
src/
├── main.ts # Application bootstrap
├── app.module.ts # Root module imports
│
├── common/ # Cross-cutting concerns
│ ├── decorators/
│ │ ├── current-user.decorator.ts # @CurrentUser() parameter decorator
│ │ ├── roles.decorator.ts # @Roles('admin', 'farmer')
│ │ └── public.decorator.ts # @Public() skip auth
│ ├── filters/
│ │ └── all-exceptions.filter.ts # Global exception formatting
│ ├── guards/
│ │ ├── jwt-auth.guard.ts # JWT validation
│ │ └── roles.guard.ts # Role-based access
│ ├── interceptors/
│ │ ├── logging.interceptor.ts # Request/response logging
│ │ └── transform.interceptor.ts # Response envelope (data, meta)
│ ├── pipes/
│ │ └── validation.pipe.ts # Class-validator global pipe
│ └── dto/
│ ├── pagination.dto.ts # PaginationParams, PaginatedResult
│ └── api-response.dto.ts # ApiResponse<T>, ApiError
│
├── config/
│ ├── app.config.ts # App configuration (env-based)
│ ├── database.config.ts # TypeORM data source config
│ ├── stellar.config.ts # Stellar network + accounts
│ ├── oracle.config.ts # Oracle node settings
│ ├── jwt.config.ts # JWT secrets and expiry
│ └── queue.config.ts # Bull queue configuration
│
├── modules/
│ ├── auth/
│ │ ├── auth.module.ts
│ │ ├── auth.controller.ts
│ │ ├── auth.service.ts
│ │ ├── strategies/
│ │ │ ├── jwt.strategy.ts # JWT verification
│ │ │ └── stellar-wallet.strategy.ts # Stellar signed-challenge auth
│ │ └── dto/
│ │ ├── challenge.dto.ts
│ │ ├── login.dto.ts
│ │ └── register.dto.ts
│ │
│ ├── users/
│ │ ├── users.module.ts
│ │ ├── users.controller.ts
│ │ ├── users.service.ts
│ │ ├── entities/
│ │ │ └── user.entity.ts
│ │ └── dto/
│ │ ├── create-user.dto.ts
│ │ └── update-user.dto.ts
│ │
│ ├── projects/
│ │ ├── projects.module.ts
│ │ ├── projects.controller.ts
│ │ ├── projects.service.ts
│ │ ├── entities/
│ │ │ ├── project.entity.ts
│ │ │ └── project-document.entity.ts
│ │ └── dto/
│ │ ├── create-project.dto.ts
│ │ ├── update-project.dto.ts
│ │ └── project-filter.dto.ts
│ │
│ ├── sensors/
│ │ ├── sensors.module.ts
│ │ ├── sensors.controller.ts
│ │ ├── sensors.service.ts
│ │ ├── sensors.gateway.ts # WebSocket gateway
│ │ ├── entities/
│ │ │ ├── sensor-reading.entity.ts
│ │ │ └── sensor-device.entity.ts
│ │ └── dto/
│ │ ├── submit-reading.dto.ts
│ │ └── sensor-query.dto.ts
│ │
│ ├── credits/
│ │ ├── credits.module.ts
│ │ ├── credits.controller.ts
│ │ ├── credits.service.ts
│ │ ├── entities/
│ │ │ ├── credit-token.entity.ts
│ │ │ └── retirement.entity.ts
│ │ └── dto/
│ │ ├── retire-credits.dto.ts
│ │ └── credit-query.dto.ts
│ │
│ ├── oracle/
│ │ ├── oracle.module.ts
│ │ ├── oracle.controller.ts
│ │ ├── oracle.service.ts
│ │ ├── oracle.processor.ts # Bull queue processor
│ │ ├── entities/
│ │ │ └── oracle-submission.entity.ts
│ │ └── dto/
│ │ └── submit-reading.dto.ts
│ │
│ ├── governance/
│ │ ├── governance.module.ts
│ │ ├── governance.controller.ts
│ │ ├── governance.service.ts
│ │ ├── entities/
│ │ │ └── proposal.entity.ts
│ │ └── dto/
│ │ ├── create-proposal.dto.ts
│ │ └── vote.dto.ts
│ │
│ ├── analytics/
│ │ ├── analytics.module.ts
│ │ ├── analytics.controller.ts
│ │ └── analytics.service.ts
│ │
│ └── notifications/
│ ├── notifications.module.ts
│ ├── notifications.service.ts
│ └── notifications.gateway.ts
│
├── stellar/
│ ├── stellar.module.ts
│ ├── stellar.service.ts # High-level contract interactions
│ ├── stellar.client.ts # Low-level Soroban RPC wrapper
│ ├── stellar.types.ts # Contract ID type definitions
│ └── interfaces/
│ ├── credit-token.interface.ts # Typed ABI for credit_token
│ ├── factory.interface.ts # Typed ABI for credit_factory
│ ├── oracle.interface.ts # Typed ABI for verification_oracle
│ └── retirement.interface.ts # Typed ABI for retirement_registry
│
├── database/
│ ├── database.module.ts
│ ├── typeorm.config.ts
│ ├── entities/ # Re-export barrel
│ └── migrations/
│ ├── 001_create_users.sql
│ ├── 002_create_projects.sql
│ ├── 003_create_sensor_readings.sql
│ ├── 004_create_retirements.sql
│ └── 005_create_oracle_submissions.sql
│
└── scripts/
├── seed.ts # Demo data seeder
├── deploy-contracts.ts # Deploy Soroban contracts from backend
└── simulate-sensors.ts # Generate fake sensor data for testing
Handles user authentication using Stellar wallets — no passwords required.
User Backend Stellar Network
│ │ │
│ POST /auth/challenge │ │
│ { wallet: "GABC...DEF" } │ │
│ ──────────────────────────────▶ │ │
│ │ Generate random challenge │
│ │ Store in cache (5 min TTL) │
│ ◀────────────────────────────── │ │
│ { challenge: "Sign this: ..." } │ │
│ │ │
│ User signs challenge with │ │
│ Freighter wallet extension │ │
│ │ │
│ POST /auth/login │ │
│ { wallet, signature } │ │
│ ──────────────────────────────▶ │ │
│ │ Verify signature via │
│ │ Stellar SDK │
│ │ ────────────────────────────────▶│
│ │ ◀────────────────────────────────│
│ │ { verified: true } │
│ │ │
│ │ Issue JWT │
│ ◀────────────────────────────── │ │
│ { token, user, expiresIn } │ │
│ │ │
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/auth/challenge |
Public | Request a Stellar challenge message |
POST |
/auth/login |
Public | Verify signed challenge, return JWT |
POST |
/auth/register |
Public | Create user account (newcomers without wallet) |
POST |
/auth/refresh |
Bearer | Refresh JWT before expiry |
POST |
/auth/logout |
Bearer | Invalidate current session |
interface JwtPayload {
sub: string; // User UUID
wallet: string; // Stellar public key
role: UserRole; // "admin" | "developer" | "farmer" | "buyer" | "oracle"
iat: number;
exp: number;
}User profile and role management.
| Method | Path | Auth | Role | Description |
|---|---|---|---|---|
GET |
/users/me |
Bearer | Any | Current user profile |
PATCH |
/users/me |
Bearer | Any | Update profile (name, avatar, settings) |
GET |
/users |
Bearer | Admin | List all users (paginated) |
GET |
/users/:id |
Bearer | Admin | Get user by ID |
PATCH |
/users/:id/role |
Bearer | Admin | Change user role |
PATCH |
/users/:id/kyc |
Bearer | Admin | Update KYC status |
DELETE |
/users/:id |
Bearer | Admin | Soft-delete user |
// entities/user.entity.ts
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
wallet: string; // Stellar public key (G...)
@Column({ nullable: true })
email: string;
@Column({ nullable: true })
displayName: string;
@Column({
type: 'enum',
enum: ['admin', 'developer', 'farmer', 'buyer', 'oracle'],
default: 'buyer',
})
role: UserRole;
@Column({ default: false })
isKycVerified: boolean;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@OneToMany(() => Project, (project) => project.owner)
projects: Project[];
@OneToMany(() => Retirement, (retirement) => retirement.retiree)
retirements: Retirement[];
}Manage watershed restoration projects from registration through completion.
| Method | Path | Auth | Role | Description |
|---|---|---|---|---|
GET |
/projects |
Bearer | Any | List projects (filterable) |
GET |
/projects/:id |
Bearer | Any | Project detail with sensor stats |
POST |
/projects |
Bearer | Developer, Admin | Create new project |
PATCH |
/projects/:id |
Bearer | Developer, Admin | Update project metadata |
DELETE |
/projects/:id |
Bearer | Admin | Soft-delete project |
POST |
/projects/:id/submit |
Bearer | Developer | Submit for verification |
GET |
/projects/:id/documents |
Bearer | Any | List project documents |
POST |
/projects/:id/documents |
Bearer | Developer | Upload document |
GET |
/projects/:id/credits |
Bearer | Any | Credit summary for project |
GET |
/projects/:id/sensors |
Bearer | Any | Sensor devices for project |
DRAFT ──▶ REGISTERED ──▶ BASELINE ──▶ ACTIVE ──▶ COMPLETED ──▶ CLOSED
│ │ │ │
│ ▼ ▼ ▼
│ Collecting Credits All credits
│ baseline being retired or
│ data (30d) minted expired
▼
REJECTED
| Parameter | Type | Example | Description |
|---|---|---|---|
status |
string | active |
Filter by status |
methodology |
string | Wetland_Restoration_v2 |
Filter by methodology |
owner |
string | GABC... |
Filter by owner wallet |
lat |
number | 38.8977 |
Center latitude (for radius search) |
lon |
number | -77.0365 |
Center longitude |
radius |
number | 50 |
Search radius in km |
search |
string | Green Valley |
Text search in name/description |
page |
number | 1 |
Page number |
limit |
number | 20 |
Items per page |
sort |
string | createdAt:desc |
Sort field and direction |
Ingest, validate, and serve real-time sensor data from IoT devices deployed at project sites.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/sensors/reading |
API Key | Submit a sensor reading (sensor → backend) |
GET |
/sensors/devices |
Bearer | List sensor devices for current user |
POST |
/sensors/devices |
Bearer | Register a new sensor device |
GET |
/sensors/devices/:id |
Bearer | Get device details |
GET |
/sensors/projects/:projectId/readings |
Bearer | Reading history for a project |
GET |
/sensors/projects/:projectId/latest |
Bearer | Most recent reading |
GET |
/sensors/projects/:projectId/summary |
Bearer | Aggregated stats (daily/weekly/monthly) |
WS |
/sensors/live |
Bearer (query) | Real-time sensor feed |
{
"deviceId": "sensor-gv-001",
"projectId": "proj_abc123",
"timestamp": 1700000000,
"readings": {
"ph": 7.2,
"turbidity_ntu": 12.4,
"dissolved_oxygen_mgl": 6.8,
"flow_rate_cms": 1.834,
"total_nitrogen_mgl": 2.45,
"total_phosphorus_mgl": 0.125,
"temperature_c": 18.5
},
"signature": "0xabc123def456..."
}{
"deviceId": "sensor-gv-001",
"projectId": "proj_abc123",
"manufacturer": "YSI",
"model": "ProDSS",
"location": {
"lat": 38.8977,
"lon": -77.0365
},
"parameters": ["ph", "turbidity", "do", "temp"],
"publicKey": "0x04a1b2..." // ECDSA public key for signature verification
}The SensorsGateway uses Socket.IO to broadcast real-time sensor readings to subscribed clients.
Client connection:
import { io } from 'socket.io-client';
const socket = io('wss://api.water-credits.io', {
auth: { token: 'Bearer <JWT>' },
});
// Subscribe to a specific project
socket.emit('subscribe:project', { projectId: 'proj_abc123' });
// Listen for new readings
socket.on('sensor:reading', (data) => {
console.log('New reading:', data);
});
// Listen for alerts
socket.on('sensor:alert', (data) => {
console.warn('Threshold breach:', data);
});
// Unsubscribe
socket.emit('unsubscribe:project', { projectId: 'proj_abc123' });Query credit balances, initiate retirements, and generate certificates.
| Method | Path | Auth | Role | Description |
|---|---|---|---|---|
GET |
/credits |
Bearer | Any | Global credit overview |
GET |
/credits/projects/:projectId |
Bearer | Any | Credit detail for a project |
GET |
/credits/portfolio |
Bearer | Any | Current user's credit holdings |
POST |
/credits/retire |
Bearer | Any | Initiate credit retirement |
GET |
/credits/retirements |
Bearer | Any | Retirement history |
GET |
/credits/retirements/:id |
Bearer | Any | Retirement detail |
GET |
/credits/retirements/:id/certificate |
Bearer | Any | Download certificate PDF |
{
"projectId": "proj_abc123",
"amount": 50000,
"purpose": "compliance",
"metadataUri": "ipfs://QmXK...",
"notes": "FY2025 EPA compliance retirement"
}{
"id": "ret_001",
"status": "confirmed",
"txHash": "a1b2c3d4e5f6...",
"blockNumber": 12345678,
"certificate": {
"id": "WQC-2025-001-0042",
"url": "https://api.water-credits.io/credits/retirements/ret_001/certificate",
"ipfsUri": "ipfs://QmCert..."
},
"timestamp": 1700000000
}Manages the off-chain oracle node infrastructure that submits sensor data to Soroban.
| Method | Path | Auth | Role | Description |
|---|---|---|---|---|
GET |
/oracle/status |
Bearer | Oracle, Admin | Oracle node health |
GET |
/oracle/submissions |
Bearer | Oracle, Admin | Submission history |
GET |
/oracle/submissions/:id |
Bearer | Oracle, Admin | Submission detail |
GET |
/oracle/pending |
Bearer | Oracle, Admin | Pending readings waiting for submission |
POST |
/oracle/trigger |
Bearer | Oracle, Admin | Manually trigger submission cycle |
GET |
/oracle/contract-config |
Bearer | Oracle, Admin | Current on-chain oracle config |
1. Sensor readings arrive → stored in PostgreSQL sensor_readings table
2. Cron job runs every hour → collects readings from last hour
3. Aggregates into time-window buckets (median per parameter)
4. Validates against physical thresholds
5. Calls verification_oracle.submit_reading() on Soroban
6. Stores submission result in oracle_submissions table
7. Updates sensor_reading batch status
8. Emits WebSocket event on completion
// oracle.processor.ts
@Processor('oracle-submission')
export class OracleProcessor {
@Process('submit-batch')
async handleBatchSubmission(job: Job<BatchJob>) {
const { projectId, readings, nonce } = job.data;
// Build Soroban transaction
const tx = await this.stellarService.buildOracleSubmission(
projectId,
readings,
nonce,
);
// Sign and submit
const result = await this.stellarService.submitTransaction(tx);
// Store result
await this.oracleService.recordSubmission(projectId, result);
// Update nonce
await this.oracleService.incrementNonce(projectId);
}
}Interface for on-chain governance — proposals, voting, and protocol configuration.
| Method | Path | Auth | Role | Description |
|---|---|---|---|---|
GET |
/governance/config |
Bearer | Any | Current protocol parameters |
POST |
/governance/config |
Bearer | Admin | Update config (off-chain cache) |
GET |
/governance/proposals |
Bearer | Any | List proposals |
POST |
/governance/proposals |
Bearer | Any | Create proposal |
GET |
/governance/proposals/:id |
Bearer | Any | Proposal detail |
POST |
/governance/proposals/:id/vote |
Bearer | Any | Vote on proposal |
POST |
/governance/proposals/:id/execute |
Bearer | Admin | Execute approved proposal |
GET |
/governance/multisig |
Bearer | Admin | List multisig members |
POST |
/governance/multisig |
Bearer | Admin | Add/remove multisig member |
Aggregated data for the frontend dashboard widgets.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/analytics/overview |
Bearer | Total projects, credits minted, retired, active users |
GET |
/analytics/credits-over-time |
Bearer | Monthly mint/retire volumes |
GET |
/analytics/project-distribution |
Bearer | Projects by methodology, status, geography |
GET |
/analytics/retirement-by-purpose |
Bearer | Compliance vs. voluntary breakdown |
GET |
/analytics/top-projects |
Bearer | Highest credit-generating projects |
GET |
/analytics/top-retirees |
Bearer | Most active credit buyers |
In-app and email notifications for important events.
| Event | Channel | Message |
|---|---|---|
| Credit minted | In-app | "500 credits minted for Green Valley Wetland" |
| Credit retired | In-app | "Your retirement of 1,000 credits is confirmed" |
| Sensor alert | In-app + Email | " |
| Project status change | In-app | "Green Valley Wetland is now ACTIVE" |
| Oracle missed submission | "Oracle node missed 3 consecutive submissions" | |
| Proposal created | In-app | "New governance proposal: Update oracle fees" |
| Proposal passed | In-app | "Proposal #42 passed! Changes will take effect in 7 days" |
┌─────────────────┐ ┌─────────────────────┐ ┌────────────────────┐
│ users │ │ projects │ │ sensor_devices │
├─────────────────┤ ├─────────────────────┤ ├────────────────────┤
│ id (PK, UUID) │───1:N─│ id (PK, UUID) │───1:N─│ id (PK, UUID) │
│ wallet (unique) │ │ owner_id (FK) │ │ project_id (FK) │
│ email │ │ name │ │ device_id (unique) │
│ display_name │ │ description │ │ manufacturer │
│ role (enum) │ │ latitude │ │ model │
│ is_kyc_verified │ │ longitude │ │ parameters (jsonb) │
│ is_active │ │ methodology │ │ public_key │
│ created_at │ │ status (enum) │ │ last_reading_at │
│ updated_at │ │ area_hectares │ │ created_at │
└─────────────────┘ │ credit_token_address │ └────────────────────┘
│ contract_id │ │
│ baseline_started_at │ │
│ baseline_ended_at │ │ 1:N
│ created_at │ │
│ updated_at │ ▼
└─────────────────────┘ ┌────────────────────┐
│ │ sensor_readings │
│ 1:N ├────────────────────┤
│ │ id (PK, UUID) │
▼ │ device_id (FK) │
┌─────────────────────┐ │ project_id (FK) │
│ project_documents │ │ timestamp │
├─────────────────────┤ │ ph (numeric) │
│ id (PK, UUID) │ │ turbidity_ntu │
│ project_id (FK) │ │ dissolved_oxygen │
│ document_type (enum) │ │ flow_rate_cms │
│ filename │ │ total_nitrogen │
│ ipfs_uri │ │ total_phosphorus │
│ uploaded_at │ │ temperature_c │
└─────────────────────┘ │ signature (text) │
│ is_verified (bool) │
┌─────────────────────┐ ┌─────────────────────┐ │ batch_id (FK) │
│ retirements │ │ oracle_submissions │ │ created_at │
├─────────────────────┤ ├─────────────────────┤ └────────────────────┘
│ id (PK, UUID) │ │ id (PK, UUID) │ │
│ user_id (FK) │ │ project_id (FK) │ │
│ project_id (FK) │ │ oracle_address │ │ 1:N
│ amount (numeric) │ │ nonce │ │
│ purpose (string) │ │ tx_hash │ ▼
│ metadata_uri │ │ status (enum) │ ┌────────────────────┐
│ tx_hash │ │ readings_snapshot │ │ reading_batches │
│ certificate_ipfs_uri │ │ result (jsonb) │ ├────────────────────┤
│ retired_at │ │ submitted_at │ │ id (PK, UUID) │
│ created_at │ │ confirmed_at │ │ project_id (FK) │
└─────────────────────┘ └─────────────────────┘ │ status (enum) │
│ reading_count │
┌─────────────────────┐ ┌─────────────────────┐ │ credits_generated │
│ proposals │ │ governance_config │ │ submitted_at │
├─────────────────────┤ ├─────────────────────┤ │ confirmed_at │
│ id (PK, UUID) │ │ id (PK, UUID) │ └────────────────────┘
│ proposer_id (FK) │ │ protocol_fee_bps │
│ title │ │ min_oracles │
│ description │ │ ph_min / ph_max │
│ action_type │ │ do_threshold │
│ action_params (jsonb)│ │ temp_penalty_delta │
│ votes_for │ │ weight_volumetric │
│ votes_against │ │ weight_nitrogen │
│ status (enum) │ │ weight_phosphorus │
│ deadline │ │ updated_by │
│ executed_at │ │ updated_at │
│ created_at │ └─────────────────────┘
└─────────────────────┘
All API responses follow a consistent envelope:
// Success
{
"success": true,
"data": { ... },
"meta": {
"page": 1,
"limit": 20,
"total": 100,
"totalPages": 5
}
}
// Error
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input data",
"details": [
{ "field": "amount", "message": "must be a positive integer" }
]
}
}| Endpoint Group | Rate Limit | Burst |
|---|---|---|
| Public (challenge, register) | 10/min | 20 |
| Authenticated (general) | 60/min | 120 |
| Sensor ingestion | 1000/min | 2000 |
| Oracle submissions | 30/min | 60 |
| Admin endpoints | 20/min | 40 |
| Code | Meaning |
|---|---|
200 |
Success |
201 |
Created |
400 |
Bad request (validation error) |
401 |
Unauthorized (missing/invalid JWT) |
403 |
Forbidden (wrong role) |
404 |
Not found |
409 |
Conflict (duplicate) |
422 |
Unprocessable (business logic error) |
429 |
Too many requests |
500 |
Internal server error |
┌─────────────────┐
│ Redis │
│ (Bull backend) │
└────────┬────────┘
│
┌────────────────┼────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ sensor-ingestion │ │ oracle-submit │ │ retirements │
│ │ │ │ │ │
│ Ingest raw │ │ Aggregate and │ │ Sign and submit │
│ readings from │ │ submit verified │ │ retirement │
│ sensors │ │ readings to │ │ transactions to │
│ Validate and │ │ Soroban │ │ Soroban │
│ store │ │ │ │ Generate certs │
└─────────────────┘ └─────────────────┘ └─────────────────┘
| Property | Value |
|---|---|
| Concurrency | 5 |
| Retry attempts | 3 |
| Backoff | Exponential (1s, 5s, 30s) |
| TTL | 1 hour |
Job data:
interface SensorIngestionJob {
deviceId: string;
projectId: string;
readings: SensorReadingDto;
receivedAt: number; // Unix timestamp
}| Property | Value |
|---|---|
| Concurrency | 1 (nonce ordering) |
| Retry attempts | 5 |
| Backoff | Exponential (10s, 60s, 300s) |
| Repeat | Cron: 0 * * * * (every hour) |
| TTL | 24 hours |
Job data:
interface OracleSubmissionJob {
batchId: string;
projectId: string;
aggregatedReadings: AggregatedReading;
nonce: number;
}| Property | Value |
|---|---|
| Concurrency | 2 |
| Retry attempts | 3 |
| Backoff | Fixed (30s) |
| TTL | 6 hours |
Job data:
interface RetirementJob {
retirementId: string;
userId: string;
projectId: string;
amount: number;
purpose: string;
metadataUri: string;
}The stellar.service.ts is the core integration point with the Stellar network.
@Injectable()
export class StellarService {
constructor(
private readonly config: ConfigService,
private readonly stellarClient: StellarClient,
) {}
// ── Authentication ──
async generateChallenge(wallet: string): Promise<string>;
async verifySignature(wallet: string, signature: string, challenge: string): Promise<boolean>;
// ── Contract Interactions ──
async getCreditBalance(tokenId: string, address: string): Promise<BigNumber>;
async getTotalSupply(tokenId: string): Promise<BigNumber>;
async getTotalRetired(tokenId: string): Promise<BigNumber>;
async mintCredits(tokenId: string, to: string, amount: BigNumber): Promise<TransactionResult>;
async retireCredits(tokenId: string, holder: string, amount: BigNumber, purpose: string, metadataUri: string): Promise<RetirementResult>;
async submitOracleReading(contractId: string, projectId: string, reading: SensorReading, nonce: number): Promise<TransactionResult>;
async getProtocolConfig(): Promise<GovernanceConfig>;
async createProposal(proposer: string, description: string, action: string, params: string[]): Promise<string>;
async voteOnProposal(voter: string, proposalId: string, support: boolean): Promise<void>;
// ── Network ──
async getAccount(address: string): Promise<Account>;
async getNetwork(): Promise<{ passphrase: string; rpcUrl: string }>;
async estimateFee(tx: Transaction): Promise<BigNumber>;
async submitTransaction(tx: Transaction): Promise<TransactionResult>;
// ── Events ──
async getEvents(filter: EventFilter): Promise<SorobanEvent[]>;
async streamEvents(filter: EventFilter, callback: (event: SorobanEvent) => void): Promise<void>;
}Low-level wrapper around @stellar/stellar-sdk:
@Injectable()
export class StellarClient {
private server: SorobanServer;
private keypair: Keypair;
constructor(config: StellarConfig) {
this.server = new SorobanServer(config.rpcUrl);
this.keypair = Keypair.fromSecret(config.backendSecret);
}
async getContractData(contractId: string, key: ScVal): Promise<ScVal>;
async simulateTx(tx: Transaction): Promise<SimulateResult>;
async prepareTx(tx: Transaction): Promise<Transaction>;
async sendTx(tx: Transaction): Promise<TransactionResult>;
async getLedgerEntries(keys: LedgerKey[]): Promise<LedgerEntry[]>;
}| Event | Payload | When |
|---|---|---|
sensor:reading |
{ projectId, deviceId, parameter, value, unit, timestamp } |
New sensor reading received |
sensor:alert |
{ projectId, deviceId, parameter, value, threshold, direction } |
Parameter outside acceptable range |
sensor:device-status |
{ deviceId, status, lastSeen } |
Device online/offline |
credit:minted |
{ projectId, amount, beneficiary, txHash } |
Credits minted on chain |
credit:retired |
{ projectId, amount, retiree, purpose, txHash } |
Credits retired on chain |
credit:transferred |
{ from, to, amount, projectId } |
Credits transferred between wallets |
oracle:status |
{ oracleId, status, lastSubmission, missedCount } |
Oracle node health change |
oracle:submitted |
{ projectId, nonce, creditsGenerated, txHash } |
Oracle submission confirmed |
governance:proposal |
{ proposalId, title, status, deadline } |
Proposal created/updated |
governance:vote |
{ proposalId, voter, support } |
New vote cast |
notification |
{ id, type, title, message, actionUrl } |
User notification |
| Event | Payload | Description |
|---|---|---|
subscribe:project |
{ projectId } |
Subscribe to all events for a project |
unsubscribe:project |
{ projectId } |
Unsubscribe from project events |
subscribe:user |
{ userId } |
Subscribe to user-specific notifications |
unsubscribe:user |
{ userId } |
Unsubscribe from user notifications |
subscribe:admin |
{} |
Subscribe to admin events (admin only) |
1. Client: POST /auth/challenge { wallet: "GABC...DEF" }
2. Server: Generate random challenge string
Store in Redis with 5-min TTL
Return { challenge: "Sign this message to authenticate..." }
3. Client: User signs challenge with Freighter wallet
4. Client: POST /auth/login { wallet, signature }
5. Server: Verify signature using Stellar SDK
- Recover public key from signature
- Check recovered key === wallet
- Delete challenge from Redis (one-time use)
6. Server: Find or create User by wallet address
Generate JWT with { sub, wallet, role, iat, exp }
7. Server: Return { token, user, expiresIn }
1. Client: Attach Authorization: Bearer <JWT> to all requests
2. Server: JwtAuthGuard extracts and validates JWT
3. Server: JwtStrategy.validate() loads user from DB
4. Server: Attach User to request object
5. Server: RolesGuard checks user.role against required roles
Sensor devices authenticate via pre-shared API keys:
Header: X-API-Key: wc_sensor_abc123def456
API keys are stored hashed (bcrypt) in the database, associated with a device.
# ── Application ──
NODE_ENV=development # development | production | test
PORT=3000 # API server port
API_PREFIX=/api/v1 # Global API prefix
CORS_ORIGIN=http://localhost:4200 # Frontend origin
# ── Database (PostgreSQL) ──
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=water_credits
DATABASE_USER=postgres
DATABASE_PASSWORD=postgres
DATABASE_SSL=false # Enable in production
DATABASE_LOGGING=false # Enable for debugging
# ── Redis (Bull Queues + Cache) ──
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# ── Stellar Network ──
STELLAR_NETWORK=testnet # testnet | public
STELLAR_RPC_URL=https://soroban-testnet.stellar.org
STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
# ── Stellar Backend Account ──
STELLAR_BACKEND_SECRET=SXXX...YYY # Backend Stellar account secret
STELLAR_BACKEND_PUBLIC=GABC...DEF # Backend Stellar account public
# ── Contract Addresses (deployed on Stellar) ──
CONTRACT_CREDIT_FACTORY=C...
CONTRACT_VERIFICATION_ORACLE=C...
CONTRACT_RETIREMENT_REGISTRY=C...
CONTRACT_GOVERNANCE=C...
# ── JWT ──
JWT_SECRET=<random-64-byte-hex-string>
JWT_EXPIRATION=1h # Access token TTL
JWT_REFRESH_EXPIRATION=7d # Refresh token TTL
# ── Oracle ──
ORACLE_ADDRESS=G... # Oracle Stellar wallet
ORACLE_SECRET=S... # Oracle Stellar secret
ORACLE_SUBMISSION_INTERVAL=3600000 # ms (default: 1 hour)
ORACLE_MIN_CONFIRMATIONS=2 # Min oracle confirmations
# ── Queue ──
QUEUE_SENSOR_CONCURRENCY=5
QUEUE_ORACLE_CONCURRENCY=1
QUEUE_RETIREMENT_CONCURRENCY=2
# ── Rate Limiting ──
THROTTLE_TTL=60000 # Window (ms)
THROTTLE_LIMIT=60 # Max requests per window
# ── Logging ──
LOG_LEVEL=debug # error | warn | info | debug
LOG_FORMAT=json # json | text
# ── Monitoring ──
SENTRY_DSN= # Sentry DSN (optional)
PROMETHEUS_ENABLED=true # Enable /metrics endpoint
# ── External APIs ──
IPFS_API_URL=https://ipfs.infura.io:5001
IPFS_PROJECT_ID=
IPFS_PROJECT_SECRET=# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/.env ./
EXPOSE 3000
CMD ["node", "dist/main"]# docker-compose.yml
version: "3.8"
services:
backend:
build: .
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
DATABASE_HOST: postgres
REDIS_HOST: redis
env_file: .env
postgres:
image: postgres:15-alpine
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
environment:
POSTGRES_DB: water_credits
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
volumes:
pgdata:See k8s/ directory for manifests:
deployment.yaml— Backend deployment (3 replicas)service.yaml— ClusterIP serviceingress.yaml— Ingress with TLShpa.yaml— Horizontal pod autoscalerconfigmap.yaml— Non-sensitive configsecret.yaml— Sensitive config (use SealedSecrets or External Secrets Operator)
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: water_credits_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run migration:run
- run: npm test
- run: npm run test:e2e
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t water-credits-backend .
- run: docker push ${{ secrets.REGISTRY }}/water-credits-backend
- run: kubectl set image deployment/backend backend=${{ secrets.REGISTRY }}/water-credits-backend ╱╲
╱ E2E ╲ ← 5% — Full system tests (Supertest)
╱───────╲
╱Integration╲ ← 25% — Module interaction tests
╱─────────────╲
╱ Unit Tests ╲ ← 70% — Isolated service tests
╱───────────────────╲
# Unit tests
npm test
# Watch mode
npm run test:watch
# With coverage
npm run test:cov
# E2E tests (requires postgres + redis)
npm run test:e2esrc/
├── modules/
│ ├── auth/
│ │ ├── auth.controller.spec.ts
│ │ ├── auth.service.spec.ts
│ │ └── strategies/
│ │ ├── jwt.strategy.spec.ts
│ │ └── stellar-wallet.strategy.spec.ts
│ ├── projects/
│ │ ├── projects.controller.spec.ts
│ │ └── projects.service.spec.ts
│ ├── sensors/
│ │ ├── sensors.controller.spec.ts
│ │ └── sensors.service.spec.ts
│ └── credits/
│ ├── credits.controller.spec.ts
│ └── credits.service.spec.ts
├── stellar/
│ ├── stellar.service.spec.ts
│ └── stellar.client.spec.ts
└── common/
├── guards/
│ ├── jwt-auth.guard.spec.ts
│ └── roles.guard.spec.ts
└── pipes/
└── validation.pipe.spec.ts
GET /health{
"status": "ok",
"timestamp": 1700000000,
"uptime": 3600,
"checks": {
"database": { "status": "ok", "latency_ms": 2 },
"redis": { "status": "ok", "latency_ms": 1 },
"stellar": { "status": "ok", "latest_ledger": 12345678 },
"queues": {
"sensor-ingestion": { "waiting": 0, "active": 2, "failed": 0 },
"oracle-submit": { "waiting": 1, "active": 0, "failed": 0 },
"retirements": { "waiting": 0, "active": 0, "failed": 0 }
}
}
}Available at GET /metrics:
| Metric | Type | Labels | Description |
|---|---|---|---|
http_requests_total |
Counter | method, path, status | Total HTTP requests |
http_request_duration_ms |
Histogram | method, path | Request latency |
stellar_rpc_calls_total |
Counter | method, contract | Soroban RPC calls |
stellar_rpc_duration_ms |
Histogram | method | RPC latency |
sensor_readings_total |
Counter | project, status | Sensor readings received |
credits_minted_total |
Counter | project | Credits minted |
credits_retired_total |
Counter | project | Credits retired |
queue_depth |
Gauge | queue | Current queue depth |
queue_failed_total |
Counter | queue | Failed jobs |
oracle_submissions_total |
Counter | status | Oracle submissions |
{
"level": "info",
"timestamp": "2025-06-04T12:00:00.000Z",
"context": "StellarService",
"message": "Credits minted successfully",
"data": {
"projectId": "proj_abc123",
"amount": 5000,
"txHash": "a1b2c3...",
"duration_ms": 234
}
}| Area | Implementation |
|---|---|
| Authentication | Stellar wallet challenge-response + JWT |
| Authorization | Role-based guards (@Roles('admin')) |
| Rate limiting | @nestjs/throttler with per-endpoint configuration |
| CORS | Whitelist frontend origin only |
| Helmet | Security headers (CSP, HSTS, X-Frame-Options) |
| Input validation | class-validator DTOs on every endpoint |
| SQL injection | TypeORM parameterized queries |
| Secrets | Environment variables (never hardcoded) |
| HTTPS | TLS termination at ingress level |
| CSRF | SameSite cookies + custom header check |
| Dependency audit | npm audit in CI pipeline |
| Container security | Non-root user in Docker, read-only filesystem |
| API keys | Hashed (bcrypt) in database, rotated regularly |
The core infrastructure is in place. The backend boots, all modules are scaffolded, migrations run, and the CI pipeline is green. The project is in active early development — not yet ready for production use.
| Area | Status |
|---|---|
| Project scaffolding & module structure | ✅ Done |
| Database migrations (users, projects, sensors, oracle, governance) | ✅ Done |
| Auth module (Stellar wallet challenge-response + JWT) | ✅ Done |
| Users module (CRUD, role management) | ✅ Done |
| Projects module (full lifecycle) | ✅ Done |
| Sensors module (ingestion, WebSocket gateway) | ✅ Done |
| Credits module (balance queries, retirement flow) | ✅ Done |
| Oracle module (Bull queue, Soroban submission) | ✅ Done |
| Governance module (proposals, voting) | ✅ Done |
| Analytics module | ✅ Done |
| Notifications module (in-app + email) | ✅ Done |
| Health check & Prometheus metrics | ✅ Done |
| Docker & docker-compose setup | ✅ Done |
| Unit & e2e test coverage | 🔄 In progress |
| Stellar Soroban contract integration (live testnet) | 🔄 In progress |
| IPFS document/certificate upload | 🔄 In progress |
| KYC integration | 📋 Planned |
| Frontend (Angular) integration | 📋 Planned |
| Mainnet deployment hardening | 📋 Planned |
| Audit (security + smart contracts) | 📋 Planned |
- Complete unit test coverage (target 80%)
- Live Soroban testnet integration end-to-end
- Sensor simulator script for local development
- IPFS certificate upload on retirement confirmation
- Angular frontend integration
- KYC provider integration
- Rate limiting and abuse protection hardening
- Performance benchmarks and query optimisation
- Security audit complete
- Mainnet deployment
- Operator runbook and monitoring playbooks
- Public API documentation (OpenAPI / Swagger)
See CONTRIBUTING.md.
git clone https://github.com/water-credits/water-credits-backend
git checkout -b feat/your-feature
# Install
npm install
# Create database
createdb water_credits
# Run migrations
npm run migration:run
# Start
npm run start:dev
# Make changes, then:
npm run lint
npm test
# Commit and push
git commit -m "feat: add endpoint for batch credit issuance"
git push origin feat/your-feature| Channel | Details |
|---|---|
| GitHub Issues | Bug reports and feature requests — open an issue |
| GitHub Discussions | Questions, ideas, and general discussion — start a discussion |
| Telegram | Direct questions and community chat — @Escelit |
| General enquiries — ogazipromise81@gmail.com | |
| Security vulnerabilities | Do not open a public issue. See SECURITY.md for the responsible disclosure process |
For anything sensitive or urgent that can't go through GitHub, reach out to the maintainers directly via the contact details in SECURITY.md.
MIT — see LICENSE.