tradeit.gg

ENGINEERING · SERVICE DEEP DIVE · 09-05-2026

tradeit-tradebot-server

The Steam trade bot fleet's compute layer — process model, contract surface, trade lifecycle, and the auto-QA contract.

~400
Bots in fleet
1:1
Process : Steam acct
3,869
Lines in index.ts
9
Bull job types
0
Tests

Contents


TL;DR

Topology

One container = one Steam account = one Node process. TRADEIT_BOT_ID selects credentials. ~400 of these run across multiple EC2 instances.

Inputs

Redis pub/sub on bot {id} (commands from tradeit-backend) + Bull queue tradebotQueue:{id} (async jobs from backend cron).

Outputs

Redis state keys (sinv, stayingalive, storageItem), pub/sub events for monitoring, Bull queue tradeitQueue back to backend.

Gotcha

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.


Architecture

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

Process model

Single-file IIFE

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.

Closure-scoped modules (line ranges)

Steam login & session127–585
Casket / storage651–800
Redis dispatcher910–920
Trade utilities1007–1300
Trade lifecycle (in)1391–2100
Inventory refresh2280–2710
CS2 (GC)2258–2780
Trade lifecycle (out)3038–3530
Offer cleanup3630–3721
DB logging3734–3786
Process signals3853–3861

Redis surface

Pub/sub channels

ChannelDirectionProducerConsumerPayload
bot {botid}intradeit-backend (tradeRedis.js)this repo (redisMessage)verb prefix + tradeData:{json}
tradestateoutthis repo + old-tradeittradeit-socket-server → Socket.IO room scoped to steamId{ steamId, token, state: 0|1|2|3, msg }
finishedtradesoutthis repo(none — currently dead code)trade key string
canceledtradesoutthis repotradeit-socket-server → Socket.IO broadcastSteam ID 64
steamDownoutthis repoops alertingtimestamp ms

Inbound command verbs (channel bot {botid})

VerbSourceAction
tradetradeServiceswap (item-for-item or item-for-balance)
saletradeRedis.publishSaleuser sells to bot
withdrawtradeRedis.publishTradeuser pulls items from bot
delayedtrade lifecycletrade-locked / timed item path
unstorecontainerItemsRepo.unstoreAssetFromBotremove items from caskets
checkSteamGuardtradeServicereport Steam Guard status for a steamId
getTradeUrlPartnerInvtradeServicefetch public partner inventory

State keys

KeyOwnerReaderPurposeTTL
botRefreshToken:{id}botbotSteam refresh tokenpersistent
pollData:{id}botbotTradeOfferManager poll state3m + on shutdown
stayingalive:{id}botbackendheartbeat — backend marks dead at 180s lag60s rotate
sinv:{id}:compressedbotbackend (inventoryRedis.js:23)compressed bot inventorypersistent
storageItem:{id}:{casketId}botbackend (internal.js:563)asset IDs in a casketpersistent
containers:{id}botbotfull casket map60s rewrite
inspectResult:{assetId}botbackendCS2 inspect result20s
tradeUrlInv:{steamId}botbotpartner inventory cache10s
lockTrade:{assetId}botbotper-asset lockpersistent
tradeinfo:{key}bothbothper-trade staging payload30m
opentrades:{id}bothbothopen-trade counter per userpersistent

Bull queue topology

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"]

Job catalog

QueueDirectionJobSide effect
tradebotQueue:{id}backend → botdeleteContainerItemBot removes item from casket after 10s + reload
tradebotQueue:{id}backend → botcheckRevertTradeBot revalidates a recent trade for revert
tradebotQueue:{id}backend → botinspectCS2 GC inspect → write inspectResult:{assetId}
tradeitQueuebot → backendtradeBonusBackend applies bonus + OAuth2 webhook
tradeitQueuebot → backendlogSoldBackend updates bot_trade_item_logs.status
tradeitQueuebot → backendlogWithdrawBackend marks finished + removes reserves
tradeitQueuebot → backendremoveReserveBackend cleans reserved_items + clears locks
tradeitQueuebot → backendinstantSellCompletedBackend analytics tracking
tradeitQueuebot → backendtradedAssetIdsBackend updates bot_trade_item_logs with new asset IDs post-trade

