NestJS API for milestone-based supply chain escrow on Stellar
This is Repo 2 of 3 in the ChainSettle project:
| Repo | Description |
|---|---|
chainsetttle-contract |
Soroban smart contract (Rust) |
chainsetttle-backend ← you are here |
NestJS REST API + event poller |
chainsetttle-frontend |
React + Freighter wallet UI |
Contributing? See CONTRIBUTING.md for local setup, branching/commit conventions, and PR expectations.
The backend is the bridge between the Stellar blockchain and the frontend. It does NOT hold user funds or sign transactions on behalf of users — all fund movements are handled by the on-chain contract. The backend:
- Stores off-chain metadata about shipments and users (PostgreSQL via Prisma)
- Polls Stellar RPC every 5 seconds for contract events and updates local state
- Sends in-app and email notifications to relevant parties when milestones change
- Provides a clean REST API for the frontend to query shipment state
- Issues JWT tokens via a Stellar address signature (no passwords)
- Exposes Swagger docs at
/docs
For a deep-dive into module interactions, the event pipeline, shipment lifecycle, and cross-cutting concerns see ARCHITECTURE.md.
For definitions of domain terms used throughout the codebase (shipment, milestone, arbiter, proof, dispute, escalation, reconciliation, reputation) see docs/glossary.md.
┌─────────────────────────────────────────────────────┐
│ NestJS Application │
│ │
│ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ Auth │ │Shipments │ │Milestones│ │ Events │ │
│ │ Module │ │ Module │ │ Module │ │ Module │ │
│ └─────────┘ └──────────┘ └──────────┘ └────────┘ │
│ ↑ │
│ Cron (5s poll) │
│ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ PrismaService │ │ StellarService │ │
│ │ (PostgreSQL) │ │ (Soroban RPC client) │ │
│ └──────────────────┘ └──────────────────────────┘ │
└─────────────────────────────────────────────────────┘
↕ ↕
PostgreSQL DB Stellar Testnet/Mainnet
(ChainSettle Contract)
| Module | Responsibility |
|---|---|
AuthModule |
Stellar address challenge-response auth → JWT |
ShipmentsModule |
CRUD for shipment records, sync from chain |
MilestonesModule |
Milestone state updates, proof hash storage |
EventsModule |
Stellar event poller (cron), event dispatch |
NotificationsModule |
In-app + email notifications via Nodemailer |
HealthModule |
/health endpoint for DB + service liveness |
PrismaModule |
Shared global DB client (PostgreSQL) |
StellarModule |
Shared global Stellar RPC client + utilities |
Role/permission matrix (buyer, supplier, logistics, arbiter, admin — endpoint by endpoint): docs/rbac.md
All endpoints are prefixed with /api/v1 (URI versioning; see API Versioning below). Protected routes require Authorization: Bearer <JWT>.
Database schema reference (ERD + tables): docs/database.md
Typed TypeScript SDK: sdk/ — regenerate with
npm run generate:sdk
| Method | Path | Description |
|---|---|---|
GET |
/auth/nonce?address=G... |
Get challenge nonce for a Stellar address |
POST |
/auth/login |
Submit signed nonce, receive JWT |
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/shipments |
✓ | Register on-chain shipment in DB |
GET |
/shipments |
✓ | List shipments (filters: buyer, supplier, status, favorite=true) |
GET |
/shipments/:id |
✓ | Full shipment detail + milestones + events (isFavorited) |
POST |
/shipments/:id/favorite |
✓ | Favorite (star) a shipment (participant only; private) |
DELETE |
/shipments/:id/favorite |
✓ | Remove shipment from caller's favorites |
POST |
/shipments/:id/sync |
✓ | Force sync shipment from Stellar chain |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/shipments/:id/milestones |
✓ | List all milestones for a shipment |
GET |
/shipments/:id/milestones/:index |
✓ | Get single milestone |
POST |
/shipments/:id/milestones/:index/confirm |
✓ | Confirm a single milestone (buyer) |
POST |
/shipments/:id/milestones/bulk-confirm |
✓ | Batch-confirm milestones (buyer) |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/events |
✓ | List chain events (filter by shipmentId) |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/notifications |
✓ | Get user notifications |
PATCH |
/notifications/:id/read |
✓ | Mark notification as read |
PATCH |
/notifications/read-all |
✓ | Mark all as read |
GET |
/notifications/preferences |
✓ | Get channel preferences (+ Slack webhook) |
PATCH |
/notifications/preferences |
✓ | Update preferences / Slack webhook URL |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/health |
— | Database + service health check |
chainsetttle-backend/
├── .env.example ← copy to .env and fill in values
├── .gitignore
├── nest-cli.json
├── package.json
├── tsconfig.json
├── README.md
│
├── prisma/
│ └── schema.prisma ← Database schema (User, Shipment, Milestone, etc.)
│
└── src/
├── main.ts ← App bootstrap (Swagger, CORS, pipes, guards)
├── app.module.ts ← Root module
│
├── common/
│ ├── prisma/
│ │ ├── prisma.module.ts
│ │ └── prisma.service.ts ← PrismaClient wrapper
│ ├── stellar/
│ │ ├── stellar.module.ts
│ │ └── stellar.service.ts ← RPC client, event fetching, utilities
│ ├── filters/
│ │ └── http-exception.filter.ts ← Standardised error responses
│ ├── interceptors/
│ │ └── transform.interceptor.ts ← Wraps all responses in { success, data, timestamp }
│ ├── guards/
│ │ └── jwt-auth.guard.ts
│ └── decorators/
│ └── current-user.decorator.ts
│
└── modules/
├── auth/
│ ├── auth.module.ts
│ ├── auth.controller.ts
│ ├── auth.service.ts ← Nonce generation + JWT issuance
│ ├── jwt.strategy.ts
│ └── dto/login.dto.ts
│
├── shipments/
│ ├── shipments.module.ts
│ ├── shipments.controller.ts
│ ├── shipments.service.ts
│ ├── shipments.service.spec.ts ← Unit tests
│ └── dto/create-shipment.dto.ts
│
├── milestones/
│ ├── milestones.module.ts
│ ├── milestones.controller.ts
│ └── milestones.service.ts ← DB updates triggered by chain events
│
├── events/
│ ├── events.module.ts
│ ├── events.controller.ts
│ └── events.service.ts ← Stellar RPC poller (cron every 5s)
│
├── notifications/
│ ├── notifications.module.ts
│ ├── notifications.controller.ts
│ └── notifications.service.ts ← In-app + email via Nodemailer
│
└── health/
├── health.module.ts
└── health.controller.ts
- Node.js v20+
- pnpm (recommended) or npm
- PostgreSQL 15+
- Stellar CLI (only needed if deploying the contract)
npm install
# or
pnpm installcp .env.example .envEdit .env with your values:
DATABASE_URL— your PostgreSQL connection stringJWT_SECRET— a long random stringCHAINSETTTLE_CONTRACT_ID— the deployed contract ID fromchainsetttle-contractSMTP_*— email credentials (use Gmail app password or any SMTP)
# Create and apply migrations
npx prisma migrate dev --name init
# Generate Prisma client
npx prisma generate
# (Optional) seed initial data
# npx prisma db seednpm run start:devAPI available at: http://localhost:3000/api/v1
Swagger docs at: http://localhost:3000/docs
The API uses NestJS URI versioning. The global prefix is api; the version segment is v1, v2, etc.
| Version | Base path | Status |
|---|---|---|
| v1 | /api/v1/* |
Current (default) |
| v2 | /api/v2/* |
Introduce when you need a breaking change |
Create a parallel controller (new file) and set the version explicitly:
import { Controller, Get, Version } from '@nestjs/common';
// Option A — version on the controller
@Controller({ path: 'shipments', version: '2' })
export class ShipmentsV2Controller {
@Get()
listV2() { /* new response shape */ }
}
// Option B — version on a single handler inside a shared controller
@Controller('shipments')
export class ShipmentsController {
@Get()
@Version('1')
listV1() { /* existing */ }
@Get()
@Version('2')
listV2() { /* breaking change */ }
}Register the new controller in the same module. Existing @Controller('shipments') handlers keep serving v1 via defaultVersion: '1' in main.ts.
When a v1 route is scheduled for removal:
- Annotate it with
@DeprecatedRoute({ sunset: 'Wed, 01 Jul 2027 00:00:00 GMT', link: 'https://docs.example.com/migration' }). - Clients receive
DeprecationandSunsetresponse headers (and optionalLink). - Keep the route until the Sunset date; then remove it once consumers have moved to v2.
Deprecation / Sunset / Link are exposed in CORS exposedHeaders so browsers can read them.
A typed client is generated from the Swagger/OpenAPI document into sdk/:
npm run generate:sdk # refresh openapi.json + schema.ts
npm run check:sdk # CI: fail if sdk/ is staleSee sdk/README.md.
# Unit tests
npm run test
# Unit tests with coverage
npm run test:cov
# Watch mode
npm run test:watchChainSettle uses a Sign-In With Stellar pattern — no passwords:
1. Frontend → GET /auth/nonce?address=GABC...
← { nonce: "chainsetttle:GABC...:1234567890:abc123" }
2. User signs the nonce with Freighter wallet
(Keypair.sign on the frontend)
3. Frontend → POST /auth/login
{ stellarAddress, signedNonce, signature }
← { accessToken: "eyJ..." }
4. All subsequent requests:
Authorization: Bearer eyJ...
The backend verifies the signature against the public key, then issues a JWT. Wire up the Keypair.verify() call in auth.service.ts before production.
All API routes are rate-limited via Redis-backed @nestjs/throttler. Defaults are controlled by THROTTLE_TTL (window seconds, default 60) and THROTTLE_LIMIT (max requests per key, default 100). Auth and upload routes use tighter per-route limits; some auth routes key by Stellar address instead of IP.
Every throttled response includes:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Max requests allowed in the current window |
X-RateLimit-Remaining |
Requests left in the window (0 when limited) |
X-RateLimit-Reset |
Seconds until the window resets |
Retry-After |
Present on 429 responses — same value as X-RateLimit-Reset |
These headers are CORS-exposed so browser clients can read them. Use ./test-rate-limit.sh against a running local API to verify success and 429 header behavior.
To iterate on the API/UI without a live testnet RPC:
npm run dev:mock-chain
# set STELLAR_RPC_URL=http://127.0.0.1:8787 and STELLAR_HORIZON_URL=http://127.0.0.1:8788
npm run start:devSee test/mocks/README.md for tradeoffs. Dev-only — not for integration testing of real chain behavior.
The EventsService runs a cron job every 5 seconds using @nestjs/schedule. It:
- Calls
stellar.fetchContractEvents(lastProcessedLedger)via the Soroban RPC - Routes each event to the correct handler (e.g.
handleMilestoneConfirmed) - Updates Prisma DB records to reflect the new state
- Triggers notifications to relevant Stellar addresses
- Saves the raw event to
chain_eventsfor audit trail - Advances
lastProcessedLedgercursor
For production, persist lastProcessedLedger in the DB (or Redis) so it survives restarts.
All responses are wrapped by the global TransformInterceptor:
{
"success": true,
"data": { ... },
"timestamp": "2026-05-17T12:00:00.000Z"
}Errors follow a standardised format from HttpExceptionFilter:
{
"success": false,
"statusCode": 404,
"timestamp": "2026-05-17T12:00:00.000Z",
"path": "/api/v1/shipments/SHIP-999",
"message": "Shipment SHIP-999 not found"
}Send Accept-Language: es to receive Spanish error messages for mapped strings (falls back to English). See src/i18n/README.md.
Release builds generate a CycloneDX SBOM via .github/workflows/sbom.yml. The artifact sbom.cdx.json is uploaded on release/tag runs and attached to GitHub Releases.
Regenerate locally (requires Node 20+ and an installed lockfile):
npm run sbom
# writes ./sbom.cdx.json from package-lock.json- Set
NODE_ENV=production - Use a strong
JWT_SECRET(32+ random chars) - Swap in-memory nonce store for Redis
- Persist
lastProcessedLedgerin DB (not memory) for crash recovery - Enable HTTPS (reverse proxy — nginx or Caddy)
- Set up Prisma connection pooling (PgBouncer)
- Configure
BACKUP_S3_BUCKET+ related secrets for automated encrypted DB backups (.github/workflows/db-backup.yml— seedocs/deployment.md) - Optionally set
DATABASE_REPLICA_URLfor read-heavy GET offload (seedocs/deployment.md) - Wire up real Stellar
Keypair.verify()inauth.service.ts - Set
CORS_ORIGINto your production frontend URL - Add rate limiting tuning for production traffic
- Deploy via blue/green workflow (
.github/workflows/deploy-blue-green.yml— see docs/deployment.md) - Run
npm run loadtestagainst staging before scale-up (seedocs/load-testing.md) - Review docs/webhooks.md if external systems subscribe to webhook events
| Variable | Required | Description |
|---|---|---|
NODE_ENV |
Yes | development or production |
PORT |
No | API port (default: 3000) |
API_PREFIX |
No | Route prefix without version (default: api) |
DATABASE_URL |
Yes | PostgreSQL connection string |
DATABASE_REPLICA_URL |
No | Optional read-replica URL for GET-heavy paths |
SHIPMENT_ARCHIVAL_DAYS |
No | Days before terminal shipments move to cold storage (default: 90) |
JWT_SECRET |
Yes | Secret for signing JWTs |
JWT_EXPIRES_IN |
No | Token expiry (default: 7d) |
IMPERSONATION_JWT_EXPIRES_IN |
No | Admin impersonation token TTL (default: 15m) |
STELLAR_NETWORK |
Yes | testnet or mainnet |
STELLAR_RPC_URL |
Yes | Soroban RPC endpoint |
CHAINSETTTLE_CONTRACT_ID |
Yes | Deployed contract address |
USDC_TOKEN_ADDRESS |
Yes | USDC SAC address |
SMTP_HOST |
No | Email SMTP host |
SMTP_USER |
No | SMTP username |
SMTP_PASS |
No | SMTP password |
CORS_ORIGIN |
No | Allowed frontend origin |
EVENT_POLLING_INTERVAL_MS |
No | Cron interval in ms (default: 5000) |
MIT