Skip to Content
EngineeringPlatform services

Platform services

The Derive client, subaccounts, fees, trading profiles, the generated API client, and the health probe.

The Derive client

apps/api/src/derive/ has two files at the top level and eight subdirectories holding 52 more — 54 in total. This is the entire exchange client; there is no second one anywhere.

DERIVE_GATEWAY has exactly one implementation, LiveDeriveGateway. The paper-trading mode was removed on 2026-08-27 and again in the swap path — there is no simulated gateway to fall back on.

Two independent signatures

This is the part people get wrong. A Derive request carries two unrelated signatures:

What it signsWhere
Transport authA timestamp, with personal_sign / EIP-191derive/auth/session-key-auth.provider.ts
Action signatureThe action itself — the order, deposit, or withdrawal — with EIP-712derive/signing/action-signer.ts

The first says “this request is from us”. The second says “we authorise this specific action”. The session key signs transport; the action signature is what the exchange enforces on-chain.

Transport

derive/rpc/http-rpc.client.ts is the only HTTP client to Derive’s RPC. It is used both inside DeriveModule and directly by subaccounts.module.ts:36, which needs CurrencyRegistry without auth headers.

The gateway has no WebSocket client. derive-gateway.interface.ts:250 states the reason (P3): the maker window is 20 minutes, so a minute of polling lag costs nothing. Open orders are polled.

This is a statement about the gateway, not about the process. prices/derive-spot-stream.ts does hold a raw ws connection to wss://api.lyra.finance/ws for the spot feed — see Market data. It exists to drive a price ticker in the browser, and nothing in the money paths reads it. Both @WebSocketGateway declarations in the application (prices, strategies) serve the browser.

Supporting pieces: derive/currencies/currency-registry.ts (currency and contract addresses — spotAsset, pm2Manager, read from the public RPC), derive/errors/derive-error.mapper.ts (exchange errors → application exceptions), and derive/schemas/ (zod schemas for instruments and tickers, including the abbreviated slim-ticker dialect where a and A differ only by case and mean different things).

Subaccounts

There is no subaccount pool. The model is simpler than the name suggests: one subaccount per (user, asset) pair, created lazily, UNIQUE(user_id, asset).

A subaccount is never created empty — creation is the first deposit. SubaccountProvisioner.fund (subaccounts/subaccount-provisioner.ts:75) calls repo.claimOrCreate, then either gateway.createSubaccount or gateway.deposit depending on whether a deriveSubaccountId already exists. Confirmation is a separate step, confirm (:121), which writes subaccount_funded to the ledger only after the exchange confirms.

claimOrCreate is ON CONFLICT DO NOTHING plus a re-read — two positions opened concurrently on the same asset would otherwise race.

Status

Two values, and the column is a bare varchar with no enum or check constraint: 'active' (the default) and 'stopped'.

StopService.raise (subaccounts/stop.service.ts:29) writes stop_raised, then markStopped, then alerts — the alert inside its own try/catch, so an unavailable alerting channel cannot undo the stop. It is called from raiseStop in positions/positions.scheduler.ts:148 when a cycle hits an unknown or fatal error (:143-186; the default-to-fatal rule is commented at :181).

There is no way back. No code path sets the status to 'active' again, and nothing clears cycles.stop_reason. By design, lifting a stop is a manual action through an admin panel — which does not exist. A stopped subaccount stays stopped until someone writes SQL.

Release

ReleaseService.request (subaccounts/release.service.ts:66) moves free collateral back out of the subaccount. It refuses while a position is open (CyclesRepository.hasActive) or a conversion is running, takes the whole free collateral rather than a part of it, and does not withdraw a negative USDC balance — that comes back as debt.

Both SubaccountsController and ReleaseService live in subaccounts/ but are registered by PositionsModule — see Traps.

Fees

Two different things share the word.

Exchange fees — real, and live

FeesService.calculateFees (fees/fees.service.ts:31) fetches the live ticker and reads maker, taker, baseFee and markPriceFeeRateCap from it. The arithmetic is FeeCalculator.optionFeeFromRates (derive/fees/fee-calculator.ts:58): notional × rate, capped against the premium, plus baseFee for takers.

fee-calculator.ts:36 explicitly forbids hardcoding the rates — three Derive documentation sources contradict each other.

Served publicly at GET /assets/:asset/fees, and used by the showcase to compute net premium.

The platform fee — declared, not wired

PlatformFee.applyTo(gross) (ledger/platform-fee.ts:16) computes gross → fee → net at PLATFORM_FEE_RATE.

It is called nowhere outside its own spec file. The PLATFORM_FEE token is injected by nobody. platform:fee_revenue participates in no posting rule. The rate is 0.

The only reason eight module specs require PLATFORM_FEE_RATE in their test env is that LedgerModule constructs PlatformFee while wiring — not because anything uses it.

Its one real consumer is cosmetic: GET /trading-config returns platformFeeRate for display (trading-config.controller.ts:46). It moves no money.

The shape of the fee — percentage, performance fee, or flat per cycle — has not been chosen (platform-fee.ts:9).

Trading profiles

TRADING_PROFILE is 'dev' | 'prod', required, no default. The profiles themselves are code, not configuration: PROD_TRADING_PROFILE and DEV_TRADING_PROFILE in trading-config/trading-profile.ts:87.

They differ in exactly one table: the collateral minimums and lot steps. Prod carries real product minimums (0.5 ETH, 0.015 BTC, 1000 USDC); dev sets minAmount: null, leaving the instrument’s own exchange minimum as the only sieve. premiumTolerance (0.05) and makerWindowMinutes (20) are identical on both, deliberately.

The profile is resolved once, while the DI graph is built (trading-config.module.ts:19), so there is only ever one way to learn which profile is active.

The only place the limit is actually applied is positions/positions.service.ts:226. TradingConfigController merely displays the configuration — the minimums used to live in @arkada/contracts and be enforced by the frontend alone; the server is the source of truth now.

OpenAPI and the generated client

The document is built from the same zod schemas that validate the responses: createZodDto plus cleanupOpenApiDoc (swagger/swagger.document.ts:56), which expands schemas carrying .meta({id}) into named components. It is mounted at /docs (plus /docs/json, /docs/yaml) with no NODE_ENV gate.

The web client is generated from it:

pnpm --filter=@arkada/web build-api

which runs swagger-typescript-api against http://localhost:4000/docs/json and writes apps/web/src/shared/api/types.ts. That file is never edited by hand.

The API must be running for this to work. With no apps/api on port 4000 the generation fails and types.ts silently keeps its previous contents — which is exactly how the client drifts out of sync with packages/contracts and breaks @arkada/web typecheck later.

openapi-how-it-works.md:118 still claims no client is generated at all. That is stale.

Health

GET /health returns status, service and uptimeSeconds, and checks only that the process is alive. It deliberately does not probe Postgres, Redis or Derive: its job is to tell a dead container from a running one, not a healthy service from a degraded one.