Skip to Content
EngineeringArchitecture

Architecture

NestJS 11 + TypeORM + Postgres. Dev port 4000, Swagger on /docs. Money is decimal.js through common/money.ts and never number. Schema changes go through migrations only — synchronize is off.

Module map

Counts are every .ts in the module including subdirectories and tests, recounted 2026-09-13.

ModuleWhat it isSpec
derive (54 files)The Derive exchange client: auth, currencies, instruments, RPC, signing02-derive-gateway.md, derive-gateway-how-it-works.md
treasury (46)Deposits, withdrawals, gas, wallets04-treasury.md, deposit-how-it-works.md, withdrawals-how-it-works.md
conversions (38)The conversion circle after an ITM call2026-08-31-itm-conversion-cycle-design.md†, conversion-how-it-works.md
positions (29), database (29)Position lifecycle; entities, migrations, data source (23 of the 29 are migrations)14-positions.md, positions-how-it-works.md, 03-ledger.md
swap (24)Swaps on Base through LI.FIswap-how-it-works.md
ledger (19)The double-entry journalledger-how-it-works.md
auth (19)SIWE + JWT, HD wallets, nonces
strategies (19)The option showcase and its streaming10-strategies-showcase.md, 11-showcase-streaming.md
reconciliation (14), subaccounts (14)Ledger-versus-exchange comparison, Derive subaccountsreconciliation-how-it-works.md
portfolio (12), wheel (11), signals (10)Per-user aggregation, the auto-wheel, signal webhooks05-wheel-engine.md, 2026-08-21-signals-webhook-design.md
swagger (8), trading-config (6), config (5), chart (5), fees (4), assets (4), common (3), prices (3), health (2)Supportingopenapi-how-it-works.md

Specs live in docs/superpowers/specs/auto-wheel/, except the two marked † — those are dated design documents one level up, in docs/superpowers/specs/. The index of all of them is docs/README.md.

Everything is a tick

There is no request that moves money. Every HTTP endpoint that starts a money movement answers 202 and writes a row; a background tick picks the row up. POST /positions, POST /subaccounts/:asset/release and POST /withdrawals all behave this way.

This is what makes the system resumable. A tick that dies halfway leaves a row in a claimed state with whatever evidence it had already gathered, and the next tick continues from there — rather than a request dying halfway and taking the only copy of the intent with it.

SchedulerCadenceFile
Treasury (deposits, sweeps, withdrawals, gas)every minutetreasury/treasury.scheduler.ts:129
Positions (the cycle state machine)every minutepositions/positions.scheduler.ts:60
Swapevery minuteswap/swap.scheduler.ts:83
Conversions — slow tickevery minuteconversions/conversion.scheduler.ts:103
Conversions — fast tickevery 5 secondsconversions/conversion.scheduler.ts:150
Auto-wheelevery minutewheel/wheel.scheduler.ts:45
Reconciliation — scheduled runevery hourreconciliation/reconciliation.scheduler.ts:59
Reconciliation — after-operation runevery minutereconciliation/reconciliation.scheduler.ts:87

Advisory locks

One process must not run the same tick twice, and two processes must not run it at once. Each scheduler takes a Postgres advisory lock.

KeyOwnerFileKind
728_035_957_001treasurytreasury/treasury.scheduler.ts:36try
728_035_957_002positionspositions/positions.scheduler.ts:32try
728_035_957_003swapsswap/swap.scheduler.ts:33try
728_035_957_004conversions (both ticks)conversions/conversion.scheduler.ts:29try
728_035_957_005sending from the swap wallettreasury/bridge-in.sender.ts:28blocking
728_035_957_006auto-wheelwheel/wheel.scheduler.ts:19try
728_035_957_007reconciliation (both ticks)reconciliation/reconciliation.scheduler.tstry

Three properties of these keys are deliberate and easy to break.

They are written as explicit numeric literals, never as arithmetic over an imported base. A collision introduced by a branch merge has to be visible to the eye in a diff.

The lock is session-scoped. It is taken on a dedicated createQueryRunner() and released on that same runner before release(). Take it on one connection and unlock on another and the unlock returns false while the lock stays held until the process restarts — a class of hang that looks like “the scheduler just stopped”.

The keys must differ. An advisory lock is global to the database, not scoped to a table. One shared key would make independent ticks queue behind each other while protecting nothing.

…005 is the only blocking one. Skipping a send is not an option there: two schedulers send from that address, both take nonce: 'pending', and if they overlap the second transaction evicts the first from the mempool — after the first has already had its hash recorded.

Claiming rows

Within a tick, rows are claimed with FOR UPDATE SKIP LOCKED, and the claim is an UPDATE wrapped in a CTE.

dataSource.query() over a bare UPDATE does not return rows. It returns the tuple [rows, affectedCount]. Without the CTE wrapper, .map(toRow) maps over the tuple and produces “rows” made of undefined, and the tick silently moves nothing at all. Three repositories carry a «НЕ УБИРАТЬ» (do not remove) comment on exactly this.

Rules that hold everywhere

  • Money is Decimal only, through common/money.ts. Money.of takes a string; a number loses precision. Cross-currency arithmetic is blocked by assertSameCurrency.
  • Fee rates and instrument limits are read from the live ticker at runtime (finding F17). Three separate Derive documentation sources contradict each other and the live values.
  • Collateral is one-to-one (P7) — a product definition, not a margin consequence. Measured: PM2 would have allowed a call on 0.5 ETH against 0.2 ETH of collateral.
  • An unknown error defaults to fatal and stops the subaccount, not the system.
  • An unrecognised exchange operation status is never terminal. Calling it success posts money that may not exist; calling it failure abandons an operation that is still running.
  • No step holds a lock across an external wait. The 20-minute maker window is a deadline_at column, not a held lock.
  • Funds stay on the subaccount after settlement (P1), whatever the outcome. There is no automatic return.

How to check your work

pnpm --filter=@arkada/api typecheck pnpm --filter=@arkada/api test src/<path>.spec.ts pnpm --filter=@arkada/api test:integration # needs Postgres

Live spending is behind a single switch, ALLOW_LIVE_SPEND=yes. Without it those tests skip rather than fail.