The API behind StellarExpress, a logistics platform on Stellar. A
NestJS + GraphQL service over a Postgres/Prisma data layer, with a thin
Stellar/Soroban integration layer that builds unsigned transactions for
the escrow contract and
hands them to the client to sign — the backend never touches a user's
private key.
This is one of three StellarExpress repos:
| Repo | Purpose |
|---|---|
contracts |
The Soroban escrow contract |
backend (this repo) |
GraphQL API, Postgres data layer, non-custodial Stellar integration |
frontend |
Marketing site + product preview (Next.js) |
- New to this stack? Start here
- Stack
- Architecture
- Why an off-chain API on top of an on-chain escrow
- Non-custodial Stellar flow
- Environment variables
- Getting started
- GraphQL API reference
- REST: the Stellar signing flow
- Data model
- Testing
- Deployment
- Troubleshooting
- Contributing
- NestJS structures a backend around modules (a feature area,
e.g.
shipments/), services (the actual logic and database queries), and resolvers (GraphQL) or controllers (REST). - GraphQL exposes a single
/graphqlendpoint where the client specifies exactly which fields it wants back, instead of many fixed REST routes. A query reads; a mutation writes. This schema is code-first — TypeScript classes with@ObjectType()/@Field()decorators generate the schema automatically (src/schema.gql). - Prisma is the ORM —
prisma/schema.prismabecomes a fully-typed client (this.prisma.shipment.findMany(...)), with schema changes tracked as versioned migrations. - JWT keeps a user "logged in" between requests:
signInreturns a signed token, the client sends it asAuthorization: Bearer <token>, andJwtAuthGuardverifies it on every subsequent call. - Escrow, in plain terms: funds held by a neutral party until agreed
conditions are met. Here, that neutral party is the
escrowSoroban contract, not StellarExpress's servers — see the contracts README's glossary for Stellar/Soroban terms (XDR, ledger, Stellar Asset Contract). - Why REST and GraphQL?
/stellar/buildand/stellar/submitmove a raw XDR string — Stellar's transaction wire format — which doesn't benefit from GraphQL's field-selection, so they're plain REST. Every other resource (shipments, tracking, disputes, reviews) goes through GraphQL.
NestJS 11 · GraphQL (code-first, Apollo Server 5 via @nestjs/apollo) ·
Prisma 6 / PostgreSQL · @stellar/stellar-sdk 16 · JWT auth
(passport-jwt, bcryptjs) · @nestjs/schedule (available for future
delivery-deadline reminder jobs) · class-validator / class-transformer.
src/
auth/ signup/signin (sender or carrier), JWT issuance, Stellar address linking
shipments/ the core resource — create, accept, pickup/delivery confirmation, cancel
tracking/ free-text status updates a sender or carrier posts against a shipment
disputes/ raise + arbiter-only resolve, mirroring the on-chain dispute flow
reviews/ post-delivery ratings; rolls up into a carrier's average rating
stellar/ non-custodial XDR builder + submitter for the escrow contract
prisma/ PrismaService (a thin, injectable wrapper over @prisma/client)
Each feature module follows the same shape: a *.service.ts with the
Prisma queries and authorization checks, a *.resolver.ts exposing it
over GraphQL, dto/ input types, and models/ GraphQL output types.
Every module that touches money defers final authority to the on-chain
escrow contract: this API keeps a fast, queryable off-chain mirror
of shipment state (for dashboards and notifications that shouldn't wait
on a ledger round-trip) — but the actual payment releases are enforced
by the Soroban contract, not by this service.
- Speed. Rendering "your 6 active shipments" by simulating six contract reads on every page load doesn't scale; Prisma mirrors it.
- Off-chain-only data. A receiver's name, a tracking note ("left with the front desk"), a dispute's free-text reason — none of this belongs on a public ledger, but it's exactly what the product needs.
- Marketplace browsing.
openShipmentslets a carrier browse jobs without an RPC round-trip per listing.
POST /stellar/build returns unsigned XDR for an escrow contract
call. The client signs it with Freighter, a hardware wallet, or a
passkey signer, then posts the signed envelope to POST /stellar/submit.
The backend never receives, stores, or has the ability to reconstruct a
user's secret key — see src/stellar/stellar.service.ts.
┌──────────┐ 1. build unsigned XDR ┌─────────┐ 3. simulate + prepare ┌──────────────┐
│ Frontend │ ───────────────────────▶ │ Backend │ ──────────────────────▶│ Soroban RPC │
└──────────┘ └─────────┘ └──────────────┘
│ 2. sign with Freighter / │
│ hardware wallet / passkey │
▼ │
┌──────────┐ 4. submit signed XDR ┌─────────┐ 5. send to network ┌──────────────┐
│ Frontend │ ───────────────────────▶ │ Backend │ ──────────────────────▶│ escrow │
└──────────┘ └─────────┘ │ contract │
└──────────────┘
See .env.example for the full list.
| Variable | Purpose |
|---|---|
DATABASE_URL |
Postgres connection string used by Prisma |
JWT_SECRET |
Signs and verifies session JWTs |
SOROBAN_RPC_URL |
Soroban RPC endpoint (defaults to Stellar's public testnet RPC) |
STELLAR_NETWORK_PASSPHRASE |
Network passphrase used when building/submitting transactions |
ESCROW_CONTRACT_ID |
The deployed escrow contract's id — see contracts |
STELLAR_READ_SOURCE_ACCOUNT |
A funded account used as the simulation source for read-only contract calls |
ARBITER_STELLAR_ADDRESS |
The Stellar address of the platform's dispute arbiter; only a user whose linked address matches this can resolve a dispute |
CORS_ORIGIN |
Comma-separated list of allowed origins |
PORT |
HTTP port (default 4000) |
cp .env.example .env
docker compose up -d # starts Postgres 16 on localhost:5432
npm install
npx prisma generate
npx prisma migrate deploy # applies prisma/migrations
npm run start:dev # http://localhost:4000/graphqlnpm test # unit tests (Jest, Prisma mocked — no DB required)
npm run lint
npm run buildAll resolvers except signUp/signIn require a Bearer JWT.
Auth
mutation { signUp(input: { email: "amaka@express.com", password: "••••••••", displayName: "Amaka", isCarrier: false }) { accessToken } }
mutation { signIn(input: { email: "amaka@express.com", password: "••••••••" }) { accessToken } }Shipments
query { openShipments { id originLabel destinationLabel category totalAmount pickupReleaseBps } }
query { myShipmentsAsSender { id status releasedAmount } }
query { myShipmentsAsCarrier { id status } }
query { shipment(id: "shp_1") { id status receiverName originLabel destinationLabel } }
mutation{ createShipment(input: { receiverName: "Chidi", receiverAddress: "G...", originLabel: "Lagos", destinationLabel: "Abuja", category: FOOD, assetCode: USDC, totalAmount: 5000, pickupReleaseBps: 4000, deliveryDeadlineAt: "2026-09-01T00:00:00Z" }) { id } }
mutation{ recordOnChainShipment(input: { shipmentId: "shp_1", contractShipmentId: "1", contractAddress: "C..." }) { id } }
mutation{ acceptShipment(shipmentId: "shp_1") { status } }
mutation{ confirmPickup(shipmentId: "shp_1") { status releasedAmount } }
mutation{ confirmDelivery(shipmentId: "shp_1") { status releasedAmount } }
mutation{ cancelShipment(shipmentId: "shp_1") { status } }Tracking
query { trackingUpdates(shipmentId: "shp_1") { status location note createdAt } }
mutation{ addTrackingUpdate(input: { shipmentId: "shp_1", status: "Out for delivery", location: "Wuse, Abuja" }) { id } }Disputes
query { disputesForShipment(shipmentId: "shp_1") { id status reason } }
mutation{ raiseDispute(input: { shipmentId: "shp_1", reason: "Package arrived damaged" }) { id status } }
mutation{ resolveDispute(input: { disputeId: "dsp_1", resolutionNote: "70/30 split per photos submitted", senderBps: 7000 }) { status } }Reviews
query { myReviews { rating comment } }
query { carrierProfile(userId: "usr_1") { averageRating completedDeliveries } }
mutation{ createReview(input: { shipmentId: "shp_1", rating: 5, comment: "Fast and careful with the food order" }) { id } }POST /stellar/build
Content-Type: application/json
{
"sourcePublicKey": "GABC...",
"method": "confirm_pickup",
"args": ["1"]
}
→ { "xdr": "AAAAAg..." }POST /stellar/submit
Content-Type: application/json
{ "signedXdr": "AAAAAg..." }
→ { "hash": "abcd1234...", "status": "SUCCESS" }method must be one of create_shipment, accept_shipment,
confirm_pickup, confirm_delivery, cancel_shipment,
reclaim_expired, raise_dispute, resolve_dispute — see
src/stellar/dto/build-invocation.dto.ts. Argument encoding (including
the ShipmentCategory enum, which Soroban represents as a one-element
vector containing the variant name) lives in src/stellar/stellar.service.ts.
See prisma/schema.prisma for the full model. Money fields are
Decimal(20, 7) to match Stellar's 7 decimal places of precision.
User ──< Shipment (as sender) >── CarrierProfile
──< Shipment (as carrier)
│
├─< TrackingUpdate
├─< Dispute
└─< Review >── CarrierProfile (rolling average rating)
Shipment.contractShipmentId / contractAddress link an off-chain row
to its on-chain counterpart once recordOnChainShipment confirms the
creation transaction; both are nullable because a shipment can exist
off-chain briefly before its on-chain creation confirms.
27 Jest unit tests across four spec files, all with PrismaService
mocked (no database needed):
auth.service.spec.ts— sign-up conflict handling, password hashing, carrier-profile creation on carrier sign-up, sign-in success/failureshipments.service.spec.ts— accept/pickup/delivery/cancel status transitions and their guards, the pickup-share payout math, ownership checksdisputes.service.spec.ts— party-only dispute raising, status-gated raising, arbiter-address-matched resolution, double-resolution rejectionreviews.service.spec.ts— delivered-only reviewing, sender↔carrier review direction, the carrier rolling-average-rating calculation
npm test
npm run test:covdocker build -t stellarexpress-backend .
docker run -p 4000:4000 --env-file .env stellarexpress-backendTwo-stage Dockerfile: npm ci + prisma generate + npm run build
in the build stage, then a slim runtime image with only production
dependencies. Run npx prisma migrate deploy against your production
DATABASE_URL before starting the container for the first time.
PrismaClientInitializationError: Can't reach database server— Postgres isn't running;docker compose up -d./stellar/buildfails with a simulation error — usuallyESCROW_CONTRACT_IDisn't a real deployed contract yet, orSTELLAR_READ_SOURCE_ACCOUNTisn't funded on the target network.resolveDisputealways throwsForbiddenException—ARBITER_STELLAR_ADDRESSmust exactly match thestellarPublicKeylinked to the resolving user's account.
Issues and PRs are welcome. Before opening a PR: npm run lint,
npm test, and npm run build should all pass. See
StellarExpress/contracts
for the on-chain rules this API defers to, and
StellarExpress/frontend
for the client that consumes this API.