Skip to content

Latest commit

 

History

History
584 lines (438 loc) · 21.2 KB

CONTRIBUTING.md

File metadata and controls

584 lines (438 loc) · 21.2 KB

Contributing to vortex-backend

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.


Table of contents

  1. Contributor workflow
  2. Prerequisites
  3. Getting started
  4. Development workflow
  5. Running the test suite
  6. Code conventions
  7. Module and file structure
  8. Adding a new endpoint
  9. Environment variables
  10. Database migrations
  11. Regenerating the API client SDK
  12. Commit messages
  13. Submitting a pull request

Contributor workflow

Before opening a PR, pick a task from the GitHub issue tracker and keep the work scoped to that issue. The preferred workflow is:

  1. Create a small feature branch from main.
  2. Keep the implementation focused on the issue at hand.
  3. Validate the affected commands locally (npm run lint, npm run typecheck, and the relevant test targets).
  4. 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 cache

If your change affects the protocol contract, persisted schema, or WebSocket semantics, start by reading docs/rfcs/README.md and open an RFC before implementation.


Prerequisites

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.


Getting started

# 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 --build

Most feature work does not require Postgres — the service uses an in-memory store by default. DATABASE_URL has 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-level docker-compose.yml so that workflow is one command instead of a hand-rolled local setup.


Development workflow

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:e2e

Running the test suite

Unit tests

npm 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__/.

End-to-end tests

npm run test:e2e

E2E 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.

Sharding, flakes and quarantine

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 shards

run-tests.mjs does three things beyond invoking Jest:

  1. Applies test/quarantine.json, skipping the listed files and printing what it skipped.
  2. Writes a machine-readable result file so a failure can be classified instead of guessed at.
  3. 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 ::warning annotation and a flake-report.md in 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.

Quarantining a flaky test

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.

Checks before opening a pull request

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.


Code conventions

DTO validation

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 / @ApiPropertyOptional on 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/.

Logger

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.

Error handling

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.

Module wiring

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 {}

Module and file structure

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

Repository pattern

Every domain module that owns persistent state follows the same injectable- repository pattern modelled after SolversModule:

  1. <domain>.repository.ts exports:

    • A Symbol DI token (INTENTS_REPOSITORY, SOLVERS_REPOSITORY, …).
    • A TypeScript interface (IIntentsRepository, ISolversRepository, …).
    • The in-memory adapter (InMemoryIntentsRepository, …) as the default implementation.
  2. prisma-<domain>.repository.ts exports the Prisma-backed adapter (PrismaIntentsRepository, PrismaSolversRepository, …).

  3. <domain>.module.ts binds the token to an adapter via a useFactory provider 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:

  1. Create <domain>.repository.ts with the Symbol, interface, and in-memory adapter.
  2. Create prisma-<domain>.repository.ts with the Prisma adapter.
  3. Bind the token in <domain>.module.ts using the env-var-driven useFactory pattern.
  4. Inject the token into the service with @Inject(YOUR_REPOSITORY_TOKEN).
  5. Add the new *_PERSISTENCE variable to src/config/env.validation.ts.

Never import a concrete repository class directly into a service — always use the DI token so the adapter remains swappable.


Adding a new endpoint

  1. Define the DTO in src/<domain>/dto/ with class-validator + Swagger decorators.
  2. Add the service method in src/<domain>/<domain>.service.ts.
  3. Add the controller route in src/<domain>/<domain>.controller.ts with Swagger response decorators (@ApiOkResponse, @ApiNotFoundResponse, etc.).
  4. Write unit tests for the service method alongside the service file.
  5. Write an e2e test in test/<domain>.e2e-spec.ts (or extend an existing one).
  6. Regenerate the SDK: run npm run generate:client and commit the updated src/generated/ files.

Environment variables

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.

License and file headers

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.


Database migrations

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 tests

When you add a migration, make sure it:

  1. includes a down.sql that reverses migration.sql;
  2. builds/drops indexes with CONCURRENTLY;
  3. avoids ALTER COLUMN … TYPE, SET NOT NULL without a DEFAULT, and LOCK TABLE — or suppresses them with -- squawk-ignore <rule> plus a -- justification: comment (and repeat that justification in the PR).

Regenerating the API client SDK

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:client

This 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 schemas
  • index.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.


Commit messages

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.

Updating the changelog

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.


GitHub issue labels

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.

Quick reference

  • good-first-issue — Suitable for newcomers; isolated, self-contained, no major prerequisites blocking
  • help-wanted — Open to anyone; clear scope but could benefit from fresh eyes
  • category/* — Categorize by area: category/backend, category/security, category/devops, category/governance
  • status/* — 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.

Drips Wave contributor accounting

This project offers a points-based contributor incentive program (Drips Wave). Contributors who close issues earn points that are recorded in a ledger.

How points are awarded

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.

For PR authors

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.

Disputes and escalation

If there's a disagreement about points, attribution, or the ledger record, see the escalation process in docs/DRIPS_WAVE_LEDGER.md.


Submitting a pull request

  1. Fork the repo and create a feature branch from main:
    git checkout -b feat/my-feature
  2. Make your changes, following the conventions above.
  3. Run the full verification suite:
    npm run lint && npm run typecheck && npm test && npm run test:e2e
  4. Open a PR against main with:
    • A clear description of what changed and why.
    • Closes #<issue-number> in the description body.
    • Test output or screenshots demonstrating the fix.
  5. Address review comments and keep the branch rebased on main.

PRs that fail lint, typecheck, or any test will not be merged.