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.
| Module | What it is | Spec |
|---|---|---|
derive (54 files) | The Derive exchange client: auth, currencies, instruments, RPC, signing | 02-derive-gateway.md, derive-gateway-how-it-works.md |
treasury (46) | Deposits, withdrawals, gas, wallets | 04-treasury.md, deposit-how-it-works.md, withdrawals-how-it-works.md |
conversions (38) | The conversion circle after an ITM call | 2026-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.FI | swap-how-it-works.md |
ledger (19) | The double-entry journal | ledger-how-it-works.md |
auth (19) | SIWE + JWT, HD wallets, nonces | — |
strategies (19) | The option showcase and its streaming | 10-strategies-showcase.md, 11-showcase-streaming.md |
reconciliation (14), subaccounts (14) | Ledger-versus-exchange comparison, Derive subaccounts | reconciliation-how-it-works.md |
portfolio (12), wheel (11), signals (10) | Per-user aggregation, the auto-wheel, signal webhooks | 05-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) | Supporting | openapi-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.
| Scheduler | Cadence | File |
|---|---|---|
| Treasury (deposits, sweeps, withdrawals, gas) | every minute | treasury/treasury.scheduler.ts:129 |
| Positions (the cycle state machine) | every minute | positions/positions.scheduler.ts:60 |
| Swap | every minute | swap/swap.scheduler.ts:83 |
| Conversions — slow tick | every minute | conversions/conversion.scheduler.ts:103 |
| Conversions — fast tick | every 5 seconds | conversions/conversion.scheduler.ts:150 |
| Auto-wheel | every minute | wheel/wheel.scheduler.ts:45 |
| Reconciliation — scheduled run | every hour | reconciliation/reconciliation.scheduler.ts:59 |
| Reconciliation — after-operation run | every minute | reconciliation/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.
| Key | Owner | File | Kind |
|---|---|---|---|
728_035_957_001 | treasury | treasury/treasury.scheduler.ts:36 | try |
728_035_957_002 | positions | positions/positions.scheduler.ts:32 | try |
728_035_957_003 | swaps | swap/swap.scheduler.ts:33 | try |
728_035_957_004 | conversions (both ticks) | conversions/conversion.scheduler.ts:29 | try |
728_035_957_005 | sending from the swap wallet | treasury/bridge-in.sender.ts:28 | blocking |
728_035_957_006 | auto-wheel | wheel/wheel.scheduler.ts:19 | try |
728_035_957_007 | reconciliation (both ticks) | reconciliation/reconciliation.scheduler.ts | try |
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
Decimalonly, throughcommon/money.ts.Money.oftakes a string; anumberloses precision. Cross-currency arithmetic is blocked byassertSameCurrency. - 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
fataland 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_atcolumn, 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 PostgresLive spending is behind a single switch, ALLOW_LIVE_SPEND=yes. Without it those tests skip rather
than fail.