Trade entry flow

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.

Web users

new-tradeit (browser) → tradeit-backend POST /trade (18 guards, OpenSearch + CS2 lock + block-trade) → tradeService.sendTradesaxios.post(${TI_OLD_PRIVATE_IP}/trade, ...) with siteTokenold-tradeit web.ts:1243+ (28 guards; callbacks 12-13 skipped) → redis.publish('bot {botid}', ...).

External auto-traders

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"]

old-tradeit POST /trade payload (web.ts:1243-1254)

FieldTypeMeaning
tradeurlstringSteam trade URL from user session
cselectedstring[]Deposit items: composite key botId_gameId_contextId_assetId_itemKey_name
sselectedstring[]Withdraw items: same shape
tokenstringClient-side trade UUID
delayedstring'true' for sale/reserved-item path (currently rejected)
bonusstringBonus value in cents (currently '0')
spricestringSite-item total in cents
cpricestringUser-item total in cents
botidstringRequested bot ID, or -1 for auto-select
localestring?Language code, e.g. 'en'
siteTokenstring?If equals TI_SITE_TOKEN, skips CS2 + block-trade callbacks (internal path)

Internal HTTP callbacks (old-tradeit → tradeit-backend)

EndpointCallerPurposeReturns
POST /api/v2/internal/validate-csgo-itemsold-tradeit web.ts:1681CS2 deposit-lock check{ data: [<blocked names>] }
POST /api/v2/internal/check-block-tradeold-tradeit web.ts:1739Item / 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.


old-tradeit guard chain (28 in order)

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.

#GuardFile:lineFailure
1No sessionweb.ts:1079'no active session'
2Queue full (>80)web.ts:1442'Trade request queue is full'
3allDisabledweb.ts:1470'Site maintenance. Trading is disabled.'
4tradeDisabled (Steam lag)web.ts:1489'Steam is lagging. Trading is disabled.'
5Large trades disabledweb.ts:1514'Trading is currently under maintenance'
6Banned userweb.ts:1535'Banned.'
7Dota2 withdrawweb.ts:1553'Dota2 trades are disabled'
8Dota2 depositweb.ts:1575'Dota2 trades are disabled'
9TF2 Unusuals depositweb.ts:1593'TF2 Unusuals deposits are disabled temporarily'
10>100 items per sideweb.ts:1614'Trade too large. Max 100 items per side.'
11User inv cache missweb.ts:1635warn only
12CS2 deposit-lock callbackweb.ts:1681'Following items are not tradeable at the moment: …'
13Block-trade callbackweb.ts:1739uses returned message
14Spam rate-limit (500ms)web.ts:1779'Too many trades at one time'
15Reserve-item rate-limit (50ms)web.ts:1825'Rate limit'
16Trade value limitweb.ts:1865'Trade value limited'
17Trade URL verificationweb.ts:1884silent if URL stale
18Bot downweb.ts:1893'This bot is currently down. Try trading with another bot.'
19Open-trades quota per botweb.ts:1931per-bot cap
20Reserved-items DB ownershipweb.ts:1940withdraw-only flow
21Live price recheckweb.ts:2078recomputes server-side
22Price-difference cap >$20kweb.ts:2112'Difference too large.' (dev IDs exempt)
23delayed === 'true'web.ts:2137cancels
24Insufficient balanceweb.ts:2176'Insufficient balance.'
25Daily withdraw limitweb.ts:2152daily security limit
26Daily balance limitweb.ts:2165same daily-cap path
27Large-trade capsweb.ts:2200 (logic 1309-1394)≤ 15/h, ≤ 30/day, ≤ 3/min, ≤ 2M cents/day
28Balance-transfer failureweb.ts:2214generic balance error

tradeit-backend guard chain (18 in order)

Modern entry point — runs before old-tradeit is called. tradeit-backend/server/controllers/trade.js.

