engineering case study

I built a full Hyperliquid trading system, on my own.

Cabbge is a production Telegram trading bot for Hyperliquid: perpetual futures, HIP-3 tokenised equities and commodities, HIP-4 prediction markets, spot, deposits, withdrawals, and an MCP server that lets an AI client place the same orders. 35,432 lines of TypeScript, deployed on Fly, typecheck-clean.

This page is the engineering record. What the architecture is, which problems were genuinely hard and how they were solved, the numbers you can count yourself, and an honest account of how it went after launch. Every claim below points at a file in the repository. There are no user counts, revenue figures or testimonials on this page, because there are none to report.

35,432
lines of TypeScript across 155 files in src/
17
background worker modules, 14 booted in production
34
SQL migrations, 31 live Postgres tables
18
MCP tools behind OAuth 2.1 with PKCE
47
Telegram commands, 192 callback routes
0
TypeScript errors on tsc --noEmit

Counts measured from the working tree on 2026-08-23. The commit history runs to 2026-07-21 (196 commits, first commit 2026-05-22); the difference is the white-label refactor, which is written but not yet committed. Reproduce with find src -name '*.ts' | xargs wc -l and bun run typecheck. If those commands disagree with the numbers above, trust the commands: the code moved and this page did not.

the system

What it is, at architecture level.

Two front doors, one execution core. A Telegram user and an AI client both end up in the same order builder, the same signing layer, and the same Hyperliquid endpoint. Nothing about custody or signing changes based on which door you came through.

Cabbge system architecture Two entry paths. A Telegram user reaches a grammY long-polling bot with 47 commands and 192 callback routes; an AI client reaches a Bun HTTP server on port 8080 that serves the MCP server, 18 tools, behind OAuth 2.1 with PKCE and Sign-In With Ethereum. Both paths converge on a shared trade router and order builder, which passes to a signing layer that msgpack-encodes the action, keccak-hashes it, and wraps it in an EIP-712 phantom-agent envelope on chain ID 1337, signed either by a per-user delegated agent key held under AES-256-GCM or by a Privy MPC wallet. Signed actions go to Hyperliquid L1 over the exchange endpoint, the info endpoint and a WebSocket. A left rail shows the Arbitrum deposit bridge using an EIP-2612 permit with a relayer paying gas. A right rail shows 17 background worker modules including trailing stops, fill watching, deposit polling, copy-trade watching, agent anomaly detection and a scaling monitor. Everything persists to Postgres on Supabase across 31 live tables. ENTRY · TELEGRAM ENTRY · AI CLIENT Telegram user chat, buttons, confirm cards Claude / MCP client natural language requests grammY bot, long polling 47 commands · 192 callbacks update dedup · debounce Bun HTTP server :8080 /mcp · /oauth · /health /.well-known discovery Session & rate limits 30 reads / 10 writes per min per-user token buckets MCP server · 18 tools OAuth 2.1 PKCE (S256) SIWE identity · chain 42161 EXECUTION CORE Trade router & order builder size, tick snap, reduce-only, TP/SL asset resolve: perp · HIP-3 · HIP-4 · spot in-flight lock, one order per user per asset SIGNING LAYER msgpack → keccak256 → EIP-712 Agent envelope, chainId 1337, source "a" Agent key AES-256-GCM Privy MPC owner wallet Hyperliquid L1 /exchange signed actions /info polling · WebSocket push FUNDING Arbitrum deposit bridge EIP-2612 permit, signed off-chain batchedDepositWithPermit relayer pays gas user needs zero ETH BACKGROUND · 17 MODULES trailing-stop-worker · 60s fill-watcher · settlement DMs deposit-poller · L1 + Arbitrum copy-watcher · 45s, confirm only agent-anomaly-watcher · 60s position-health · liquidation cashout-arrival · notification referral-reconciler · channel scaling-monitor · operator alerts 14 boot at startup, 3 held back Postgres on Supabase 31 live tables · 34 migrations · users, agents, trails, copies, referrals, settings
Both entry paths converge before signing, which is the point. The MCP client cannot reach a code path the Telegram bot does not also use, so there is one place where an order is built and one place where it is signed.
Telegram layer
grammY in long-polling mode. 47 registered commands and 192 callback-query routes across 44 handler modules. Update deduplication is persisted so a bot restart cannot replay a trade. Outbound messages go through a queue that drains at 25 messages per second, under Telegram's 30 per second ceiling, so a worker fanning out alerts to every user does not trip a 429.
Wallet layer
Privy server wallets in owner mode, so signatures recover to the wallet's own address rather than a separate signer address. That distinction matters: Hyperliquid identifies accounts by recovered signer, and the delegated-signer model would have produced the wrong account. On top of that sits a per-user delegated agent key that removes the MPC round trip from the hot trade path.
Signing & exchange
Hand-written Hyperliquid L1 signing. No SDK. msgpack encoding, keccak256 action hash, EIP-712 phantom-agent envelope for L1 actions, and separate typed-data schemas for the user-signed actions (approveBuilderFee, spotSend, usdClassTransfer, tokenDelegate, withdraw3). The exchange client wraps that with nonce management, retry, error classification and a self-healing path for revoked agents.
Market coverage
One asset resolver covers four venue types. Perpetuals by index, HIP-3 tokenised equities and commodities by their dex-prefixed symbol (xyz:AAPL), HIP-4 prediction markets by a computed asset id of 100_000_000 + (10 × outcomeIndex + sideIndex), and spot by token pair. Everything past that point is shared.
Workers
17 worker modules, 14 booted in production. All use a recursive setTimeout tick rather than setInterval, specifically so a stalled database round trip cannot cause two cycles to run concurrently and race each other's cooldown checks. Each tick is wrapped so a single user's failure never kills the cycle.
Persistence
Postgres on Supabase. 34 forward-only migrations, 33 CREATE TABLE statements, 31 tables live (a partner-payouts pair was added and then dropped when that product direction was cut). A typed query module per domain (users, agents, trailing stops, copy follows, referrals, settings, HIP-4 positions). A hot-path cache layer sits in front with an in-flight map so a hundred simultaneous requests for the same market produce one upstream call.
MCP server
855 lines exposing 18 tools, served over the same Bun HTTP server as the health check. Full OAuth 2.1 with PKCE, dynamic client registration, and Sign-In With Ethereum as the identity proof. This is what makes the bot usable from a Claude client instead of only from Telegram.
the hard parts

