This document covers backend-specific setup and conventions. For the repository's community expectations and RFC workflow, see CODE_OF_CONDUCT.md and docs/rfcs/README.md.
- Contributor workflow
- Prerequisites
- Getting started
- Development workflow
- Running the test suite
- Code conventions
- Module and file structure
- Adding a new endpoint
- Environment variables
- Database migrations
- Regenerating the API client SDK
- Commit messages
- Submitting a pull request
Before opening a PR, pick a task from the GitHub issue tracker and keep the work scoped to that issue. The preferred workflow is:
- Create a small feature branch from
main. - Keep the implementation focused on the issue at hand.
- Validate the affected commands locally (
npm run lint,npm run typecheck, and the relevant test targets). - Open a PR with a clear summary and a link to the issue.
Commit messages follow Conventional Commits and are enforced by the local
commit-msg husky hook (.husky/commit-msg, see commitlint.config.js). The typical format is:
<type>(<scope>): <summary>Examples:
docs: add contributor onboarding
fix: validate soroban config in production
feat: add quote endpoint cacheIf your change affects the protocol contract, persisted schema, or WebSocket semantics, start by reading docs/rfcs/README.md and open an RFC before implementation.
| Tool | Minimum version | Notes |
|---|---|---|
| Node.js | 20 | Use nvm to pin the version |
| npm | 10 | Bundled with Node 20 |
| Docker (optional) | any | Only needed for a local Postgres instance |
No global installs are required — all tooling is in devDependencies.
# 1. Clone and install
git clone https://github.com/vortex-protocol/vortex-backend.git
cd vortex-backend
npm install
# 2. Set up environment — pick the variant that matches your target network
cp .env.testnet.example .env # for testnet development (most common)
# cp .env.mainnet.example .env # for mainnet — requires real keys and contract IDs
# 3. Start the dev server (watch mode, no DB required for most features)
npm run dev
# → http://localhost:4000
# → Swagger UI: http://localhost:4000/docs
# → WebSocket: ws://localhost:4000/ws
# 4. If you need a local Postgres-backed app, use the bundled compose stack
# (one command; matches the CI service container config)
docker compose up --buildMost feature work does not require Postgres — the service uses an in-memory store by default.
DATABASE_URLhas a sensible default so the app boots without a live database. You only need Postgres if you are working on Prisma migrations or the database-backed health check. The repo now includes a root-leveldocker-compose.ymlso that workflow is one command instead of a hand-rolled local setup.
| Command | Description |
|---|---|
npm run dev |
Watch-mode NestJS server (restarts on save) |
npm run build |
Compile TypeScript to dist/ |
npm run start |
Run the compiled server (dist/main.js) |
npm run lint |
ESLint across src/, test/, scripts/ |
npm run typecheck |
tsc --noEmit — no output files, just type errors |
npm run test |
Unit tests (Jest) |
npm run test:e2e |
End-to-end tests against a booted app (supertest) |
npm run generate:client |
Regenerate the typed API client in src/generated/ |
npm run solver:demo |
Run the reference solver bot (see scripts/README.md) |
npm run seed |
Seed the database with sample data |
npm run db:migrate |
Run pending Prisma migrations against the local DB |
npm run check:migrations |
Lint changed migrations for unsafe DDL and a missing down.sql (see Database migrations) |
npm run test:scripts |
Tests for the tooling in scripts/ (e.g. the migration checker's fixtures) |
Run the full verification suite before opening a PR:
npm run lint && npm run typecheck && npm test && npm run test:e2enpm test # run all unit tests
npm test -- --watch # watch mode
npm test -- --coverage # with coverage report (threshold: 70 % on all axes)Unit test files live next to the source file they test (*.spec.ts). Mocks
for external dependencies go in test/__mocks__/.
npm run test:e2eE2E tests use supertest against a real (but in-process) NestJS application.
The createTestApp() helper in test/utils/create-test-app.ts boots the full
AppModule with a no-op PrismaService stub so no live database is required.
The @stellar/stellar-sdk is mocked globally in e2e tests via Jest's
moduleNameMapper (see test/jest-e2e.json) so Soroban calls never hit
the network.
CI does not run npm test directly. The suite is split across parallel
runners by scripts/ci/run-tests.mjs, and you can reproduce a single shard
locally with the same command:
npm run test:ci # unit suite, whole
npm run test:ci -- --shard=1/4 # unit suite, one of four shards
npm run test:e2e:ci -- --shard=1/2 # e2e suite, one of two shardsrun-tests.mjs does three things beyond invoking Jest:
- Applies
test/quarantine.json, skipping the listed files and printing what it skipped. - Writes a machine-readable result file so a failure can be classified instead of guessed at.
- If a run fails, re-runs only the failing files once and diffs the two
runs. A test that passes on the retry is reported as a flake and the job
still goes green, with a
::warningannotation and aflake-report.mdin the job summary. Anything that fails twice, or a test file that cannot even load, fails the job — a module-load error is never retried, because retrying cannot fix it.
Coverage is collected per shard but not gated per shard: a shard that runs
a quarter of the suite cannot meet a global threshold. Shards write to
coverage-shards/, and scripts/ci/coverage-merge.mjs sums the counters,
writes coverage/, and enforces the 70 % threshold from jest.config.js on
the merged result. Threshold changes therefore stay in one place.
Quarantine is a debt record, not a silencer. Adding an entry to
test/quarantine.json requires all of:
| Field | Rule |
|---|---|
path |
Repo-relative, must exist, no duplicates |
reason |
What actually goes wrong, not "flaky" |
owner |
A GitHub handle — @you |
issue |
#123 or the issue URL tracking the fix |
addedAt |
ISO date the entry was added |
scripts/ci/check-quarantine.mjs fails the build on a missing owner or issue,
a path that no longer exists, a duplicate, or an entry older than
staleAfterDays (90 by default). It is plain Node with no imports, so it also
works as a pre-commit hook without npm ci.
Prefer fixing the test. Quarantine only when the flake is genuinely environmental (timing, a shared fixture, upstream RPC). Before opening a PR that adds an entry, expect a maintainer to ask why the test cannot be made deterministic.
There are no automated CI checks on this repository any more — the GitHub Actions workflows have been removed. Verification is therefore a local responsibility, and the same commands the removed workflows used to run are the ones to run before asking for review:
npm run lint # eslint, 0 errors
npm run typecheck # tsc --noEmit
npm run build # nest build
npm test # unit suite
npm run test:e2e # e2e suite (needs a Postgres instance)If CI is reintroduced later, note that the test gates must stay separate from
the lint/build job — a red unit test should not be able to hide behind a green
build — and that scripts/ci/coverage-merge.mjs fails loudly when the shard
count it is given does not match the shards that actually ran, rather than
silently gating coverage on a partial union.
Every request body is typed with a DTO class decorated with
class-validator decorators and documented with @nestjs/swagger decorators:
import { IsString, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ExampleDto {
@ApiProperty({ description: 'A Stellar contract address (56 uppercase chars)' })
@IsString()
@Matches(/^[A-Z0-9]{56}$/)
contractId!: string;
}Rules:
- All DTO fields must be explicitly decorated — no plain TypeScript types.
- Use
@ApiProperty/@ApiPropertyOptionalon every field for Swagger. - Optional fields use
@IsOptional()+@ApiPropertyOptional()and are typed with?:(not| undefined). - Amount fields are
string(bigint-as-string) with a@Matches(/^\d+$/)guard. - String fields with a clear bound must also declare
@MaxLength(...)(for example, Ed25519 signatures are base64-encoded 64-byte values and are capped at 88 chars). - Custom validators live in
src/common/validators/.
Use the shared Winston logger from src/common/logger.ts — do not create
per-service logger instances with new Logger() (NestJS built-in) in code
paths outside NestJS lifecycle hooks:
import { logger } from '../common/logger';
logger.info('intent created', { intentId, user });
logger.warn('deadline approaching', { intentId, secondsLeft });
logger.error('fill failed', { intentId, error: err.message });Use Logger from @nestjs/common only in NestJS-specific lifecycle methods
(onModuleInit, onModuleDestroy) where the shared instance may not yet be
bootstrapped.
Throw NestJS HTTP exceptions from controllers — never throw new Error(...):
import { NotFoundException, ConflictException } from '@nestjs/common';
if (!intent) throw new NotFoundException('Intent not found');
if (intent.state !== 'open') throw new ConflictException(`Intent is ${intent.state}`);The global HttpExceptionFilter in src/common/http-exception.filter.ts
serialises all exceptions into a consistent { statusCode, message, error }
JSON response.
Every NestJS module must explicitly export any provider that other modules
depend on. Do not rely on the DI container resolving cross-module providers
without a proper exports declaration:
@Module({
providers: [MyService],
exports: [MyService], // ← required if any other module injects MyService
})
export class MyModule {}src/
<domain>/
<domain>.module.ts # NestJS module definition
<domain>.controller.ts # HTTP routes + Swagger annotations
<domain>.service.ts # Business logic
<domain>.service.spec.ts # Unit tests for the service
<domain>.repository.ts # Repository interface + DI token + in-memory adapter
prisma-<domain>.repository.ts # Prisma adapter (when persistence is needed)
dto/ # Request/response DTOs
common/ # Shared utilities (logger, filters, guards)
config/ # ConfigModule + Joi validation schema
prisma/ # PrismaService (database client wrapper)
generated/ # Auto-generated API types (do not edit)
scripts/ # Developer scripts (run via tsx)
test/ # E2E tests and shared helpers
Every domain module that owns persistent state follows the same injectable-
repository pattern modelled after SolversModule:
-
<domain>.repository.tsexports:- A
SymbolDI token (INTENTS_REPOSITORY,SOLVERS_REPOSITORY, …). - A TypeScript
interface(IIntentsRepository,ISolversRepository, …). - The in-memory adapter (
InMemoryIntentsRepository, …) as the default implementation.
- A
-
prisma-<domain>.repository.tsexports the Prisma-backed adapter (PrismaIntentsRepository,PrismaSolversRepository, …). -
<domain>.module.tsbinds the token to an adapter via auseFactoryprovider that reads config (INTENTS_STORE,SOLVERS_PERSISTENCE) and returns the appropriate instance. Nothing else in the codebase needs to change when switching adapters.
The services (IntentsService, SolversService) inject the token and always
await repository method calls so both sync (in-memory) and async (Prisma)
shapes work transparently:
@Injectable()
export class IntentsService {
constructor(
@Inject(INTENTS_REPOSITORY)
private readonly repo: IIntentsRepository,
// ...
) {}
async get(id: string): Promise<Intent | undefined> {
return this.repo.findById(id); // works for both adapters
}
}To add a new domain with repository-backed persistence:
- Create
<domain>.repository.tswith the Symbol, interface, and in-memory adapter. - Create
prisma-<domain>.repository.tswith the Prisma adapter. - Bind the token in
<domain>.module.tsusing the env-var-drivenuseFactorypattern. - Inject the token into the service with
@Inject(YOUR_REPOSITORY_TOKEN). - Add the new
*_PERSISTENCEvariable tosrc/config/env.validation.ts.
Never import a concrete repository class directly into a service — always use the DI token so the adapter remains swappable.
- Define the DTO in
src/<domain>/dto/with class-validator + Swagger decorators. - Add the service method in
src/<domain>/<domain>.service.ts. - Add the controller route in
src/<domain>/<domain>.controller.tswith Swagger response decorators (@ApiOkResponse,@ApiNotFoundResponse, etc.). - Write unit tests for the service method alongside the service file.
- Write an e2e test in
test/<domain>.e2e-spec.ts(or extend an existing one). - Regenerate the SDK: run
npm run generate:clientand commit the updatedsrc/generated/files.
Three .env.example variants are provided:
| File | Use case |
|---|---|
.env.example |
Generic template with all available variables |
.env.testnet.example |
Testnet development (RPC, blank contract IDs, relaxed CORS) |
.env.mainnet.example |
Production mainnet (strict CORS, required signing key) |
Copy the right one for your context and fill in real values where marked.
Never commit a filled-in .env file.
The validation schema in src/config/env.validation.ts documents every
variable with Joi — run npm run dev and check the startup error if any
variable fails validation.
This repository is licensed under the MIT License. Because the project is permissive by default, source files do not require a per-file SPDX or copyright banner; just keep the repo-level license in place and avoid adding custom header text that conflicts with it.
Migrations live in prisma/migrations/. Every migration
must ship a hand-authored down.sql for rollback, and changed migrations are
linted for unsafe DDL by the migration-lint CI job. See
prisma/migrations/README.md for the full rule
set and the -- squawk-ignore suppression convention.
npm run check:migrations # lint migrations changed since HEAD^1
npm run check:migrations -- --base <sha> # lint migrations changed since <sha>
npm run test:scripts # run the checker's fixture testsWhen you add a migration, make sure it:
- includes a
down.sqlthat reversesmigration.sql; - builds/drops indexes with
CONCURRENTLY; - avoids
ALTER COLUMN … TYPE,SET NOT NULLwithout aDEFAULT, andLOCK TABLE— or suppresses them with-- squawk-ignore <rule>plus a-- justification:comment (and repeat that justification in the PR).
Whenever you add, remove, or rename an endpoint or change a DTO, regenerate the typed client so downstream consumers stay in sync:
npm run generate:clientThis builds the app first (npm run build), boots a throw-away NestJS process
to collect Swagger metadata, and writes three files to src/generated/:
openapi.json— raw OpenAPI 3.0 spec (useful for Postman / Redoc)api-types.ts— TypeScript types for all paths, operations, and schemasindex.ts— re-export entry point
The generated SDK follows the backend's release tags (vX.Y.Z) for versioning,
so the publish workflow uses the Git tag as the SDK version when the package is
published to npm. Commit these files as part of the same PR that changes the API
surface.
This repo enforces the Conventional Commits format via commitlint:
<type>(<optional scope>): <short summary>
# Types: feat | fix | docs | style | refactor | test | chore | perf | ci | build
Examples:
feat(intents): add idempotency key support to POST /intents
fix(solvers): return 403 when bond is zero instead of 500
docs: add backend-specific CONTRIBUTING guide
test(intents): cover expired-deadline path in e2e suite
chore: regenerate API client SDK after adding quote endpoint
The CI job commitlint checks the PR title on every pull request. Squash
merges are preferred so that the merge commit title is the canonical changelog
entry.
When working on a feature or fix that affects users, add an entry to CHANGELOG.md
under [Unreleased] before opening your PR:
| Commit Type | Changelog Section | Example |
|---|---|---|
feat(...) |
Added |
A new endpoint or behavior |
fix(...) |
Fixed |
A bug fix or behavioral correction |
perf(...) |
Changed |
Performance improvement |
refactor(...) |
Changed |
Major structural change affecting users |
docs(...) |
Documentation |
User-facing documentation changes |
chore(...) |
— | Skip (internal tooling, no user-visible change) |
test(...) |
— | Skip (internal tests, no user-visible change) |
For details on changelog format and section definitions, see the instructions in
CHANGELOG.md.
When creating or triaging GitHub Issues, use the label taxonomy defined in docs/LABEL_TAXONOMY.md to categorize work by difficulty and area. This helps contributors discover work that matches their skill level and interest.
good-first-issue— Suitable for newcomers; isolated, self-contained, no major prerequisites blockinghelp-wanted— Open to anyone; clear scope but could benefit from fresh eyescategory/*— Categorize by area:category/backend,category/security,category/devops,category/governancestatus/*— Track lifecycle:status/in-progress,status/blocked,status/ready-to-merge
For detailed mapping rules (e.g. how to label issues from the contributor backlog), see docs/LABEL_TAXONOMY.md.
This project offers a points-based contributor incentive program (Drips Wave). Contributors who close issues earn points that are recorded in a ledger.
Every issue in the contributor backlog has a fixed point value:
- High complexity: 200 points
- Medium complexity: 150 points
When your PR closes an issue, the points for that issue are credited to your contributor account. See docs/DRIPS_WAVE_LEDGER.md for the full ledger and accounting rules.
When you open a PR that solves an issue from the backlog, include a Closes tag in your
PR description to link the PR to the issue number:
## Description
Brief description of the changes.
Closes #<issue-number>This creates an auditable record that ties your PR to a specific point value. The ledger is updated when the PR is merged.
If there's a disagreement about points, attribution, or the ledger record, see the escalation process in docs/DRIPS_WAVE_LEDGER.md.
- Fork the repo and create a feature branch from
main:git checkout -b feat/my-feature
- Make your changes, following the conventions above.
- Run the full verification suite:
npm run lint && npm run typecheck && npm test && npm run test:e2e
- Open a PR against
mainwith:- A clear description of what changed and why.
Closes #<issue-number>in the description body.- Test output or screenshots demonstrating the fix.
- Address review comments and keep the branch rebased on
main.
PRs that fail lint, typecheck, or any test will not be merged.