#GuardFile:lineCheck
1Per-user 3s rate-limit:287Redis tempLimitedTrade:{steamId}
2Trades-disabled config:301DB tradesDisabled / largeTradesDisabled
3Missing session:326No Steam ID / trade URL
4No items selected:331Both arrays empty
5Negative balance:335balance < 0
6Insufficient balance:336siteSelectedPrice + userSelectedPrice vs balance
7Mixed-game cart (DEV-4815):369>1 steamAppId in cart
8Price-change detected:375sellService.validateIsPriceChange()
9OpenSearch lookup miss:380queryByAssetIds() empty
10Block-trade flag:382tradeService.checkBlockTradeAndGetMessage()
11onlyStore items:387'Items not able to trade'
12User inv cache miss:393'Your session has expired'
13User asset-IDs not in inv:398ReturnableError('User items not found')
14CS2 deposit-lock validation:425tradeService.validateCSGOItems()
15Withdraw-limited items:454tradeService.validateWithdrawLimitedItems()
16Trade-lock time:482virtual trades only
17Daily trade limituserLimitationService.checkTradeLimitper-user daily cap
18Daily sent-value limituserLimitationService.checkSendTradeLimitper-user daily cap

If 1-18 pass, tradeService.createTrades locks site items in Redis (SETNX), picks bot tier (veryGoodBotsgoodBotsbadBots), then axios.posts the request to old-tradeit with siteToken. The actual Redis publish happens inside old-tradeit.


Deposit lifecycle

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
      

Withdrawal lifecycle

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
      

Sale subtype dispatch

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"]

Steam SDK event handlers

SDKEventEffect
steam-userloggedOnAfter 10s, load games (CS2 730 / TF2 440 / Dota2 570)
steam-userwebSessionCapture cookies + API key
steam-userrefreshTokenPersist botRefreshToken:{id}
steam-userdisconnected / errorLog + retry (with 30s login rate-limit)
tf2accountLoaded / backpackLoadedPersist invsize440:{id}
globaloffensiveconnectedToGC / itemAcquired / itemRemovedCS2 inventory delta tracking
steam-tradeoffer-managerpollDataPersist pollData:{id} (throttled 3m)
steam-tradeoffer-managersentOfferChangedUpdate bot_trades_sent; publish tradestate
steam-tradeoffer-managernewOfferValidate, accept/reject, log Steam Guard escrow

Interval task timeline

Frequency relative to a 1-hour window. Higher bar = more frequent.

unstoreItems
3s · 1200×/h
storeItems
10s · 360×/h
acceptConfirmations
11s · 327×/h
recheckTradeStatuses
20s · 180×/h
refreshInv
30s · 120×/h
stayingalive
60s · 60×/h
cancelExpiredOffers
60s · 60×/h
containers sync
60s · 60×/h
pollData save
3m · 20×/h
checkForUnstore
5m · 12×/h
updateGlobalItemInfo
6m · 10×/h
updateUserItems
8m · 7×/h

MySQL surface

Read-only

  • csgo_collections
  • item_types
  • configurations
  • bot_accounts

Read / write

  • container_item + container_item_transactions
  • bot_users + balance_transactions
  • saleoffer_trades / sale_offers / sell_offers
  • bot_trades + bot_trades_sent
  • delayed_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.


Configuration keys (MySQL 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).

Kill-switches & auto-mutated guards

KeyTypeReaderBehaviour
siteDisabledbool 0/1old-tradeit web.ts:638Site-wide maintenance — every request returns the maintenance page. Manual ops flip.
tradesDisabledbool 0/1tradeit-backend trade.js:303, userLimitationService.js:357Global trade kill-switch. Auto-set when hourly outbound > hourlyLimitHardOutValue.
largeTradesDisabledbool 0/1tradeit-backend trade.js:304, userLimitationService.js:332,398Selectively blocks trades > largeTradeValue. Auto-set when hourly outbound > hourlyLimitSoftOutValue.
largeTradeValueint (cents)tradeit-backend trade.js:302, userLimitationService.js:325,406Threshold separating "normal" vs "large" trades.
disableOtherGamesbool 0/1tradeit-backend userLimitationService.js:377Auto-set when hourlyLimitHardOutValueOtherGames exceeded. Blocks Rust / Dota2 / TF2 (CS2 only).