Six problems that were actually difficult.

Not the CRUD. These are the parts where getting it slightly wrong means a signature the exchange rejects, a double fill the user did not ask for, or a position sitting on the exchange with no stop attached.

01 · signing

Byte-exact msgpack under an EIP-712 phantom agent

Hyperliquid does not verify a signature against bytes you send it. It reconstructs the action bytes server-side and checks the signature against its own reconstruction. So your msgpack output has to match Python's, byte for byte, or every order you sign is rejected with an unhelpful error and a garbage recovered address.

The action hash is keccak256(msgpack(action) ‖ nonce_be8 ‖ vault_flag ‖ optional_vault ‖ optional_expires), then that hash goes in as connectionId inside a fixed Agent { source, connectionId } struct on domain { name: "Exchange", chainId: 1337 }. Map keys have to be emitted in insertion order, not sorted, which means the action objects have to be constructed in the exact field order the Python reference uses.

One example of how unforgiving this is: withdraw3 takes destination as an EIP-712 string, not an address. Using address is more natural and produces a valid signature over the wrong hash, so Hyperliquid recovers a nonsense account and answers "must deposit before performing actions". That was found end-to-end, on a real withdrawal that kept failing, and the fix is annotated in the source so nobody undoes it.

Where it livessrc/services/hyperliquid/signing.ts (224 lines, zero dependencies beyond msgpack and keccak)
02 · concurrency

Nonce monotonicity and the double-tap that costs real money

Hyperliquid requires strictly increasing nonces per wallet. Date.now() is not good enough: a worker tick and a user callback can land in the same millisecond and produce a duplicate. The nonce generator clamps forward instead, taking the next tick when the clock has not moved, which burns a few milliseconds of nonce space and never collides.

The larger problem is human. Impatient users double-tap "Confirm" before the bot has edited the message, and each tap is a fresh callback query producing a fresh order with a fresh nonce. Hyperliquid accepts both, and the user wakes up with two positions they did not want. The defence is a per-user, per-asset in-flight lock, keyed on u:userId:assetId, falling back to w:walletId:assetId for the prediction-market path where the user id is deliberately null. The fallback exists because without it that path bypassed the lock entirely.

Underneath, separate read and write token buckets: 30 reads and 10 writes per user per minute. Money-moving actions get the stricter one.

Where it livessrc/services/hyperliquid/exchange.ts · src/lib/rate-limit.ts
03 · custody

Non-custodial signing with a key that cannot withdraw

Every user gets their own secp256k1 keypair, approved on Hyperliquid via approveAgent. The protocol scopes that key to placing and cancelling orders and adjusting leverage. It cannot withdraw or move collateral. That is the ceiling on the blast radius: a total server compromise lets an attacker churn a balance through forced trades, not drain it.

