Backend for Kestrel, a wallet-tracking and token-sniping tool for Ethereum mainnet. NestJS + TypeScript, strict mode. REST endpoints and a Socket.IO gateway; no private keys ever touch this service — swaps are prepared as unsigned transactions and signed client-side.
Four product surfaces, one service:
- Watchtower — track wallets, get realtime buy/sell notifications when they trade.
- Scope — a live stream of newly created Uniswap V2/V3/V4 pools, with a safety score.
- Terminal — per-token candle data (1m–1d) and a swap quote/prepare flow.
- Portfolio — what an address is holding, valued in USD.
viem (chain + ABI decoding, no ethers), Postgres 16 + TypeORM, Redis 7 (rate-limited queues, caching, Streams as the ingestion buffer), Socket.IO, Zod for all external input and the shared event/DTO shapes.
npm install
cp .env.example .env # fill in ALCHEMY_WS_URL/ALCHEMY_RPC_URL, ZEROEX_API_KEY, ETHERSCAN_API_KEY, JWT_SECRET at minimum
docker compose up -d # postgres + redis only
npm run migration:run
npm run start:dev
curl http://localhost:3000/health
open http://localhost:3000/docs # Swagger UIPostgres is mapped to 5433 (not 5432) so it does not collide with a local install — match DATABASE_URL to whatever you choose.
Validated by Zod at boot (src/config/env.schema.ts); the process refuses to start on an invalid config rather than failing later in a request.
| Variable | Required | Default | Notes |
|---|---|---|---|
DATABASE_URL |
yes | — | Postgres 16 |
REDIS_URL |
yes | — | Redis 7 |
JWT_SECRET |
yes | — | signs the session cookie |
ALCHEMY_WS_URL |
for ingestion | '' |
push subscriptions (pools, swaps, tracked-wallet txs) |
ALCHEMY_RPC_URL |
for ingestion | '' |
multicall, eth_call simulation, token balances |
ZEROEX_API_KEY |
for swaps | '' |
0x allowance-holder quote/prepare |
ETHERSCAN_API_KEY |
for safety | '' |
source/ownership checks |
GECKOTERMINAL_BASE_URL |
no | public API | no auth required |
GECKOTERMINAL_RATE_LIMIT_PER_MIN |
no | 28 |
see GeckoTerminal is the scarcest resource below |
COINGECKO_BASE_URL / COINGECKO_API_KEY |
no | public API | demo key only raises the free-tier limit |
SIWE_DOMAIN / SIWE_URI |
no | localhost:3000 |
must match the frontend origin in production |
CORS_ORIGIN |
no | http://localhost:3001 |
the UI |
The app boots without the credentialed ones — chain ingestion, safety scoring, and swaps are simply inert until they are set.
All responses are JSON. Every route is behind a 120 req/min per-IP limiter (src/common/guards/rate-limit.guard.ts). 🔒 = requires the session cookie.
Swagger UI is at /docs, the raw spec at /docs-json — served outside production only (src/swagger.ts). Request schemas are generated from the same Zod schemas the ZodValidationPipe validates against, via Zod 4's z.toJSONSchema(), so the docs cannot drift from the validation. Enrich a parameter by chaining .meta({ description, example }) onto its Zod schema rather than writing a separate DTO. Response shapes, which have no Zod schema to derive from, are hand-maintained in src/common/swagger/response-schemas.ts.
| Route | Purpose |
|---|---|
GET /auth/nonce |
nonce to embed in the SIWE message |
POST /auth/verify |
{ message, signature } → sets an httpOnly session cookie |
POST /auth/logout |
clears it |
GET /auth/me |
current user, or 401 when signed out |
| Route | Purpose |
|---|---|
GET /candles?pool=<uuid>&timeframe=<1m…1d>&from&to |
OHLCV history merged with the live forming bar |
GET /pools/resolve?token=0x… |
token address → a pool to chart and trade |
GET /safety/:tokenAddress?pool=<uuid> |
honeypot / tax / ownership / LP-lock score |
| Route | Purpose |
|---|---|
GET /swap/quote |
indicative price, no commitment |
POST /swap/prepare |
unsigned transaction + allowance target, simulated before returning |
GET /portfolio?wallet=0x… 🔒 |
holdings valued in USD; defaults to the signed-in wallet |
| Route | Purpose |
|---|---|
GET/POST /wallets, PATCH/DELETE /wallets/:id 🔒 |
tracked-wallet CRUD |
GET /alerts?limit&cursor&kind 🔒 |
stored alert history, cursor-paginated |
PATCH /alerts/read 🔒 |
mark read |
GET /health (Terminus; 503 when a dependency is down) · GET /metrics (ingestion lag, reorg count, GeckoTerminal upstream stats).
Room-based. Subscribe, then listen.
socket.emit('sub:candles', { poolId, timeframe: '1m' }) // → candle:update
socket.emit('unsub:candles', { poolId, timeframe: '1m' })
socket.emit('sub:new-pools', {}) // → pool:new
socket.emit('sub:wallets') // → wallet:activity (needs the cookie on the handshake)Candle subscriptions are refcounted — a client that never unsubscribes leaks polling work.
src/
├── chain/ Alchemy WS (reconnect + circuit breaker), log decoding, reorg handling, token balances
├── ingestion/ raw log -> normalized event -> Redis Stream
├── pools/ pool/token registry, discovery, token→pool resolution, trade writer (decimals-adjusted price math)
├── candles/ GeckoTerminal backfill, gap filling, live tip-bucket engine
├── market/ GeckoTerminal (rate-limited queue) and CoinGecko clients
├── users/ auth/ SIWE login, JWT session cookie
├── wallets/ tracked-wallet CRUD + address matcher
├── notifications/ receipt decoding -> wallet:activity matching, alert persistence
├── scope/ pool:new event assembly + GeckoTerminal reconciliation backstop
├── safety/ honeypot/tax/ownership/LP-lock/deployer checks, 0x-backed sellability check
├── swap/ RouterProvider interface, 0x allowance-holder implementation, quote/prepare
├── portfolio/ balance enumeration + batch USD valuation
├── realtime/ Socket.IO gateway (candles, wallet activity, new pools — all room-based)
├── maintenance/ trade retention cron (30d), /metrics
└── common/ bigint-to-string wire serializer, Zod validation pipe, rate limiter, HTTP filter
Short list of things that are deliberate and non-obvious. Each one was a bug first.
Numbers are strings on the wire. Every bigint is serialised to a decimal string (common/interceptors/bigint-serializer.interceptor.ts). Never Number() a raw token amount — above 2^53 that silently loses precision, which for an 18-decimal token is under 0.01 of a token. Prices and decimal-adjusted balances are safe to convert for display only.
Candles are denominated in the pool's quote token, not USD. The live tip is patched from on-chain swap amounts (priceNative = quote per base), so history has to speak the same unit or a single bar opens in one currency and closes in another — observed as a 1878× "collapse" on a WETH-quoted pool that looked exactly like a rug. GET /candles therefore asks GeckoTerminal for the base token by address (currency=token&token=…), which also sidesteps GT's own base/quote choice disagreeing with ours. The response carries a denomination object; label your axis from it rather than assuming.
Portfolio is USD — deliberately different. A wallet spans pools with different quote assets, so there is no single native unit to sum in.
decimals is never assumed to be 18. If decimals() fails, the token's balance comes back as null rather than a number that is wrong by orders of magnitude. Same for candle math.
Candle history is gap-filled and capped. GeckoTerminal only emits buckets that traded, so silent buckets are synthesised as flat bars (o === h === l === c, v: "0"). Responses cap at 1000 bars — ~16h at 1m, ~41d at 1h. Page further back with from/to.
GeckoTerminal is the scarcest resource in the system. Its effective per-IP ceiling is undocumented and it polices rate, not just volume — 429s have been observed at ~10 calls/min purely because two landed in the same millisecond. All GT traffic funnels through GeckoTerminalService, which layers a sliding-window limiter, a cross-process pacer, in-flight coalescing of identical misses, a shared cooldown on 429, and a long-lived :stale copy so an outage degrades to slightly-old data instead of a 500. Never fetch GT directly from a request handler.
A token's symbol is not its identity. Airdrop spam impersonates real symbols — one wallet held a "USDC" with a 36-billion balance that was not USDC. Match on address, always.
Reorgs roll back state. ReorgService deletes affected pools/trades, PoolRegistryService evicts them from memory, and CandleTipService reseeds its tips. Pools adopted from GeckoTerminal carry created_block = 0 so they are never evicted — they predate our indexing and no reorg we watch can undo them.
This service never signs anything. POST /swap/prepare returns unsigned calldata that it has simulated; signing and broadcasting are the client's job.
npm run start:dev # watch mode
npm run build # tsc via nest build
npm test # jest (--forceExit; a couple of specs open a real Postgres connection)
npm run lint
npm run migration:generate # after changing an entity
npm run migration:runcurl http://localhost:3000/health
curl http://localhost:3000/metrics
curl "http://localhost:3000/pools/resolve?token=0x514910771af9ca656af840dff83e8264ecf986ca"
curl "http://localhost:3000/candles?pool=<uuid>&timeframe=1h"
curl "http://localhost:3000/safety/<tokenAddress>?pool=<uuid>"
curl "http://localhost:3000/swap/quote?sell=<addr>&buy=<addr>&amount=<wei>&taker=<addr>"
# Authenticated routes: GET /auth/nonce -> sign client-side -> POST /auth/verify {message, signature}
# (sets an httpOnly cookie); then POST /wallets to track an address, or GET /portfolio./pools/resolve is the easiest smoke test that needs no auth and no pool id — give it any liquid token address and it should come back with a poolId.
Every module in this tree has been exercised against real Ethereum mainnet data during development — real WS log decoding, a real reconnect after a genuine socket drop, real GeckoTerminal/CoinGecko/0x/Etherscan responses, a real detected honeypot, a real prepared-and-simulated swap transaction, a portfolio reconciled against a Uniswap pool's actual reserves. What's explicitly not done here, because it needs time or infrastructure this environment doesn't have:
- The 24h unattended ingestion soak and the pool-count reconciliation against GeckoTerminal's own feed (
/metrics→ingestion.lagSecondsis the number to watch;ScopeService's reconciliation log line is the count check). - A 1000-concurrent-socket load test.
- A real signed-and-broadcast swap (this service only prepares the unsigned transaction).
Known gaps, tracked rather than hidden: trades.price_usd is declared but never populated (candles went native instead, so nothing fills it); portfolio pricing is capped at 60 tokens per request, so a genuine holding ranked below the cap goes unpriced until background pricing exists; and pool created_at for live-discovered pools is wall-clock at discovery rather than the block timestamp, which under-sizes candle backfill for a pool adopted long after its creation.
Interactive Swagger UI, generated from the live API: