ENGINEERING · SERVICE DEEP DIVE · 09-05-2026
The Steam trade bot fleet's compute layer — process model, contract surface, trade lifecycle, and the auto-QA contract.
One container = one Steam account = one Node process. TRADEIT_BOT_ID selects credentials. ~400 of these run across multiple EC2 instances.
Redis pub/sub on bot {id} (commands from tradeit-backend) + Bull queue tradebotQueue:{id} (async jobs from backend cron).
Redis state keys (sinv, stayingalive, storageItem), pub/sub events for monitoring, Bull queue tradeitQueue back to backend.
Backend does NOT subscribe to tradestate / finishedtrades / canceledtrades — state propagation back to backend goes through the tradeitQueue Bull queue. The pub/sub channels feed the frontend via tradeit-socket-server (room-scoped Socket.IO tradestate drives the trade modal; canceledtrade broadcast pops the security-alert popup). finishedtrades is currently dead code on the wire — published by the bot but no service or client subscribes.
All inter-service traffic is Redis. No HTTP server runs in this process — there is no Express, no NestJS controllers. The only HTTP outbound is Slack webhooks and partner-inventory fetches.
flowchart LR
subgraph BE["tradeit-backend"]
TS["tradeService
publishTrade / publishSale"]
CR["cron: checkRevertTrade"]
MQ["mainQueue.process"]
INV["inventoryRedis"]
HB["botsService.getActiveBots"]
OR["internal.js orphan-cleanup"]
end
BUS[("Redis pub/sub: bot id")]
Q1[("Bull: tradebotQueue:id")]
Q2[("Bull: tradeitQueue")]
RDS[("Redis keys
sinv / stayingalive / storageItem")]
SOC[("tradeit-socket-server
WebSocket")]
WEB[("new-tradeit web
(browser)")]
STM[("Steam Web API + GC")]
DB[("MySQL Aurora")]
subgraph BOT["tradeit-tradebot-server"]
DSP["redisMessage dispatcher"]
LIFE["trade lifecycle"]
INVS["refreshInv 30s"]
HBT["heartbeat 60s"]
CASK["casket store/unstore"]
BQC["tradebotQueue.process"]
BQP["tradeitQueue.add"]
end
TS --> BUS
CR --> Q1
Q2 --> MQ
RDS --> INV
RDS --> HB
RDS --> OR
BUS --> DSP
DSP --> LIFE
Q1 --> BQC
BQC --> CASK
LIFE --> BQP
BQP --> Q2
INVS --> RDS
HBT --> RDS
CASK --> RDS
LIFE -.->|"tradestate / canceledtrades"| SOC
SOC -.->|"Socket.IO"| WEB
LIFE <-->|"TradeOfferManager + GC"| STM
LIFE <-->|"trades / balance / items"| DB
All bot logic lives inside an async IIFE in src/index.ts (~3,900 lines). Functions are closures over shared state — Redis client, Steam client, in-memory caches, the loaded TradeOfferManager. They are not exportable Node modules.
Treat the line ranges below as logical "modules" but remember: any change to one can leak into another via the shared closure scope.
| Steam login & session | 127–585 |
| Casket / storage | 651–800 |
| Redis dispatcher | 910–920 |
| Trade utilities | 1007–1300 |
| Trade lifecycle (in) | 1391–2100 |
| Inventory refresh | 2280–2710 |
| CS2 (GC) | 2258–2780 |
| Trade lifecycle (out) | 3038–3530 |
| Offer cleanup | 3630–3721 |
| DB logging | 3734–3786 |
| Process signals | 3853–3861 |
| Channel | Direction | Producer | Consumer | Payload |
|---|---|---|---|---|
bot {botid} | in | tradeit-backend (tradeRedis.js) | this repo (redisMessage) | verb prefix + tradeData:{json} |
tradestate | out | this repo + old-tradeit | tradeit-socket-server → Socket.IO room scoped to steamId | { steamId, token, state: 0|1|2|3, msg } |
finishedtrades | out | this repo | (none — currently dead code) | trade key string |
canceledtrades | out | this repo | tradeit-socket-server → Socket.IO broadcast | Steam ID 64 |
steamDown | out | this repo | ops alerting | timestamp ms |
bot {botid})| Verb | Source | Action |
|---|---|---|
| trade | tradeService | swap (item-for-item or item-for-balance) |
| sale | tradeRedis.publishSale | user sells to bot |
| withdraw | tradeRedis.publishTrade | user pulls items from bot |
| delayed | trade lifecycle | trade-locked / timed item path |
| unstore | containerItemsRepo.unstoreAssetFromBot | remove items from caskets |
| checkSteamGuard | tradeService | report Steam Guard status for a steamId |
| getTradeUrlPartnerInv | tradeService | fetch public partner inventory |
| Key | Owner | Reader | Purpose | TTL |
|---|---|---|---|---|
botRefreshToken:{id} | bot | bot | Steam refresh token | persistent |
pollData:{id} | bot | bot | TradeOfferManager poll state | 3m + on shutdown |
stayingalive:{id} | bot | backend | heartbeat — backend marks dead at 180s lag | 60s rotate |
sinv:{id}:compressed | bot | backend (inventoryRedis.js:23) | compressed bot inventory | persistent |
storageItem:{id}:{casketId} | bot | backend (internal.js:563) | asset IDs in a casket | persistent |
containers:{id} | bot | bot | full casket map | 60s rewrite |
inspectResult:{assetId} | bot | backend | CS2 inspect result | 20s |
tradeUrlInv:{steamId} | bot | bot | partner inventory cache | 10s |
lockTrade:{assetId} | bot | bot | per-asset lock | persistent |
tradeinfo:{key} | both | both | per-trade staging payload | 30m |
opentrades:{id} | both | both | open-trade counter per user | persistent |
flowchart LR BE["tradeit-backend"] -->|"add: deleteContainerItem
checkRevertTrade
inspect"| Q1[("tradebotQueue:id")] Q1 -->|"process"| BOT["tradebot"] BOT -->|"add: logSold
logWithdraw
tradeBonus
tradedAssetIds
removeReserve
instantSellCompleted"| Q2[("tradeitQueue")] Q2 -->|"process: mainQueue.js"| BE2["tradeit-backend"]
| Queue | Direction | Job | Side effect |
|---|---|---|---|
tradebotQueue:{id} | backend → bot | deleteContainerItem | Bot removes item from casket after 10s + reload |
tradebotQueue:{id} | backend → bot | checkRevertTrade | Bot revalidates a recent trade for revert |
tradebotQueue:{id} | backend → bot | inspect | CS2 GC inspect → write inspectResult:{assetId} |
tradeitQueue | bot → backend | tradeBonus | Backend applies bonus + OAuth2 webhook |
tradeitQueue | bot → backend | logSold | Backend updates bot_trade_item_logs.status |
tradeitQueue | bot → backend | logWithdraw | Backend marks finished + removes reserves |
tradeitQueue | bot → backend | removeReserve | Backend cleans reserved_items + clears locks |
tradeitQueue | bot → backend | instantSellCompleted | Backend analytics tracking |
tradeitQueue | bot → backend | tradedAssetIds | Backend updates bot_trade_item_logs with new asset IDs post-trade |
A trade does not flow tradeit-backend → bot directly. It hops through the legacy monolith old-tradeit, which owns POST /trade and is the canonical Redis publisher for bot {botid} commands.
new-tradeit (browser) → tradeit-backend POST /trade (18 guards, OpenSearch + CS2 lock + block-trade) → tradeService.sendTrades → axios.post(${TI_OLD_PRIVATE_IP}/trade, ...) with siteToken → old-tradeit web.ts:1243+ (28 guards; callbacks 12-13 skipped) → redis.publish('bot {botid}', ...).
Third-party bot operators authenticate via Steam OpenID (session cookie, not API key) and POST directly to old-tradeit /trade with no siteToken. They run the full 28-guard chain including the two HTTP callbacks back into tradeit-backend (validate-csgo-items, check-block-trade).
flowchart LR WEB["new-tradeit
(browser)"] -->|"POST /trade"| BE["tradeit-backend
POST /trade"] AT["external
auto-traders"] -->|"POST /trade
(Steam session cookie)"| OLD["old-tradeit
POST /trade"] BE -->|"axios + siteToken"| OLD OLD -->|"validate-csgo-items"| BE OLD -->|"check-block-trade"| BE OLD -->|"publish bot id"| BUS[("Redis bot {id}")] BUS --> BOT["tradeit-tradebot-server"]
| Field | Type | Meaning |
|---|---|---|
tradeurl | string | Steam trade URL from user session |
cselected | string[] | Deposit items: composite key botId_gameId_contextId_assetId_itemKey_name |
sselected | string[] | Withdraw items: same shape |
token | string | Client-side trade UUID |
delayed | string | 'true' for sale/reserved-item path (currently rejected) |
bonus | string | Bonus value in cents (currently '0') |
sprice | string | Site-item total in cents |
cprice | string | User-item total in cents |
botid | string | Requested bot ID, or -1 for auto-select |
locale | string? | Language code, e.g. 'en' |
siteToken | string? | If equals TI_SITE_TOKEN, skips CS2 + block-trade callbacks (internal path) |
| Endpoint | Caller | Purpose | Returns |
|---|---|---|---|
POST /api/v2/internal/validate-csgo-items | old-tradeit web.ts:1681 | CS2 deposit-lock check | { data: [<blocked names>] } |
POST /api/v2/internal/check-block-trade | old-tradeit web.ts:1739 | Item / user block flags | { message: <reason> | null } |
Both calls are skipped when siteToken === TI_SITE_TOKEN — i.e. the modern tradeit-backend already ran its own equivalents.
Each guard either calls cancelTrade() or publishes to tradestate with a user-facing message and short-circuits. Auto-traders see every check in the list.
| # | Guard | File:line | Failure |
|---|---|---|---|
| 1 | No session | web.ts:1079 | 'no active session' |
| 2 | Queue full (>80) | web.ts:1442 | 'Trade request queue is full' |
| 3 | allDisabled | web.ts:1470 | 'Site maintenance. Trading is disabled.' |
| 4 | tradeDisabled (Steam lag) | web.ts:1489 | 'Steam is lagging. Trading is disabled.' |
| 5 | Large trades disabled | web.ts:1514 | 'Trading is currently under maintenance' |
| 6 | Banned user | web.ts:1535 | 'Banned.' |
| 7 | Dota2 withdraw | web.ts:1553 | 'Dota2 trades are disabled' |
| 8 | Dota2 deposit | web.ts:1575 | 'Dota2 trades are disabled' |
| 9 | TF2 Unusuals deposit | web.ts:1593 | 'TF2 Unusuals deposits are disabled temporarily' |
| 10 | >100 items per side | web.ts:1614 | 'Trade too large. Max 100 items per side.' |
| 11 | User inv cache miss | web.ts:1635 | warn only |
| 12 | CS2 deposit-lock callback | web.ts:1681 | 'Following items are not tradeable at the moment: …' |
| 13 | Block-trade callback | web.ts:1739 | uses returned message |
| 14 | Spam rate-limit (500ms) | web.ts:1779 | 'Too many trades at one time' |
| 15 | Reserve-item rate-limit (50ms) | web.ts:1825 | 'Rate limit' |
| 16 | Trade value limit | web.ts:1865 | 'Trade value limited' |
| 17 | Trade URL verification | web.ts:1884 | silent if URL stale |
| 18 | Bot down | web.ts:1893 | 'This bot is currently down. Try trading with another bot.' |
| 19 | Open-trades quota per bot | web.ts:1931 | per-bot cap |
| 20 | Reserved-items DB ownership | web.ts:1940 | withdraw-only flow |
| 21 | Live price recheck | web.ts:2078 | recomputes server-side |
| 22 | Price-difference cap >$20k | web.ts:2112 | 'Difference too large.' (dev IDs exempt) |
| 23 | delayed === 'true' | web.ts:2137 | cancels |
| 24 | Insufficient balance | web.ts:2176 | 'Insufficient balance.' |
| 25 | Daily withdraw limit | web.ts:2152 | daily security limit |
| 26 | Daily balance limit | web.ts:2165 | same daily-cap path |
| 27 | Large-trade caps | web.ts:2200 (logic 1309-1394) | ≤ 15/h, ≤ 30/day, ≤ 3/min, ≤ 2M cents/day |
| 28 | Balance-transfer failure | web.ts:2214 | generic balance error |
Modern entry point — runs before old-tradeit is called. tradeit-backend/server/controllers/trade.js.
| # | Guard | File:line | Check |
|---|---|---|---|
| 1 | Per-user 3s rate-limit | :287 | Redis tempLimitedTrade:{steamId} |
| 2 | Trades-disabled config | :301 | DB tradesDisabled / largeTradesDisabled |
| 3 | Missing session | :326 | No Steam ID / trade URL |
| 4 | No items selected | :331 | Both arrays empty |
| 5 | Negative balance | :335 | balance < 0 |
| 6 | Insufficient balance | :336 | siteSelectedPrice + userSelectedPrice vs balance |
| 7 | Mixed-game cart (DEV-4815) | :369 | >1 steamAppId in cart |
| 8 | Price-change detected | :375 | sellService.validateIsPriceChange() |
| 9 | OpenSearch lookup miss | :380 | queryByAssetIds() empty |
| 10 | Block-trade flag | :382 | tradeService.checkBlockTradeAndGetMessage() |
| 11 | onlyStore items | :387 | 'Items not able to trade' |
| 12 | User inv cache miss | :393 | 'Your session has expired' |
| 13 | User asset-IDs not in inv | :398 | ReturnableError('User items not found') |
| 14 | CS2 deposit-lock validation | :425 | tradeService.validateCSGOItems() |
| 15 | Withdraw-limited items | :454 | tradeService.validateWithdrawLimitedItems() |
| 16 | Trade-lock time | :482 | virtual trades only |
| 17 | Daily trade limit | userLimitationService.checkTradeLimit | per-user daily cap |
| 18 | Daily sent-value limit | userLimitationService.checkSendTradeLimit | per-user daily cap |
If 1-18 pass, tradeService.createTrades locks site items in Redis (SETNX), picks bot tier (veryGoodBots → goodBots → badBots), then axios.posts the request to old-tradeit with siteToken. The actual Redis publish happens inside old-tradeit.
User trades items into a bot for balance.
sequenceDiagram
autonumber
participant U as User
participant FE as new-tradeit
participant BE as tradeit-backend
participant R as Redis
participant BOT as tradebot
participant ST as Steam
U->>FE: Click "Deposit"
FE->>BE: POST /trade
BE->>R: reserve items (lock assetIds)
BE->>R: publish bot {id} "trade ..."
R-->>BOT: redisMessage dispatcher
BOT->>ST: TradeOfferManager createOffer + send
ST-->>BOT: offer accepted (newOffer event)
BOT->>BOT: validate items + accept confirmation
BOT->>R: publish tradestate
BOT->>BE: Bull tradeitQueue.add("logSold", ...)
BE->>R: release reservation
BE-->>U: WebSocket "completed" via socket-server
User pulls items they own out of a bot.
sequenceDiagram
autonumber
participant U as User
participant BE as tradeit-backend
participant R as Redis
participant BOT as tradebot
participant ST as Steam
BE->>BE: select bot tier (very-good → good → bad)
BE->>R: publish bot {id} "withdraw ..."
R-->>BOT: redisMessage dispatcher
BOT->>BOT: createAndSendOffer (verifyBotItems + verifyUserItems)
BOT->>ST: send trade offer to user
BOT->>BE: Bull tradeitQueue.add("logWithdraw", ...)
Note over BOT,ST: User accepts (or expires)
BOT->>BOT: sentOfferChanged → updateTradeInfo
BOT->>BE: Bull tradeitQueue.add("removeReserve" + "tradedAssetIds")
BOT->>R: publish finishedtrades / canceledtrades
Sales fork into four subtypes — three list the item and wait for a buyer; INSTANT_SELL finalizes immediately.
flowchart TD
A["sale command"] --> B{"subtype"}
B -->|"INSTANT_SELL"| C["mark SOLD
immediately"]
B -->|"TRADE_BALANCE"| D["mark LISTED"]
B -->|"STORE_BALANCE"| D
B -->|"P2P_SELL"| D
C --> E["Bull tradeitQueue
logSold + instantSellCompleted"]
D --> F["wait for buyer trade event"]
F --> G["on buyer accept:
logSold + tradedAssetIds"]
| SDK | Event | Effect |
|---|---|---|
steam-user | loggedOn | After 10s, load games (CS2 730 / TF2 440 / Dota2 570) |
steam-user | webSession | Capture cookies + API key |
steam-user | refreshToken | Persist botRefreshToken:{id} |
steam-user | disconnected / error | Log + retry (with 30s login rate-limit) |
tf2 | accountLoaded / backpackLoaded | Persist invsize440:{id} |
globaloffensive | connectedToGC / itemAcquired / itemRemoved | CS2 inventory delta tracking |
steam-tradeoffer-manager | pollData | Persist pollData:{id} (throttled 3m) |
steam-tradeoffer-manager | sentOfferChanged | Update bot_trades_sent; publish tradestate |
steam-tradeoffer-manager | newOffer | Validate, accept/reject, log Steam Guard escrow |
Frequency relative to a 1-hour window. Higher bar = more frequent.
csgo_collectionsitem_typesconfigurationsbot_accountscontainer_item + container_item_transactionsbot_users + balance_transactionssaleoffer_trades / sale_offers / sell_offersbot_trades + bot_trades_sentdelayed_trades / distribution_queue / reserved_items Driver gotcha: uses the legacy callback-based mysql package (NOT mysql2), wrapped in a manual queryPromise. Connection pool defined in src/configure.ts.
configurations table)Runtime tunables that gate the trade flow. Stored as a key-value table; loaded with a 2-tier cache in tradeit-backend (60s in-memory + 3m Redis at configuration:{key}), uncached in old-tradeit (per-request DB read), no active reads in this repo. Auto-mutating keys are written by userLimitationService when hourly value caps are breached (1s async write + Redis purge + Slack alert).
| Key | Type | Reader | Behaviour |
|---|---|---|---|
siteDisabled | bool 0/1 | old-tradeit web.ts:638 | Site-wide maintenance — every request returns the maintenance page. Manual ops flip. |
tradesDisabled | bool 0/1 | tradeit-backend trade.js:303, userLimitationService.js:357 | Global trade kill-switch. Auto-set when hourly outbound > hourlyLimitHardOutValue. |
largeTradesDisabled | bool 0/1 | tradeit-backend trade.js:304, userLimitationService.js:332,398 | Selectively blocks trades > largeTradeValue. Auto-set when hourly outbound > hourlyLimitSoftOutValue. |
largeTradeValue | int (cents) | tradeit-backend trade.js:302, userLimitationService.js:325,406 | Threshold separating "normal" vs "large" trades. |
disableOtherGames | bool 0/1 | tradeit-backend userLimitationService.js:377 | Auto-set when hourlyLimitHardOutValueOtherGames exceeded. Blocks Rust / Dota2 / TF2 (CS2 only). |
| Key | Reader | Behaviour |
|---|---|---|
hourlyLimitSoftOutValue | userLimitationService.js:327,333 | Soft outbound ceiling — flips largeTradesDisabled on breach. |
hourlyLimitHardOutValue | userLimitationService.js:328,334 | Hard outbound ceiling — flips tradesDisabled on breach. |
hourlyLimitHardOutValueOtherGames | userLimitationService.js:329,335 | Hard ceiling for non-CS2 games — flips disableOtherGames. |
| Key | Reader | Behaviour |
|---|---|---|
sentTradesValueLimit_<level> | tradeit-backend enums.js:755-757, old-tradeit web.ts:4006 | Per-level daily margin cap on outbound trades. |
balanceLimitLevel_<level> | tradeit-backend userLimitationService.js:99-100 | Per-level max balance — gates top-ups. |
tradeLimitLevel_<level> | tradeit-backend userLimitationService.js:112-118 | Per-level daily trade-count cap. |
saleWithdrawLimit_<level> | tradeit-backend enums.js:758-762 | Per-level daily withdrawal cap on sale earnings. |
saleWithdrawStoreLimit_<level> | tradeit-backend enums.js:762-765 | Per-level daily withdrawal cap on store-balance sale earnings. |
| Key | Reader | Behaviour |
|---|---|---|
maxTopupBalanceDaily | userLimitationService.js:68-71 | Site-wide daily balance top-up cap. |
maxTopupStoreBalanceDaily | userLimitationService.js:43-46 | Same, for store-balance top-ups. |
isSaleDisabled | tradeit-backend enums.js:774 | Disables P2P sell + listing (checked in sellService). |
isInstantSellDisabled | tradeit-backend enums.js:775 | Disables instant-sell fast-path. |
isSellBalanceDisabled | tradeit-backend enums.js:776 | Disables balance withdrawals from sale earnings. |
botDisabled | tradeit-backend enums.js:750 | Defined; reserved for bot suspension. Not yet on the trade hot path. |
Cache propagation gotcha: there is no pub/sub invalidation across instances. Each process polls Redis independently and self-evicts on its own TTL. A manual SQL update can take up to 3 minutes to take effect site-wide. updateValueByKey() mutations explicitly purge the Redis key — use it instead of raw SQL for incident-response toggles.
For the tradeit-auto-qa project: the synthetic bot must implement this surface to be substitutable for a real Steam-bot process. Anything outside this surface (Steam SDK internals, casket disk persistence) is implementation detail the synthetic bot is free to mock.
bot {botid} — parse verb prefixes (trade/sale/withdraw/delayed/unstore/checkSteamGuard/getTradeUrlPartnerInv) and the tradeData:{json} suffix.tradebotQueue:{botid} — process deleteContainerItem, checkRevertTrade, inspect.stayingalive:{botid} set every 60s (backend marks dead at 180s lag).sinv:{botid}:compressed with the compressed inventory schema (Game/Item/ItemD in src/util.ts).storageItem:{botid}:{casketId} JSON array of asset IDs whenever caskets change.tradestate / finishedtrades / canceledtrades per lifecycle event.tradeitQueue: logSold / logWithdraw / tradeBonus / tradedAssetIds / removeReserve / instantSellCompleted.bot_trades / bot_trades_sent / sell_offers / saleoffer_trades per trade type.reserved_items, container_item, bot_users (balance) per outcome.inspect jobs. If the synthetic bot upholds this contract, the rest of the platform (tradeit-backend, tradeit-socket-server, new-tradeit) cannot tell it apart from a real bot.
| Symptom | Likely cause | Fast check |
|---|---|---|
| Bot offline in admin panel | stayingalive:{id} lag > 180s | redis-cli GET stayingalive:{id} |
| Withdrawals stuck for one user | reserve lock not released | redis-cli GET lockTrade:{assetId} |
| Inventory not updating | refreshInv interval blocked | container logs for "refreshInv" |
| Confirmations piling up | shared secret mismatch / clock skew | EC2 NTP + SHARED_SECRET env |
| Bot login loop | Steam rate limit / refresh token invalid | botLastDoLogin:{id} + clear botRefreshToken:{id} |
inspect jobs slow | CS2 GC reconnect | look for csgo.connectedToGC log |
| Bull queue backlog | Bot process stuck mid-job | tradeit-backend Bull dashboard / tradebotQueue:{id} length |
| MySQL pool exhaustion | Long-running query in queryPromise | Datadog APM tradeit-tradebot-server service |
tradeit-tradebot-server/CLAUDE.md — AI-optimized contract reference. Use as ground truth when generating the auto-QA synthetic bot.
Generated 09-05-2026 · tradeit.gg engineering