The private key is stored encrypted with AES-256-GCM under a per-user key derived by HKDF-SHA256, where the input keying material is an environment secret that never touches the database and the salt is the user id. A database dump on its own decrypts nothing. Signing is pure CPU, roughly a millisecond, with no network hop.

Third leg: an anomaly watcher polls per-agent trade counters every 60 seconds against an in-memory baseline. Thirty trades in a minute DMs the user with a revoke button. A hundred in five minutes pages the operator, for the case where the user is asleep. And when Hyperliquid deregisters an agent (which it does when an account is emptied to zero), the exchange client detects that exact rejection, revokes the dead agent locally, re-signs the identical action with the master wallet under a fresh nonce, resubmits, and re-provisions a new agent in the background. The user sees a trade that worked.

Where it livessrc/services/agent/wallet.ts · provision.ts · rate-limit.ts · src/workers/agent-anomaly-watcher.ts
04 · workers

Trailing stops Hyperliquid does not offer natively

Hyperliquid has stop orders. It does not have a stop that follows price up and never comes back down. Building that server-side means holding state the exchange does not hold for you, and being correct about it every 60 seconds, forever.

The cycle: fetch clearinghouse state once per user (one call covers all their coins), walk the extreme (high for a long, low for a short), compute the new trigger at extreme × (1 ∓ trail_bps), and only act if the new trigger is strictly tighter than what is already armed by at least 5 basis points. Below that threshold it just persists the walked extreme, because churning cancel-and-replace on 0.01% moves is how you burn API quota for nothing.

The correctness work is in the edge cases. If the user flipped from long to short on the same coin between ticks, position size is non-zero but on the wrong side; walking that extreme would cancel the old stop and leave the new position naked, so the trail is disabled and the user is told to re-arm. If the stop we placed is no longer resting (the user cancelled it in the Hyperliquid UI), we re-arm rather than assume. Each trail is isolated in its own try/catch so one failure costs one trail one minute, not the whole cycle. And in the one genuinely dangerous window, cancel succeeded but the replacement was rejected, the user gets an immediate DM saying the position is unprotected right now, with re-arm and fixed-stop buttons attached. Discovering that after a liquidation is not acceptable.

Where it livessrc/workers/trailing-stop-worker.ts (428 lines)
05 · onboarding

Depositing without ever owning gas

A new user with USDC on Arbitrum and no ETH cannot deposit to Hyperliquid. They need gas, and telling someone to go buy ETH to move the money they already have is where onboarding dies.

The bridge signs an EIP-2612 permit off-chain with the user's wallet, authorising the Hyperliquid bridge contract to pull their USDC, then submits batchedDepositWithPermit from a treasury relayer that pays the gas. No approve transaction, no ETH balance, no second signature. If no relayer is configured the same code path falls back to a user-signed transaction and surfaces a specific error about the ETH shortfall rather than a generic revert.

Two pollers watch for arrival on both the Arbitrum and native L1 sides and notify the user when funds land, because a deposit that has been credited but not announced is indistinguishable from a deposit that was lost.

Where it livessrc/services/arbitrum/bridge-to-l1.ts · relayer.ts · src/workers/arbitrum-deposit-poller.ts · l1-deposit-watcher.ts
06 · integration

An MCP server a consumer AI client will actually connect to

Claude's custom connectors authenticate one way: OAuth. A static bearer token will not work, a token in the URL will not work. To be a first-class connector you have to be an authorization server, not just consume one.

So the bot serves the whole surface: protected-resource metadata (RFC 9728), authorization-server metadata (RFC 8414) with an OpenID-configuration alias for clients that probe it, dynamic client registration (RFC 7591), an authorize endpoint, and a token endpoint. PKCE with S256 is required, not optional; anything else is rejected at validation. Discovery metadata is derived from the request host so it stays self-consistent on any domain without a redeploy.

The identity problem is separate and more interesting: an AI client has no idea who the Telegram user is. That gap is closed with Sign-In With Ethereum on chain 42161, where the server builds the message, stores a single-use nonce, and verifies the signature, plus a short-lived code minted inside the bot. Eighteen tools sit behind it.

Where it livessrc/mcp/oauth.ts (407 lines) · siwe.ts · server.ts (855 lines, 18 tools)
countable facts

Numbers you can check yourself.

Every row below is a measurement of the repository or the deployment config, with the command or file that produces it. Nothing here is an estimate.

