Node.js oracle server for the Heliobond platform. It simulates IoT sensor data for solar panel and satellite readings, computes impact scores from that data, and submits update_impact_score transactions to the Soroban ProjectRegistry contract on Stellar. An hourly cron job keeps on-chain scores current automatically; the same logic is exposed over REST for on-demand updates.
Heliobond is a comprehensive platform for green energy investment tracking and impact scoring. The system consists of multiple components:
- Backend API (this repository): Node.js/TypeScript server that computes impact scores and interacts with the Stellar blockchain
- Frontend: User interface for viewing projects and impact scores
- Blockchain Contracts: Soroban smart contracts for project registry and investment tracking
- Data Processing: Components for handling IoT data and financial calculations
flowchart TD
subgraph Client
A[Browser / External caller]
end
subgraph Express["Express (src/index.ts)"]
H[GET /health]
IOT[iot.ts\nGET /v1/iot/solar/:id\nGET /v1/iot/satellite/:id]
ADMIN[admin.ts\nPOST /v1/admin/update-scores\n— Bearer token required]
CRON[node-cron\nhourly @ :00]
end
subgraph Lib["lib modules"]
SC[scoring.ts\npure computation\ncomputeScores]
ST[stellar.ts\nRPC client\nsignAndSubmit]
REG[registry.ts\ncontract calls\nupdateImpactScore\ngetTotalProjects]
end
subgraph Stellar
RPC[Stellar RPC\nsoroban-testnet.stellar.org]
CONTRACT[Soroban\nProjectRegistry\ncontract]
end
A -->|HTTP| H
A -->|HTTP| IOT
A -->|HTTP + Bearer| ADMIN
IOT -->|read-only sim| SC
ADMIN --> SC
ADMIN --> REG
CRON --> SC
CRON --> REG
REG --> ST
ST --> RPC
RPC --> CONTRACT
Data flow for a score update (admin route or cron):
getSolarData(id)andgetSatelliteData(id)produce deterministic, hourly-seeded sensor readings.computeScores({ solar, satellite })derivescredit_qualityandgreen_impact(pure, no I/O).updateImpactScore(id, cq, gi)inregistry.tsbuilds and prepares a Soroban transaction.signAndSubmit(xdr, keypair)instellar.tssigns, submits, and polls until the transaction is confirmed.
Full request/response details, validation rules, and error codes are in API.md.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/health |
— | Liveness + uptime and last cron run |
GET |
/v1/iot/solar/:id |
— | Simulated solar panel reading for project id |
GET |
/v1/iot/satellite/:id |
— | Simulated satellite / vegetation reading for project id |
GET |
/v1/projects |
— | Paginated list of projects (?page=&pageSize=; limit/cursor aliases) |
GET |
/v1/projects/:id |
— | Nested project detail ({project, detail, verifiedMetadata}); 404 if unknown |
GET |
/v1/portfolio/:address |
— | Indexed deposit/withdraw history for an address |
POST |
/v1/admin/update-scores |
Bearer token | Submit impact score update(s) to the Soroban contract |
POST |
/v1/telemetry |
— | Ingest frontend error reports and Web Vitals (beacon) |
Errors return a consistent { "error": { "code": "<code>", "message": "<detail>" } }
JSON shape (never a stack trace). code is a stable, machine-readable
identifier; message is human-readable detail. All /api/* routes are rate
limited and return 429 with a Retry-After header once the limit is
exceeded.
{
"error": {
"code": "bad_request",
"message": "project id must be a positive integer"
}
}Frontend integration:
NEXT_PUBLIC_API_URLis the versioned base and must include/v1, e.g.http://localhost:3001/v1. The frontend calls${NEXT_PUBLIC_API_URL}/projects, so a base without/v1resolves to the deprecated/apipaths (or404).
{ "status": "ok" }Every :id path parameter is a project ID: a whole number in 1..1000000
inclusive. The upper bound is configurable via MAX_PROJECT_ID.
Anything else is rejected with 400 bad_request before the route runs — floats
(1.5), signed values (-5, +5), exponent notation (1e6), surrounding
whitespace, non-numeric strings, and IDs above the bound.
{
"power_output_kw": 742.15,
"efficiency_pct": 74.21,
"max_power_kw": 1000,
"timestamp": 1718150400000
}Readings are deterministic per (project_id, hour) — the same id returns the same values within a given clock hour.
Because they are deterministic, readings are cached in memory for the remainder
of the clock hour instead of being recomputed per request; timestamp is still
the time of the request. Set IOT_CACHE_DISABLED=true to recompute every time,
and IOT_CACHE_MAX_SIZE to cap retained entries.
{
"forest_density_pct": 68.44,
"ndvi_score": 0.684,
"timestamp": 1718150400000
}Headers: Authorization: Bearer <ADMIN_API_KEY>
Body (optional):
{ "project_ids": [1, 2, 3] }Omit project_ids (or send an empty array) to update every project registered on-chain (fetched via getTotalProjects()).
Response:
{
"updated": 2,
"results": [
{
"project_id": 1,
"tx_hash": "abc123...",
"credit_quality": 74,
"green_impact": 69
}
],
"errors": [
{
"project_id": 4,
"error": { "code": "update_failed", "message": "Soroban RPC timeout" }
}
]
}Soroban does not support multi-call batching; transactions are submitted sequentially.
Ingests frontend error reports and Web Vitals measurements.
The browser sends beacons with navigator.sendBeacon using a text/plain Blob
(JSON string); application/json is accepted as well. No authentication or CSRF
token is required — the route carries no cookies or session, and browser beacons
cannot set custom headers. It is rate limited per client IP.
Request body (one report per request):
kindandcontractErrorNameare bounded metric labels (frontend_errors_total).nameandratinglabel thefrontend_web_vitalhistogram.- A second scrub pass strips anything resembling a Stellar address (
G...), secret seed (S...), contract id (C...), XDR blob, or e-mail address before it can reach a metric, log, or trace. Reports are never persisted.
Responses:
| Status | When |
|---|---|
204 |
Accepted (no body) |
400 |
Payload matches neither schema, or malformed JSON |
413 |
Body exceeds TELEMETRY_BODY_SIZE_LIMIT (default 64kb) |
429 |
Per-IP telemetry rate limit exceeded |
The endpoint is also mounted at the deprecated /api/telemetry path.
Frontend follow-up: the frontend lives in a separate repository. Point its
NEXT_PUBLIC_ERROR_REPORT_URLat<api-base>/v1/telemetry(see.env.example).
Both output values are integers in [0, 100].
credit_quality = clamp(efficiency_pct, 0, 100)
green_impact = clamp(
(power_output_kw / max_power_kw) * 50
+ (forest_density_pct / 100) * 50,
0, 100
)
credit_quality reflects how efficiently the solar array is operating.
green_impact is a 50/50 blend of power production ratio and vegetation health.
All API endpoints are rate-limited to prevent abuse and protect against fee-drain attacks on Soroban transactions.
| Limiter | Default Window | Default Max | Applied To |
|---|---|---|---|
| Public | 60 seconds | 100 requests/IP | All unauthenticated endpoints |
| Admin | 60 seconds | 20 requests/IP | All authenticated admin endpoints |
When the limit is exceeded, the API returns 429 Too Many Requests with:
Retry-Afterheader (seconds until the window resets)RateLimit-Remaining: 0andRateLimit-Resetheaders (RFC 6585 standard)
{
"error": {
"code": "too_many_requests",
"message": "Rate limit exceeded. Please retry later."
}
}Configure via environment variables (see below). Admin limits are stricter because each POST /v1/admin/update-scores call triggers on-chain Soroban transactions that cost XLM.
Create a .env file (see .env.example):
| Variable | Required | Default | Description |
|---|---|---|---|
STELLAR_NETWORK |
No | testnet |
testnet or mainnet — selects the network passphrase |
ADMIN_SECRET_KEY |
Yes | — | Stellar secret key (S...) used to sign transactions |
PROJECT_REGISTRY_CONTRACT_ID |
Yes | — | Soroban contract address for the ProjectRegistry |
RPC_URL |
No | https://soroban-testnet.stellar.org |
Stellar RPC endpoint |
PORT |
No | 3001 |
HTTP port the server listens on |
FRONTEND_URL |
No | http://localhost:3000 |
Origin allowed by CORS |
ADMIN_API_KEY |
No | — | Bearer token for /api/admin/*. If unset, auth is skipped (dev only) |
RATE_LIMIT_WINDOW_MS |
No | 60000 |
Public rate-limit window (ms) |
RATE_LIMIT_MAX |
No | 100 |
Public max requests per IP per window |
RATE_LIMIT_ADMIN_WINDOW_MS |
No | RATE_LIMIT_WINDOW_MS |
Admin rate-limit window (ms) |
RATE_LIMIT_ADMIN_MAX |
No | 20 |
Admin max requests per IP per window |
POLL_INTERVAL_MS |
No | 1500 |
Stellar transaction confirmation polling interval (ms) |
POLL_MAX_ATTEMPTS |
No | 20 |
Max polling attempts before timing out |
TX_TIMEOUT_SECONDS |
No | 30 |
Soroban transaction timeout (seconds) |
MAX_POWER_KW |
No | 1000 |
Maximum simulated solar power output (kW) |
IOT_CACHE_DISABLED |
No | — | true bypasses the in-memory IoT reading cache |
IOT_CACHE_MAX_SIZE |
No | 1000 |
Max cached IoT readings; oldest are evicted first |
MAX_PROJECT_ID |
No | 1000000 |
Inclusive upper bound accepted for a :id project param |
BODY_SIZE_LIMIT |
No | 100kb |
Max request body size accepted by express.json() (e.g. 100kb, 1mb). Requests exceeding it return 413 Payload Too Large |
TELEMETRY_BODY_SIZE_LIMIT |
No | 64kb |
Max body size for POST /v1/telemetry; oversize bodies return 413 |
TELEMETRY_OTLP_ENABLED |
No | false |
true forwards accepted telemetry reports to the OTLP exporter |
TELEMETRY_RATE_LIMIT_WINDOW_MS |
No | RATE_LIMIT_WINDOW_MS |
Per-IP telemetry rate-limit window (ms) |
TELEMETRY_RATE_LIMIT_MAX |
No | 120 |
Max telemetry requests per IP per window |
Prerequisites: Bun (curl -fsSL https://bun.sh/install | bash).
# 1. Install dependencies
bun install
# 2. Configure environment
cp .env.example .env
# Edit .env — ADMIN_SECRET_KEY and PROJECT_REGISTRY_CONTRACT_ID are required to
# start the server; the rest have sensible defaults.
# 3. Development (ts-node + hourly cron + 5-min indexer)
bun run dev # -> Heliobond backend listening on port 3001
# watches src/ and restarts on save
bun run dev:no-watch # same, without file watching
# Verify it's up
curl http://localhost:3001/health
# Production
bun run build && bun start
# Quality gate
bun run build # tsc type-check
bun run test # jest suiteDependency vulnerability checks run in the CI workflow for every pull request. The audit gate uses npm audit --audit-level=high, so CI fails when npm reports any high or critical dependency vulnerabilities. Moderate and low findings are still included in the workflow summary for visibility.
Run the same audit locally before opening a PR:
npm audit --audit-level=highUse npm audit --json if you need machine-readable details while triaging a finding.
Critical packages are pinned to exact versions in package.json to prevent
compromised or buggy minor/patch releases from being silently installed by
bun install. The lockfile (bun.lock) records the resolved versions and is
checked in so installs are reproducible.
The following packages are pinned because they are security-sensitive or directly handle blockchain and HTTP trust boundaries:
@stellar/stellar-sdk— pinned; Stellar transaction signing and submission.express— pinned; HTTP server and request routing.dotenv— pinned; loads secrets and configuration.
Only the packages listed above are pinned; all other dependencies retain their
existing caret ranges. When upgrading a pinned package, change the exact version
in package.json, run bun install, and commit the updated bun.lock. Do not
change a pinned dependency back to a caret range.
Build and run using Docker:
docker build -t heliobond-backend .
docker run -p 3001:3001 --env-file .env heliobond-backendUse the provided docker-compose.yml for local development with all dependencies:
docker-compose upTo run the whole platform locally (Stellar node, contracts, Postgres, backend,
optional frontend) use docker compose --profile local-stack up --build backend-local; see
docs/LOCAL_STACK.md.
For production deployments, consider:
- Using a process manager like PM2 or systemd
- Setting up a reverse proxy (Nginx, Caddy)
- Configuring SSL/TLS certificates
- Implementing proper monitoring and alerting
The application includes OpenTelemetry instrumentation for:
- Distributed Tracing: Track requests across services
- Metrics: Monitor performance and resource usage
- Logging: Structured logging with correlation IDs
Frontend telemetry ingested at POST /v1/telemetry is exposed on the shared
/metrics registry as frontend_errors_total{kind, contract_error_name} and the
frontend_web_vital{name, rating} histogram. Set TELEMETRY_OTLP_ENABLED=true
to additionally forward accepted reports to the configured OTLP exporter.
Configure OpenTelemetry exporters in your environment to send data to your preferred observability platform (Jaeger, Zipkin, Prometheus, etc.).
| Layer | Technology |
|---|---|
| Runtime | Node.js 20 |
| Language | TypeScript |
| HTTP framework | Express 5 |
| Stellar SDK | @stellar/stellar-sdk v15 |
| Scheduler | node-cron v4 |
| Package manager / test runner | Bun |
| Test framework | Jest + ts-jest + Supertest |
| Database | PostgreSQL (via Knex.js) |
| API Documentation | Swagger/OpenAPI |
| Monitoring | OpenTelemetry |
| Containerization | Docker |
| CI/CD | GitHub Actions |
- Use TypeScript strict mode
- Follow ESLint and Prettier configuration
- Write comprehensive tests for new features
- Maintain test coverage above 80%
Run the test suite with:
bun run test # Run all tests
bun run test:coverage # Run tests with coverage report- Use meaningful variable and function names
- Add JSDoc comments for public APIs
- Follow the existing code patterns and architecture
- Keep functions small and focused on single responsibilities
Comprehensive API documentation is available in multiple formats:
After starting the server, visit http://localhost:3001/docs for interactive Swagger UI documentation.
The full OpenAPI specification is available at http://localhost:3001/docs.json.
Detailed API reference with examples and error codes is available in API.md.
Notable changes for each version are recorded in CHANGELOG.md,
which follows Keep a Changelog. Released
sections are generated by semantic-release from Conventional Commits; add
unreleased work under ## [Unreleased] using the template at the bottom of the
file.
Please read CONTRIBUTING.md for details on our code of conduct and the process for submitting pull requests.
For security concerns, please review SECURITY.md and report vulnerabilities through the appropriate channels.
This project is licensed under the terms in the LICENSE file.