TradeRouter
Solana swap builder and limit-order engine.
Base URL: https://api.traderouter.ai
WebSocket: wss://api.traderouter.ai/ws
Website: https://traderouter.ai
Auth: None. No API key. Wallet address is the only identity.
Content-Type: All REST requests require Content-Type: application/json.
Before you use this skill
Maintaining the WebSocket connection: Limit orders, trailing orders, and order management (cancel, extend, list) require an open WebSocket connection to wss://api.traderouter.ai/ws. The server delivers order_filled only over that connection — if the client disconnects, it will not receive fills until it reconnects and re-registers. Keep the WS connection alive for the lifetime of any active limit/trailing orders so you can receive and execute fills. On disconnect, reconnect and re-register (see Reconnection); active orders persist server-side.
Authentication for order management: WebSocket order placement and cancellation are gated by a challenge–response flow: the server sends a challenge with a nonce; the client must sign the nonce with the wallet’s private key (Ed25519) and send register with wallet_address and the base58 signature. Only after the server responds with registered and authenticated: true can the client place or cancel orders. Authorization is proof-of-control of the wallet via the signed challenge — no separate API key.
Service origin: This skill documents the API only. The service website is https://traderouter.ai (API at api.traderouter.ai).
MEV protection: The POST /protect endpoint accepts signed transactions and uses Jito and a staked connection lane to process your transaction.
Risk: No API key is requested; identity is the wallet address (and for WebSocket orders, proof via the signed challenge).
When to use which endpoint
| User intent | Endpoint | Method |
|---|---|---|
| Instant buy or sell of a token | POST /swap → sign → POST /protect |
REST |
| Check wallet token balances | POST /holdings |
REST |
| Submit an already-signed transaction with MEV protection | POST /protect |
REST |
| Market cap / price for token(s) | GET /mcap?tokens=MINT1,MINT2 |
REST |
| Flex trade card image for wallet + token | GET /flex?wallet_address=…&token_address=… |
REST |
| Limit order (take-profit, stop-loss, dip buy, breakout) | WebSocket sell or buy action |
WS |
| Trailing stop (auto-adjust with market) | WebSocket trailing_sell or trailing_buy |
WS |
| TWAP (time-weighted buy/sell over duration) | WebSocket twap_buy or twap_sell |
WS |
| Manage orders (check, list, cancel, extend) | WebSocket actions | WS |
| DCA (recurring small buys) | WebSocket buy orders — see DCA section below |
WS |
POST /swap — Build unsigned swap transaction
Returns an unsigned transaction (base58). Client must sign it, then submit via POST /protect.
Sell uses holdings_percentage (bps). Buy uses amount (lamports). Never mix them.
Request
{
"wallet_address": "SOLANA_PUBKEY",
"token_address": "SPL_TOKEN_MINT",
"action": "buy",
"amount": 100000000,
"slippage": 1500
}
| Field | Type | Required | Notes |
|---|---|---|---|
| wallet_address | string | yes | Solana pubkey |
| token_address | string | yes | SPL token mint address |
| action | string | yes | "buy" or "sell" |
| amount | integer | buy only | Lamports. Only for buy. |
| holdings_percentage | integer | sell only | Bps (10000 = 100%). Only for sell. |
| slippage | integer | no | Bps, default 500 (5%). For low-liquidity or newly launched tokens, use 1500-2500 bps. 500 bps will often fail on memecoins. |
If both amount and holdings_percentage are sent, treat the request as invalid. The reference client blocks this locally via schema validation before network calls, and the API should return 422 for malformed payloads.
Success response
{
"status": "success",
"data": {
"swap_tx": "<base58_unsigned_transaction>",
"token_address": "SPL_TOKEN_MINT",
"pool_type": "raydium",
"pool_address": "POOL_PUBKEY",
"amount_in": 100000000,
"min_amount_out": 950000,
"price_impact": 0.5,
"slippage": 1500,
"decimals": 6
}
}
pool_type tells you which DEX the swap routes through (e.g. raydium, pumpswap, orca, meteora). Treat this field as an open enum and handle unknown values gracefully.
Error response
{
"status": "error",
"error": "Insufficient balance",
"code": 400
}
code is optional. Common: 422 (validation), 400 (bad request).
"Error running simulation" is usually an unsellable route at that moment (dead/rugged pool, zero effective balance, or no valid route). Do not loop retries — place the token on cooldown and retry later only if strategy requires.
POST /protect — Submit signed transaction (MEV protected)
Submit a signed transaction (base64). Blocks until confirmed on-chain. Returns signature and balance changes. Under the hood, the service uses Jito and a staked connection lane for MEV protection and submission.
⚠️ Set a 30-second timeout on /protect calls. This endpoint blocks until on-chain confirmation and can hang during network congestion.
⚠️ Encoding mismatch: /swap returns swap_tx as base58. /protect expects base64. You must convert — see the workflow section.
Request
{
"signed_tx_base64": "<base64_signed_transaction>"
}
Success response
{
"status": "success",
"signature": "5kyc5dMF1tybDcj8sVMZz3fCLbHYDczZ7A4mMu5JMPz1...",
"sol_balance_pre": 10399668919,
"sol_balance_post": 10399538835,
"token_balances": [
{
"mint": "FFKwi6dzaDmkGhtMDGKbt3HAyEYWk2BgwN4AwcWbbonk",
"balance": 25952334242,
"decimals": 6,
"balance_change": 2032594114,
"ui_amount_string": "25952.334242"
}
]
}
Use sol_balance_post and token_balances to update holdings after the swap.
Error handling
{"status":"error","error":"message"}— general error.- 503 — protect endpoint not configured on server.
submitTx()handles this automatically — falls back to direct RPC. You lose MEV protection but the transaction still goes through. - Timeout — the tx may have landed on-chain. Check tx status via RPC before retrying.
POST /holdings — Scan wallet token balances
Returns token holdings with liquid DEX pool info. Set HTTP timeout to at least 100 seconds — this endpoint scans all token accounts and can be slow.
Request
{
"wallet_address": "SOLANA_PUBKEY"
}
Response
Empty wallet: {}
Wallet with holdings:
{
"data": [
{
"address": "SPL_TOKEN_MINT",
"valueNative": 1500000000,
"amount": 25952334242,
"decimals": 6
}
]
}
/holdings is intended to return sellable tokens, but keep a defensive valueNative > MIN_VALUE_NATIVE filter (default 0, i.e. valueNative > 0) in case stale or edge-case entries appear. The reference client's getHoldings() applies this filter automatically.
GET /mcap — Market cap data
Return market cap (and optional price/pool) for given token addresses.
Request: GET https://api.traderouter.ai/mcap?tokens=MINT1,MINT2 (comma-delimited Solana mint addresses).
Response: Object keyed by token address. Each value can include marketCap, pair_address, pool_type, priceUsd. Empty object if no tokens provided or none found.
GET /flex — Flex trade card PNG
Generate a flex trade card image for a wallet and token mint.
Request: GET https://api.traderouter.ai/flex?wallet_address=WALLET&token_address=MINT.
Response: image/png. 400 on invalid params; 501 if flex_card_image deps not available; 500 on server error.
Instant swap workflow (step by step)
The encoding changes: /swap returns base58, /protect expects base64. Do not send base58 to /protect.
POST /swapwith wallet_address, token_address, action, amount or holdings_percentage, slippage- Read
data.swap_txfrom response — this is base58 encoded - Decode from base58 into raw bytes
- Deserialize as VersionedTransaction
- Sign with wallet private key
- Re-serialize the signed transaction into bytes
- Encode as base64
- Submit via
submitTx(signedBase64)— this calls/protectfirst (30s timeout), auto-falls back to RPC on 503 - On success:
signature= tx hash, usesol_balance_postandtoken_balancesto update state - On timeout:
submitTxchecks if tx landed via RPC before falling back — no manual handling needed
WebSocket — Limit and trailing orders
URL: wss://api.traderouter.ai/ws
You must keep the WebSocket connection open for limit and trailing orders to work: the server sends order_filled only over this connection. If the connection drops, you will not receive fills until you reconnect and re-register. Maintain the connection for as long as you have active orders that you want to receive and execute.
Server monitors market cap every ~5 seconds. When target is crossed, server pushes order_filled with an unsigned swap transaction to sign and submit.
Reference implementation: Follow the flow below (challenge → register with signature, verification of order_filled and order_created). Use the canonical payloads, params_hash encoding, and Ed25519 verification rules in this skill as the source of truth.
Connection sequence (MUST follow this exact order)
The server sends a challenge on connect (not subscribed). Registration is challenge–response only; there is no unauthenticated path for placing orders.
- Connect to
wss://api.traderouter.ai/ws - Server sends:
{"type": "challenge", "nonce": "<nonce>", "message": "..."}. The current protocol always sendschallengeas the first message. - Client: Sign the nonce (UTF-8 bytes) with the wallet's private key (Ed25519). You must have the wallet private key to use the WebSocket for orders; without it you cannot register successfully.
- Client sends:
{"action": "register", "wallet_address": "<SOLANA_PUBKEY>", "signature": "<base58>"}. The signature is the base58-encoded result of signing the nonce. If you omit signature after a challenge, the server responds with{"type": "error", "message": "Missing signature. Sign the challenge nonce and send register with wallet_address and signature."}and you will not be authenticated. - Server sends:
{"type": "registered", "wallet_address": "<pubkey>", "authenticated": true}. - Only after receiving
registeredwithauthenticated: truemay you send order actions. Sending order actions before that returns{"type": "error", "message": "Not authenticated. Register with a valid signature to place or manage orders."}.
Do NOT send any order actions before receiving {"type": "registered", "authenticated": true}. Plain {"action": "register", "wallet_address": "..."} without a signature will fail when the server has sent a challenge.
Reconnection
On WebSocket disconnect:
- Reconnect to
wss://api.traderouter.ai/ws - Server sends a new challenge (new nonce). Send
{"action": "register", "wallet_address": "...", "signature": "<base58 of nonce signed with wallet>"}. - Wait for
{"type": "registered", "authenticated": true} - Check for any pending
order_filledmessages - Use the staleness check (
triggered_mcap / filled_mcap < 0.85) to skip stale fills
Active orders persist server-side — you do not need to re-place them after reconnect.
Limit sell (take-profit or stop-loss)
{
"action": "sell",
"token_address": "SPL_TOKEN_MINT",
"holdings_percentage": 10000,
"target": 20000,
"slippage": 1500,
"expiry_hours": 144
}
target (often named targetMcapBps in client code) is bps vs current mcap at order placement time (not your wallet entry price). Any value > 0. Sell target > 10000 = take-profit (e.g. 20000 = mcap doubles). Sell target < 10000 = stop-loss (e.g. 5000 = mcap halves).
Limit buy (dip buy or breakout entry)
{
"action": "buy",
"token_address": "SPL_TOKEN_MINT",
"amount": 100000000,
"target": 5000,
"slippage": 1500,
"expiry_hours": 144
}
target (often named targetMcapBps in client code) is bps vs current mcap at order placement time (not your wallet entry price). Any value > 0. Buy target < 10000** = dip buy (e.g. 5000 = mcap halves). **Buy target > 10000 = breakout entry (e.g. 20000 = mcap doubles).
Trailing sell / Trailing buy
{
"action": "trailing_sell",
"token_address": "SPL_TOKEN_MINT",
"holdings_percentage": 10000,
"trail": 1000,
"slippage": 1500,
"expiry_hours": 144
}
trail is bps — percentage callback from peak before triggering.
Example: trail: 1000 (10%). Token mcap peaks at $100k. Sell triggers when mcap drops to $90k (10% below peak). If mcap later peaks at $150k, the trigger moves up to $135k.
Replace trailing_sell with trailing_buy and holdings_percentage with amount for trailing buy. For trailing buy, the trigger works in reverse: if mcap bottoms at $50k, a 10% trail triggers when mcap rises to $55k.
Order management actions
{"action": "check_order", "order_id": "ORDER_ID"}
{"action": "list_orders"}
{"action": "cancel_order", "order_id": "ORDER_ID"}
{"action": "extend_order", "order_id": "ORDER_ID", "expiry_hours": 336}
TWAP (time-weighted average price)
twap_buy and twap_sell split a total quantity into frequency equal slices executed every duration / frequency seconds. duration is in seconds (min 60, max 30 days). There is no separate expiry — the order lives exactly duration seconds.
twap_sell: Either quantity (raw token units) or holdings_percentage (bps, e.g. 5000 = 50%). If using holdings_percentage, the server resolves it once at order creation to a fixed token amount, then divides by frequency per slice.
{
"action": "twap_sell",
"token_address": "SPL_TOKEN_MINT",
"frequency": 5,
"duration": 3600,
"holdings_percentage": 5000,
"slippage": 500
}
twap_buy: Use quantity (SOL lamports) as total to spend over the duration.
{
"action": "twap_buy",
"token_address": "SPL_TOKEN_MINT",
"frequency": 5,
"duration": 3600,
"quantity": 1000000000,
"slippage": 500
}
Server messages: twap_order_created when accepted; twap_execution for each slice (includes execution_num, executions_total, executions_remaining, next_execution_at; when status is success, data.swap_tx and server_signature — verify signature then sign and submit like order_filled); twap_order_completed when all slices are done. On cancel_order for a TWAP order, server responds with twap_order_cancelled. Verify twap_execution.server_signature (same trust anchor as order_filled; MCP may use a dedicated signer for the twap slice payload) before signing/submitting each slice.
Order expiry
Orders silently expire when expiry_hours is reached — the server does not send an expiry event. To detect expired orders, periodically call check_order or list_orders. Expired orders will no longer appear in results.
All WebSocket actions reference
| Action | Required fields | Optional |
|---|---|---|
| register | wallet_address | signature (required when server sent challenge; base58 of nonce signed with wallet) |
| sell | token_address, holdings_percentage (bps), target, slippage | expiry_hours (default 144), wallet_address |
| buy | token_address, amount (lamports), target, slippage | expiry_hours, wallet_address |
| trailing_sell | token_address, holdings_percentage, trail (bps), slippage | expiry_hours |
| trailing_buy | token_address, amount, trail (bps), slippage | expiry_hours |
| check_order | order_id | — |
| list_orders | — | wallet_address |
| cancel_order | order_id | — |
| extend_order | order_id, expiry_hours (max 336) | — |
| twap_sell | token_address, frequency, duration, quantity or holdings_percentage (bps) | slippage (default 500) |
| twap_buy | token_address, frequency, duration, quantity (SOL lamports) | slippage (default 500) |
expiry_hours: default 144, max 336.
Server → client message types
| type | Payload fields | Description |
|---|---|---|
| challenge | nonce, message | Sent on connect; client must sign nonce and send register with wallet_address + signature |
| registered | wallet_address, authenticated | Registration confirmed; only when authenticated true can client send order actions |
| order_created | order_id, order_type, token_address, entry_mcap, target_mcap, target_bps (limit), trail_bps (trailing), slippage, expiry_hours, amount, holdings_percentage, params_hash, server_signature | Order accepted; when params_hash and server_signature are present, verify server_signature over params_hash (Rec 2) — see Verifying server signatures |
| order_filled | order_id, order_type, status, token_address, entry_mcap, triggered_mcap, filled_mcap, target_mcap, triggered_at, filled_at, server_signature, already_dispatched, data (optional; when already_dispatched false: data.swap_tx base58) | Target hit — verify server_signature, then sign data.swap_tx and submit; when already_dispatched true, data/swap_tx may be omitted (idempotent ack) |
| twap_order_created | order_id, order_type, token_address, frequency, duration, interval_seconds, amount_per_execution, original_quantity, expires_at, slippage, holdings_percentage (optional) | TWAP order accepted |
| twap_execution | order_id, order_type, status, token_address, execution_num, executions_total, executions_remaining, next_execution_at, server_signature, data (optional), error (optional) | One TWAP slice — verify server_signature, then sign data.swap_tx and submit when status success |
| twap_order_completed | order_id, order_type, token_address, executions_completed, status | All TWAP slices done |
| twap_order_cancelled | order_id, status | TWAP order cancelled (response to cancel_order) |
| order_status | order_id, status | Response to check_order |
| order_list | orders[] | Response to list_orders |
| order_cancelled | order_id | Order cancelled |
| order_extended | order_id | TTL extended |
| error | message | Error description |
| heartbeat | — | Keepalive, ignore |
WebSocket authentication (required for orders)
The server sends a challenge with a nonce on connect. To place or manage orders you must:
- Sign the nonce (as UTF-8 bytes) with the wallet's private key (Ed25519).
- Send one message:
{"action": "register", "wallet_address": "<pubkey>", "signature": "<base58 signature>"}. There is no separateauthaction — the signature is sent in the sameregistermessage. - Wait for
{"type": "registered", "authenticated": true}. Only then send order actions.
If you send register without signature after a challenge, the server responds with an error and does not set authenticated: true. Unauthenticated sessions cannot place or manage orders.
Verifying server signatures (order_filled and order_created)
Trust anchor — do not fetch from the server. The server public key must be a hardcoded or preconfigured trust anchor. Never fetch it from the same server at runtime (e.g. GET /security) to verify that server's messages; that is a TOCTOU vulnerability. Use a hardcoded default and allow override via TRADEROUTER_SERVER_PUBKEY (base58). Use this key to verify all server signatures (Ed25519, base58 decode key and signature).
Key rotation: Support a second key via TRADEROUTER_SERVER_PUBKEY_NEXT. On verification failure with the current key, try the next key; if the next key succeeds, the server has rotated — update your primary key and treat the order as valid. Document rotation at https://api.traderouter.ai/security.
Rejection when signature is required: The server may require a valid server_signature on every order_filled (TRADEROUTER_REQUIRE_SERVER_SIGNATURE, default true). For order_created, clients can require a params commitment (TRADEROUTER_REQUIRE_ORDER_CREATED_SIGNATURE, default true); if required and the server omits params_hash/server_signature, reject the order. If signature is present but verification fails, reject the fill or order.
order_filled.server_signature: The server signs a canonical JSON payload. Build the payload from the message using only these keys (include a key only if present and not null): order_id, order_type, status, token_address, entry_mcap, triggered_mcap, filled_mcap, target_mcap, triggered_at, filled_at, data. Serialize with sorted keys (recursive for nested objects) and no extra whitespace, and ensure_ascii (escape non-ASCII as \uXXXX); e.g. Python: json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True); then SHA-256 of the UTF-8 bytes. The server's Ed25519 signature (base58) is over this digest. Verify with the server public key (base58 decode key and signature, verify digest with Ed25519). Always verify before signing or submitting the fill. If verification fails or server_signature is missing when the server is expected to send it, do not sign/submit. If already_dispatched is true, skip sign/submit (idempotent ack).
order_created.server_signature (Rec 2): When the server includes params_hash and server_signature in order_created, it is committing to the order parameters. The params_hash is the SHA-256 hex of a pipe-delimited canonical string: for limit orders order_id|token_address|order_type|target_bps|slippage|expiry_hours|amount|holdings_percentage; for trailing orders the same but trail_bps instead of target_bps. The server signs the digest SHA-256(params_hash_hex.encode("utf-8")). Verify with the server public key (base58 decode key and signature, verify digest with Ed25519). If present and verification fails, treat the order as untrusted.
Handling order_filled
When order_filled arrives:
- Idempotency: If
already_dispatchedis true, skip sign/submit; treat as idempotent ack (fill was already sent). Log and exit. - Verify: Verify
server_signatureusing the configured trust anchor (see "Verifying server signatures" above). On failure or if signature is missing when required, log and do not sign/submit. - Read
order_idfrom the message — use for logging and correlation throughout - Read
data.swap_tx— this is base58 unsigned (whenalready_dispatchedis false; when true,dataordata.swap_txmay be omitted) - Decode from base58 into raw bytes
- Deserialize as VersionedTransaction
- Sign with client wallet
- Re-serialize signed transaction into bytes
- Encode as base64
- Submit via
submitTx(signedBase64)— handles /protect + fallback internally - Log
order_id+signaturetogether for audit trail - Use response to update holdings
Idempotency: Duplicate or late order_filled messages may have already_dispatched: true and no data.swap_tx; skip sign/submit and update local state only.
Logging: For each order_filled, log at least: received (order_id, order_type, token); if skipped (already_dispatched or verify failed) log reason; on submit log order_id + signature for audit.
⚠️ filled_mcap can be 0 or null. If triggered_mcap exists but filled_mcap is 0/null, the fill is still valid — the transaction will work, but mcap data at fill time is unreliable. Don't reject fills based on filled_mcap alone.
Staleness check: Apply to every order_filled, not only after reconnect. If triggered_mcap and filled_mcap are both present and filled_mcap > 0, and triggered_mcap / filled_mcap < 0.85, treat the fill as stale and consider skipping (do not sign/submit). Divide-by-zero: If filled_mcap is 0 or null, do not apply the ratio; the fill is not stale by this check. Proceed with verification and sign/submit as normal.
holdings_percentage resolves at execution time
For limit sell and trailing sell orders, holdings_percentage is calculated when the order triggers, not when placed. If you sell 50% of a token via instant swap, a pending order with holdings_percentage: 10000 (100%) will sell 100% of the remaining balance, not the original amount. This is a feature — it accounts for partial sells between placement and execution.
DCA (Dollar-Cost Averaging)
DCA is implemented as repeated limit buy orders. It is not automatic chaining — each fill requires agent action:
- Place a
buyorder via WebSocket with the desiredamountandtarget - When
order_filledarrives, sign theswap_txand submit viasubmitTx()(follow the base58→base64 steps above) - After successful submission, place the next
buyorder - Repeat for as many intervals as desired
The server does not auto-chain orders. Each fill triggers order_filled, the agent must sign + submit, then explicitly place the next order.
Troubleshooting
| Issue | Fix |
|---|---|
| /holdings times out | Set HTTP timeout to at least 100 seconds. |
| /protect hangs | Set a 30s timeout. On timeout, check tx status via RPC before retrying — tx may have landed. |
| /protect returns 503 | submitTx() auto-falls back to RPC. No manual action needed. |
| 422 from /swap | Invalid payload (missing fields or mixed buy/sell params). Sell needs: wallet_address, token_address, action, holdings_percentage. Buy needs: wallet_address, token_address, action, amount. |
| "Error running simulation" from /swap | Route is unsellable now (dead/rugged pool, zero effective balance, or route failure). Put token on cooldown; avoid tight retry loops. |
| Swap fails on-chain | Increase slippage (1500-2500 bps for memecoins), check SOL balance for fees, verify token/pool exists. |
| No order_filled received | Verify register was sent, {"type":"registered"} received, and authenticated: true is set. A session that registered without a valid signature will receive registered with authenticated: false and will not receive fills — check this field first. Wallet must match. |
| WebSocket disconnects | Reconnect, re-register with signature, check for pending fills. Active orders persist server-side. |
| Sell fails on token from /holdings | Keep defensive filter valueNative > MIN_VALUE_NATIVE (> 0 by default) and verify balance/pool just before sell. |
| filled_mcap is 0 or null | Fill is still valid. Execute normally — mcap data is unreliable but tx works. |
| Order seems to have disappeared | Orders silently expire at expiry_hours. Use list_orders to check. |
Request pacing / rate limits
No hard limits are documented in this skill. Use conservative client pacing defaults unless the API owner gives stricter numbers:
- REST (
/swap,/protect,/holdings): target <= 2 requests/sec sustained per wallet (short bursts <= 5). - WebSocket mutating actions (
buy,sell,trailing_*,cancel_order,extend_order): target <= 5 messages/sec per wallet. - On 429 or repeated 5xx: exponential backoff with jitter (1s, 2s, 4s, cap 30s).
- Never tight-loop retries on the same token after
"Error running simulation"; honor cooldown first.
Important rules
- No API key needed. Wallet address is the only identity.
- Never expose private keys. Sign only in a secure client environment.
- Keep WebSocket connection open for limit/trailing orders. Fills are delivered only over the open WS; disconnect means you miss fills until you reconnect and re-register.
- Register with signature on WebSocket. Server sends challenge; sign nonce and send register with wallet_address + signature. No orders before
{"type":"registered","authenticated":true}. - Sell = holdings_percentage. Buy = amount. Do not mix these parameters.
- Target basis: WS
targetis relative to current mcap at order placement, not to your wallet entry price. - Encoding: /swap returns base58, /protect expects base64. Decode → deserialize → sign → serialize → encode base64.
- Slippage: Default 500 (5%). Use 1500-2500 bps for low-liquidity or newly launched tokens. 500 bps will fail on most memecoins.
- Set timeouts: 30s on /protect, 100s on /holdings.
- All transactions from the API are unsigned. Client always signs.
- Always submit via
submitTx(). This function enforces /protect first for MEV protection. RPC fallback is internal and only fires on 503 or timeout. Never callconnection.sendRawTransaction()directly. - Unsellable routes: "Error running simulation" should trigger cooldown, not spam retries.
- Holdings filtering: Keep
valueNative > MIN_VALUE_NATIVE(> 0by default) as a defensive guard before sells. The reference client'sgetHoldings()does this automatically. - Order expiry is silent. Server does not notify. Poll
list_ordersto detect.
Definition of Done
An agent is production-ready only when it can execute all of the following with zero manual steps:
- Instant buy:
POST /swap(buy) → decode base58 → sign → encode base64 →submitTx()→ verify signature - Instant sell:
POST /holdings→ defensive filtervalueNative > MIN_VALUE_NATIVE(> 0by default) →POST /swap(sell) → sign →submitTx() - WebSocket limit order: connect → challenge → register with signature → registered → place sell order → receive
order_filled→ verify → sign →submitTx() - WebSocket trailing order: connect → challenge → register with signature → registered → place
trailing_sell→ receiveorder_filled→ verify → sign →submitTx() - TWAP order: connect → register → place
twap_sellortwap_buy(frequency, duration, quantity or holdings_percentage) → receivetwap_executionfor each slice → verify server_signature → sign →submitTx()for each; receivetwap_order_completedwhen done - DCA cycle: place buy order → handle fill →
submitTx()→ place next buy order - Reconnection: disconnect → reconnect → new challenge → re-register with signature → handle pending fills with staleness check (all fills)
- Error handling: gracefully handle unsellable routes, 503, timeouts, stale fills, expired orders
- Preflight checks pass: env loaded, wallet accessible, RPC reachable, WS registration succeeds
Canonical Stack
Reference implementation: The skill text above is the source of truth for WebSocket challenge–response, verification, and params_hash. Python clients can use: solders (Keypair, VersionedTransaction), websockets, httpx, cryptography (Ed25519), base58.
Node.js (skill examples): Pin these versions unless explicitly tested.
Runtime: Node.js 20 LTS
crypto: built-in (createHash for SHA-256; no npm install)
web3.js: @solana/web3.js@1.95.8
bs58: bs58@6.0.0
ws: ws@8.18.0
ajv: ajv@8.17.1
tweetnacl: tweetnacl@1.0.3
npm init -y
npm pkg set type=module
npm install @solana/web3.js@1.95.8 bs58@6.0.0 ws@8.18.0 ajv@8.17.1 tweetnacl@1.0.3
The type=module line is required. All code below uses ESM imports and top-level await, which fail under CommonJS.
Python: solders, websockets, httpx, cryptography, base58. Encoding and verification logic are identical across runtimes; only library names differ.
Reference Client
Minimal copy-paste implementation. Everything is in this one code block — safety guards, logging, kill switch, dry-run gating. There are no separate code blocks to wire in. The reference client uses ajv to validate requests before sending them. The inline schemas enforce the minimum required fields for each call; the Request/Response Schemas section below has the full payload definitions.
One submission function: All transactions go through submitTx(). This function tries /protect first (MEV-protected), and only falls back to direct RPC on 503 or timeout. There is no separate RPC submission function — the fallback is internal. Never call connection.sendRawTransaction() directly.
import { Connection, Keypair, VersionedTransaction } from '@solana/web3.js';
import bs58 from 'bs58';
import WebSocket from 'ws';
import Ajv from 'ajv';
import nacl from 'tweetnacl';
import { createHash } from 'crypto';
const API = 'https://api.traderouter.ai';
const WS_URL = 'wss://api.traderouter.ai/ws';
const RPC_URL = process.env.RPC_URL || 'https://api.mainnet-beta.solana.com';
const connection = new Connection(RPC_URL, 'confirmed');
// SAFE-BY-DEFAULT: DRY_RUN is true unless you explicitly set DRY_RUN=false to go live.
const DRY_RUN = process.env.DRY_RUN !== 'false';
// Trust anchor: hardcoded or loaded from env. NEVER fetch from the server at runtime.
const SERVER_PUBKEY_BYTES = bs58.decode(
process.env.TRADEROUTER_SERVER_PUBKEY || 'EXX3nRzfDUvbjZSmxFzHDdiSYeGVP1EGr77iziFZ4Jd4'
);
const SERVER_PUBKEY_NEXT_BYTES = process.env.TRADEROUTER_SERVER_PUBKEY_NEXT
? bs58.decode(process.env.TRADEROUTER_SERVER_PUBKEY_NEXT)
: null;
const REQUIRE_SERVER_SIGNATURE = process.env.TRADEROUTER_REQUIRE_SERVER_SIGNATURE !== 'false';
// ---------- Schema Validation (AJV, enforced at runtime) ----------
const ajv = new Ajv({ allErrors: true, strict: false });
const swapRequestSchema = {
type: 'object',
required: ['wallet_address', 'token_address', 'action'],
properties: {
wallet_address: { type: 'string', minLength: 32, maxLength: 44 },
token_address: { type: 'string', minLength: 32, maxLength: 44 },
action: { type: 'string', enum: ['buy', 'sell'] },
amount: { type: 'integer', minimum: 1 },
holdings_percentage: { type: 'integer', minimum: 1, maximum: 10000 },
slippage: { type: 'integer', minimum: 100, maximum: 2500 },
},
if: { properties: { action: { const: 'buy' } } },
then: { required: ['amount'], not: { required: ['holdings_percentage'] } },
else: { required: ['holdings_percentage'], not: { required: ['amount'] } },
};
const protectRequestSchema = {
type: 'object',
required: ['signed_tx_base64'],
properties: {
signed_tx_base64: { type: 'string', minLength: 100, pattern: '^[A-Za-z0-9+/]+=*$' },
},
};
// When already_dispatched is true, server omits data or data.swap_tx; schema must allow that.
const orderFilledSchema = {
type: 'object',
required: ['type', 'order_id', 'order_type', 'status'],
properties: {
type: { const: 'order_filled' },
order_id: { type: 'string' },
order_type: { type: 'string', enum: ['sell', 'buy', 'trailing_sell', 'trailing_buy'] },
status: { type: 'string', enum: ['success'] },
already_dispatched: { type: 'boolean' },
data: {
type: 'object',
properties: {
swap_tx: { type: 'string', minLength: 100 },
token_address: { type: 'string' },
pool_type: { type: 'string' },
},
},
},
};
const validateSwapRequest = ajv.compile(swapRequestSchema);
const validateProtectRequest = ajv.compile(protectRequestSchema);
const validateOrderFilled = ajv.compile(orderFilledSchema);
function assertSchema(validateFn, payload, label) {
if (validateFn(payload)) return;
const detail = ajv.errorsText(validateFn.errors || [], { separator: '; ' });
throw new Error(`${label} validation failed: ${detail}`);
}
// ---------- Logging (JSON lines, one line per event) ----------
function log(fields) {
console.log(JSON.stringify({ ts: new Date().toISOString(), wallet: _wallet?.publicKey?.toBase58() || 'unknown', ...fields }));
}
// ---------- Safety Guards (enforced in buildSwap + submitTx) ----------
const SAFETY = {
MAX_BUY_LAMPORTS: 500_000_000, // 0.5 SOL max per buy (conservative starter)
MAX_SLIPPAGE_BPS: 2500, // 25% absolute ceiling
MIN_SLIPPAGE_BPS: 100, // 1% floor
MIN_VALUE_NATIVE: 0, // defensive min valueNative to attempt sell (> 0)
MAX_RETRIES_PER_TOKEN: 2, // don't hammer unsellable routes
UNSWAPPABLE_COOLDOWN_MS: 15 * 60 * 1000, // 15m cooldown for transient unsellable routes
MAX_DAILY_LOSS_LAMPORTS: 2_000_000_000, // 2 SOL daily loss limit
DENYLIST: new Map(), // token mint -> retry_after_epoch_ms (session-scoped)
dailyLoss: 0, // tracked across swaps
};
let KILL_SWITCH = false; // set true to halt all execution immediately
function isTokenOnCooldown(tokenAddress) {
const retryAfter = SAFETY.DENYLIST.get(tokenAddress);
if (!retryAfter) return false;
if (Date.now() >= retryAfter) {
SAFETY.DENYLIST.delete(tokenAddress);
return false;
}
return true;
}
function enforceSafety(action, tokenAddress, amount, slippage) {
if (KILL_SWITCH) throw new Error('KILL_SWITCH is active — all execution halted');
if (isTokenOnCooldown(tokenAddress)) {
const retryAfter = SAFETY.DENYLIST.get(tokenAddress);
log({ step: 'safety_blocked', token: tokenAddress, reason: 'cooldown_active', retry_after_ms: retryAfter });
throw new Error(`${tokenAddress} is on cooldown until ${new Date(retryAfter).toISOString()}`);
}
if (slippage > SAFETY.MAX_SLIPPAGE_BPS) throw new Error(`slippage ${slippage} exceeds max ${SAFETY.MAX_SLIPPAGE_BPS}`);
if (slippage < SAFETY.MIN_SLIPPAGE_BPS) throw new Error(`slippage ${slippage} below min ${SAFETY.MIN_SLIPPAGE_BPS}`);
if (action === 'buy' && amount > SAFETY.MAX_BUY_LAMPORTS) throw new Error(`amount ${amount} exceeds max ${SAFETY.MAX_BUY_LAMPORTS}`);
if (SAFETY.dailyLoss > SAFETY.MAX_DAILY_LOSS_LAMPORTS) {
KILL_SWITCH = true;
throw new Error('daily loss limit reached — KILL_SWITCH activated');
}
}
function markUnswappable(tokenAddress, errorMessage) {
const retryAfter = Date.now() + SAFETY.UNSWAPPABLE_COOLDOWN_MS;
SAFETY.DENYLIST.set(tokenAddress, retryAfter);
log({ step: 'safety_blocked', token: tokenAddress, reason: 'cooldown_set', retry_after_ms: retryAfter, source_error: errorMessage });
}
// ---------- Wallet (lazy init) ----------
let _wallet = null;
function getWallet() {
if (!_wallet) {
if (!process.env.PRIVATE_KEY) throw new Error('PRIVATE_KEY env var not set');
try {
_wallet = Keypair.fromSecretKey(bs58.decode(process.env.PRIVATE_KEY));
} catch (e) {
throw new Error(`Invalid PRIVATE_KEY: ${e.message}`);
}
}
return _wallet;
}
// ---------- Server Signature Verification ----------
// Canonical JSON for server signature: recursive sort_keys + ensure_ascii.
function canonicalizeForSigning(value) {
if (Array.isArray(value)) return value.map(canonicalizeForSigning);
if (value && typeof value === 'object') {
const out = {};
for (const key of Object.keys(value).sort()) out[key] = canonicalizeForSigning(value[key]);
return out;
}
return value;
}
function canonicalJsonPythonStyle(obj) {
const canonicalObj = canonicalizeForSigning(obj);
const json = JSON.stringify(canonicalObj);
return json.replace
…(truncated)