Measurement Value How it is produced
Application code 35,432 lines 155 TypeScript files under src/. Excludes tests, landing pages, scripts and generated types.
Type safety 0 errors bun run typecheck runs tsc --noEmit and exits clean. No @ts-ignore escape hatch holding it together.
Background workers 17 modules 14 boot at startup. Of the three that do not: the funding-alert worker is held back until an opt-in setting ships, because as written it DMs every user; the yield router was cut on 2026-06-07 on unit economics and is kept only so an old position can be unwound; the third is a shared keyboard builder used by the deposit pollers, not a worker at all. Source is kept rather than deleted, and the reasons are commented at the import site in src/index.ts.
Database 34 migrations Forward-only SQL in supabase/migrations/. 33 CREATE TABLE statements, 31 tables live: migration 00022 deliberately drops the two-table partner-payouts pair added by 00021 when that direction was cut. Numbered 00001 to 00035 with one unused slot.
Telegram surface 47 + 192 47 bot.command() registrations and 192 callback-query routes, across 44 handler modules in src/bot/handlers/.
MCP surface 18 tools 18 server.tool() registrations in src/mcp/server.ts, behind the OAuth flow in src/mcp/oauth.ts.
Tests 5 files Bun test suites covering L1 signing primitives, agent-key encryption round-trip, the rate limiter, SIWE verification and the Hyperliquid WebSocket handler. This is targeted coverage of the parts that silently corrupt money, not broad coverage.
Deployment Fly.io, sin 2 shared vCPU, 1024 MB, Singapore region for the shortest hop to Hyperliquid in Tokyo. TCP and HTTP health checks, restart policy always, SIGTERM with a 20 second kill timeout.
Deployment constraint min = max = 1 Pinned to a single machine on purpose. Telegram long polling is single-consumer, so a second machine would fight over getUpdates and crash-loop its health check. Documented in fly.toml so nobody "helpfully" scales it.
Capacity provisioning ~1,500 to 2,000 The memory budget was sized for that many concurrent users from a written breakdown of cache tuples, WebSocket subscriptions, worker overhead and Bun baseline, with the next bump documented in SCALING.md. This is a provisioning target, not a load figure. The bot never had that many users.
Operational safety 4 watched limits A scaling monitor ticks every 60 seconds and alerts the operator on memory above 80%, Telegram queue depth above 500 sustained across two ticks, Hyperliquid 429s above 30 per five minutes, and cache hit rate below 80%.
History 196 commits First commit 2026-05-22, last 2026-07-21. Roughly two months, solo.

On the signing test specifically, an honest note: the suite asserts hash determinism, nonce and vault sensitivity, and the correct EIP-712 domain. It does not carry a golden vector copied out of the Python SDK. The real verification is that Hyperliquid rebuilds the action bytes itself and rejects any divergence, and orders signed by this code fill on mainnet.

what happened

It shipped. It did not find users.

The system went live and worked. What it never got was distribution. I am one person in India with no audience, no marketing budget and no network in crypto trading, and I spent two months on engineering and almost none on the much harder problem of getting a stranger to trust a new bot with their money. The failure was distribution, not the system. That is not a comfortable thing to put on a page, but a technical buyer can tell the difference between a build problem and a go-to-market problem, and pretending otherwise would waste both our time.

Which is exactly why the work is for sale rather than sitting idle. If you already have the audience, the community or the client, the engineering above is the part you would otherwise spend months and a lot of money reproducing. That part is done, it is documented, and I know every line of it.

what this buys you

What I can build for you, from here.

Fixed price, fixed scope, delivered as source you own. The system above is the reference implementation for all three, so none of it starts from a blank file.

Hyperliquid execution layer

Signing, order building, nonce and concurrency handling, error classification, market resolution across perps, HIP-3, HIP-4 and spot. The part that takes weeks to get byte-correct and then never needs touching again.

Telegram trading bot

Wallet provisioning, deposits and withdrawals, confirm-card UX, stops and trailing stops, portfolio, alerts, referrals. Deployed and running on your infrastructure, with the operational monitoring already wired.

MCP server with real auth

An MCP server your users can add to a Claude client by pasting one URL. Full OAuth 2.1 with PKCE, dynamic client registration and discovery metadata, so it behaves like a first-party connector instead of a developer toy.

Two things I will not tell you. I will not tell you a bot makes money, because what is described on this page is infrastructure that places orders correctly and nothing on top of that is a promise about trading outcomes. And I will not show you traction I do not have. What I will show you is the code, and I will walk you through any part of it before you pay anything.

Try it, then talk to me.

The demo runs the real interaction flow in your browser, no wallet and no deposit. If what you see is close to what you need, send me the scope and I will send back a fixed price.