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 signs | Where | |
|---|---|---|
| Transport auth | A timestamp, with personal_sign / EIP-191 | derive/auth/session-key-auth.provider.ts |
| Action signature | The action itself — the order, deposit, or withdrawal — with EIP-712 | derive/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-apiwhich 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.