Hourly value caps (drive the auto-mutators above)

KeyReaderBehaviour
hourlyLimitSoftOutValueuserLimitationService.js:327,333Soft outbound ceiling — flips largeTradesDisabled on breach.
hourlyLimitHardOutValueuserLimitationService.js:328,334Hard outbound ceiling — flips tradesDisabled on breach.
hourlyLimitHardOutValueOtherGamesuserLimitationService.js:329,335Hard ceiling for non-CS2 games — flips disableOtherGames.

Per-user-level caps

KeyReaderBehaviour
sentTradesValueLimit_<level>tradeit-backend enums.js:755-757, old-tradeit web.ts:4006Per-level daily margin cap on outbound trades.
balanceLimitLevel_<level>tradeit-backend userLimitationService.js:99-100Per-level max balance — gates top-ups.
tradeLimitLevel_<level>tradeit-backend userLimitationService.js:112-118Per-level daily trade-count cap.
saleWithdrawLimit_<level>tradeit-backend enums.js:758-762Per-level daily withdrawal cap on sale earnings.
saleWithdrawStoreLimit_<level>tradeit-backend enums.js:762-765Per-level daily withdrawal cap on store-balance sale earnings.

Site-wide top-up & feature toggles

KeyReaderBehaviour
maxTopupBalanceDailyuserLimitationService.js:68-71Site-wide daily balance top-up cap.
maxTopupStoreBalanceDailyuserLimitationService.js:43-46Same, for store-balance top-ups.
isSaleDisabledtradeit-backend enums.js:774Disables P2P sell + listing (checked in sellService).
isInstantSellDisabledtradeit-backend enums.js:775Disables instant-sell fast-path.
isSellBalanceDisabledtradeit-backend enums.js:776Disables balance withdrawals from sale earnings.
botDisabledtradeit-backend enums.js:750Defined; 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.


Auto-QA contract surface

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.

Must consume

  1. Redis pub/sub bot {botid} — parse verb prefixes (trade/sale/withdraw/delayed/unstore/checkSteamGuard/getTradeUrlPartnerInv) and the tradeData:{json} suffix.
  2. Bull queue tradebotQueue:{botid} — process deleteContainerItem, checkRevertTrade, inspect.

Must produce

  1. stayingalive:{botid} set every 60s (backend marks dead at 180s lag).
  2. sinv:{botid}:compressed with the compressed inventory schema (Game/Item/ItemD in src/util.ts).
  3. storageItem:{botid}:{casketId} JSON array of asset IDs whenever caskets change.
  4. Pub/sub tradestate / finishedtrades / canceledtrades per lifecycle event.
  5. Bull tradeitQueue: logSold / logWithdraw / tradeBonus / tradedAssetIds / removeReserve / instantSellCompleted.

Must mirror

  1. Insert into bot_trades / bot_trades_sent / sell_offers / saleoffer_trades per trade type.
  2. Update reserved_items, container_item, bot_users (balance) per outcome.

Determinism knobs the synthetic bot should expose

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.


Operations cheat sheet

SymptomLikely causeFast check
Bot offline in admin panelstayingalive:{id} lag > 180sredis-cli GET stayingalive:{id}
Withdrawals stuck for one userreserve lock not releasedredis-cli GET lockTrade:{assetId}
Inventory not updatingrefreshInv interval blockedcontainer logs for "refreshInv"
Confirmations piling upshared secret mismatch / clock skewEC2 NTP + SHARED_SECRET env
Bot login loopSteam rate limit / refresh token invalidbotLastDoLogin:{id} + clear botRefreshToken:{id}
inspect jobs slowCS2 GC reconnectlook for csgo.connectedToGC log
Bull queue backlogBot process stuck mid-jobtradeit-backend Bull dashboard / tradebotQueue:{id} length
MySQL pool exhaustionLong-running query in queryPromiseDatadog APM tradeit-tradebot-server service

Repo memory

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