{"skill":{"slug":"trade-router","displayName":"Solana Trading Api","summary":"Solana swap execution, MEV-protected transaction submission, wallet scanning, and market-cap-based limit/trailing orders via the TradeRouter API. Use when th...","description":"---\nname: traderouter\ndescription: >\n  Solana swap execution, MEV-protected transaction submission, wallet scanning, and market-cap-based\n  limit/trailing orders via the TradeRouter API. Use when the user wants to: swap SPL tokens on Solana\n  (buy or sell), check wallet token holdings, submit signed transactions through an MEV-protected\n  priority lane, place limit orders (buy/sell at a target market cap), set trailing stop orders\n  (trailing_sell/trailing_buy), place combo orders (limit+TWAP, trailing+TWAP, limit+trailing,\n  limit+trailing+TWAP), manage existing orders (check, list, cancel, extend), or implement\n  DCA strategies. No API key required — wallet address is the only identity. Supports REST endpoints\n  POST /swap, POST /holdings, POST /protect, and WebSocket wss://api.traderouter.ai/ws for limit orders.\n---\n\n# TradeRouter\n\nSolana swap builder and limit-order engine.\n\n**Base URL:** `https://api.traderouter.ai`\n**WebSocket:** `wss://api.traderouter.ai/ws`\n**Website:** https://traderouter.ai\n**Auth:** None. No API key. Wallet address is the only identity.\n**Content-Type:** All REST requests require `Content-Type: application/json`.\n\n---\n\n## Before you use this skill\n\n**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.\n\n**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.\n\n**Service origin:** This skill documents the API only. The service website is **https://traderouter.ai** (API at api.traderouter.ai).\n\n**MEV protection:** The `POST /protect` endpoint accepts signed transactions and uses **Jito** and a **staked connection lane** to process your transaction.\n\n**Risk:** No API key is requested; identity is the wallet address (and for WebSocket orders, proof via the signed challenge).\n\n---\n\n## When to use which endpoint\n\n| User intent | Endpoint | Method |\n|-------------|----------|--------|\n| Instant buy or sell of a token | `POST /swap` → sign → `POST /protect` | REST |\n| Check wallet token balances | `POST /holdings` | REST |\n| Submit an already-signed transaction with MEV protection | `POST /protect` | REST |\n| Market cap / price for token(s) | `GET /mcap?tokens=MINT1,MINT2` | REST |\n| Flex trade card image for wallet + token | `GET /flex?wallet_address=…&token_address=…` | REST |\n| Limit order (take-profit, stop-loss, dip buy, breakout) | WebSocket `sell` or `buy` action | WS |\n| Trailing stop (auto-adjust with market) | WebSocket `trailing_sell` or `trailing_buy` | WS |\n| TWAP (time-weighted buy/sell over duration) | WebSocket `twap_buy` or `twap_sell` | WS |\n| Limit then TWAP | WebSocket `limit_twap_sell` or `limit_twap_buy` | WS |\n| Trailing then TWAP | WebSocket `trailing_twap_sell` or `trailing_twap_buy` | WS |\n| Limit then trailing (single swap on trail trigger) | WebSocket `limit_trailing_sell` or `limit_trailing_buy` | WS |\n| Limit then trailing then TWAP | WebSocket `limit_trailing_twap_sell` or `limit_trailing_twap_buy` | WS |\n| Manage orders (check, list, cancel, extend) | WebSocket actions | WS |\n| DCA (recurring small buys) | WebSocket `buy` orders — see DCA section below | WS |\n\n---\n\n## POST /swap — Build unsigned swap transaction\n\nReturns an **unsigned** transaction (base58). Client must sign it, then submit via `POST /protect`.\n\n**Sell uses `holdings_percentage` (bps). Buy uses `amount` (lamports). Never mix them.**\n\n### Request\n\n```json\n{\n  \"wallet_address\": \"SOLANA_PUBKEY\",\n  \"token_address\": \"SPL_TOKEN_MINT\",\n  \"action\": \"buy\",\n  \"amount\": 100000000,\n  \"slippage\": 1500\n}\n```\n\n| Field | Type | Required | Notes |\n|-------|------|----------|-------|\n| wallet_address | string | yes | Solana pubkey |\n| token_address | string | yes | SPL token mint address |\n| action | string | yes | `\"buy\"` or `\"sell\"` |\n| amount | integer | buy only | Lamports. **Only for buy.** |\n| holdings_percentage | integer | sell only | Bps (10000 = 100%). **Only for sell.** |\n| 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. |\n\nIf 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.\n\n### Success response\n\n```json\n{\n  \"status\": \"success\",\n  \"data\": {\n    \"swap_tx\": \"<base58_unsigned_transaction>\",\n    \"token_address\": \"SPL_TOKEN_MINT\",\n    \"pool_type\": \"raydium\",\n    \"pool_address\": \"POOL_PUBKEY\",\n    \"amount_in\": 100000000,\n    \"min_amount_out\": 950000,\n    \"price_impact\": 0.5,\n    \"slippage\": 1500,\n    \"decimals\": 6\n  }\n}\n```\n\n`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.\n\n### Error response\n\n```json\n{\n  \"status\": \"error\",\n  \"error\": \"Insufficient balance\",\n  \"code\": 400\n}\n```\n\n`code` is optional. Common: 422 (validation), 400 (bad request).\n\n**\"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.\n\n---\n\n## POST /protect — Submit signed transaction (MEV protected)\n\nSubmit 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.\n\n**⚠️ Set a 30-second timeout on /protect calls.** This endpoint blocks until on-chain confirmation and can hang during network congestion.\n\n**⚠️ Encoding mismatch:** `/swap` returns `swap_tx` as **base58**. `/protect` expects **base64**. You must convert — see the workflow section.\n\n### Request\n\n```json\n{\n  \"signed_tx_base64\": \"<base64_signed_transaction>\"\n}\n```\n\n### Success response\n\n```json\n{\n  \"status\": \"success\",\n  \"signature\": \"5kyc5dMF1tybDcj8sVMZz3fCLbHYDczZ7A4mMu5JMPz1...\",\n  \"sol_balance_pre\": 10399668919,\n  \"sol_balance_post\": 10399538835,\n  \"token_balances\": [\n    {\n      \"mint\": \"FFKwi6dzaDmkGhtMDGKbt3HAyEYWk2BgwN4AwcWbbonk\",\n      \"balance\": 25952334242,\n      \"decimals\": 6,\n      \"balance_change\": 2032594114,\n      \"ui_amount_string\": \"25952.334242\"\n    }\n  ]\n}\n```\n\nUse `sol_balance_post` and `token_balances` to update holdings after the swap.\n\n### Error handling\n\n- `{\"status\":\"error\",\"error\":\"message\"}` — general error.\n- **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.\n- **Timeout** — the tx may have landed on-chain. Check tx status via RPC before retrying.\n\n---\n\n## POST /holdings — Scan wallet token balances\n\nReturns 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.\n\n### Request\n\n```json\n{\n  \"wallet_address\": \"SOLANA_PUBKEY\"\n}\n```\n\n### Response\n\nEmpty wallet: `{}`\n\nWallet with holdings:\n\n```json\n{\n  \"data\": [\n    {\n      \"address\": \"SPL_TOKEN_MINT\",\n      \"valueNative\": 1500000000,\n      \"amount\": 25952334242,\n      \"decimals\": 6\n    }\n  ]\n}\n```\n\n`/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.\n\n---\n\n## GET /mcap — Market cap data\n\nReturn market cap (and optional price/pool) for given token addresses.\n\n**Request:** `GET https://api.traderouter.ai/mcap?tokens=MINT1,MINT2` (comma-delimited Solana mint addresses).\n\n**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.\n\n---\n\n## GET /flex — Flex trade card PNG\n\nGenerate a flex trade card image for a wallet and token mint.\n\n**Request:** `GET https://api.traderouter.ai/flex?wallet_address=WALLET&token_address=MINT`.\n\n**Response:** `image/png`. 400 on invalid params; 501 if flex_card_image deps not available; 500 on server error.\n\n---\n\n## Instant swap workflow (step by step)\n\n**The encoding changes: /swap returns base58, /protect expects base64.** Do not send base58 to /protect.\n\n1. `POST /swap` with wallet_address, token_address, action, amount or holdings_percentage, slippage\n2. Read `data.swap_tx` from response — this is **base58** encoded\n3. **Decode** from base58 into raw bytes\n4. **Deserialize** as VersionedTransaction\n5. **Sign** with wallet private key\n6. **Re-serialize** the signed transaction into bytes\n7. **Encode** as **base64**\n8. Submit via `submitTx(signedBase64)` — this calls `/protect` first (30s timeout), auto-falls back to RPC on 503\n9. On success: `signature` = tx hash, use `sol_balance_post` and `token_balances` to update state\n10. On timeout: `submitTx` checks if tx landed via RPC before falling back — no manual handling needed\n\n---\n\n## WebSocket — Limit and trailing orders\n\n**URL:** `wss://api.traderouter.ai/ws`\n\n**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.\n\nServer monitors market cap every ~5 seconds. When target is crossed, server pushes `order_filled` with an unsigned swap transaction to sign and submit.\n\n**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.\n\n### Connection sequence (MUST follow this exact order)\n\nThe server sends a **challenge** on connect (not `subscribed`). Registration is **challenge–response only**; there is no unauthenticated path for placing orders.\n\n1. Connect to `wss://api.traderouter.ai/ws`\n2. **Server sends:** `{\"type\": \"challenge\", \"nonce\": \"<nonce>\", \"message\": \"...\"}`. The current protocol always sends `challenge` as the first message.\n3. **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.\n4. **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.\n5. **Server sends:** `{\"type\": \"registered\", \"wallet_address\": \"<pubkey>\", \"authenticated\": true}`.\n6. **Only after** receiving `registered` with `authenticated: true` may 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.\"}`.\n\n**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.\n\n### Reconnection\n\nOn WebSocket disconnect:\n1. Reconnect to `wss://api.traderouter.ai/ws`\n2. Server sends a new **challenge** (new nonce). Send `{\"action\": \"register\", \"wallet_address\": \"...\", \"signature\": \"<base58 of nonce signed with wallet>\"}`.\n3. Wait for `{\"type\": \"registered\", \"authenticated\": true}`\n4. Check for any pending `order_filled` messages\n5. Use the staleness check (`triggered_mcap / filled_mcap < 0.85`) to skip stale fills\n\nActive orders persist server-side — you do **not** need to re-place them after reconnect.\n\n### Limit sell (take-profit or stop-loss)\n\n```json\n{\n  \"action\": \"sell\",\n  \"token_address\": \"SPL_TOKEN_MINT\",\n  \"holdings_percentage\": 10000,\n  \"target\": 20000,\n  \"slippage\": 1500,\n  \"expiry_hours\": 144\n}\n```\n\n`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).\n\n### Limit buy (dip buy or breakout entry)\n\n```json\n{\n  \"action\": \"buy\",\n  \"token_address\": \"SPL_TOKEN_MINT\",\n  \"amount\": 100000000,\n  \"target\": 5000,\n  \"slippage\": 1500,\n  \"expiry_hours\": 144\n}\n```\n\n`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).\n\n### Trailing sell / Trailing buy\n\n```json\n{\n  \"action\": \"trailing_sell\",\n  \"token_address\": \"SPL_TOKEN_MINT\",\n  \"holdings_percentage\": 10000,\n  \"trail\": 1000,\n  \"slippage\": 1500,\n  \"expiry_hours\": 144\n}\n```\n\n`trail` is bps — percentage callback from peak before triggering.\n\n**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.\n\nReplace `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.\n\n### Limit + TWAP (limit_twap_sell / limit_twap_buy)\n\nWait for limit target (bps vs entry mcap), then execute via TWAP. Required: `token_address`, `target`, `frequency`, `duration`; for sell add `amount` or `holdings_percentage`; for buy add `amount`. When limit crosses, server spawns TWAP; client receives `limit_twap_triggered` then `twap_execution` per slice.\n\n```json\n{\"action\": \"limit_twap_sell\", \"token_address\": \"MINT\", \"target\": 20000, \"frequency\": 5, \"duration\": 3600, \"holdings_percentage\": 5000, \"slippage\": 500, \"expiry_hours\": 144}\n{\"action\": \"limit_twap_buy\", \"token_address\": \"MINT\", \"target\": 5000, \"amount\": 100000000, \"frequency\": 5, \"duration\": 3600, \"slippage\": 500, \"expiry_hours\": 144}\n```\n\n### Trailing + TWAP (trailing_twap_sell / trailing_twap_buy)\n\nWhen trailing stop triggers, server spawns TWAP. Required: `token_address`, `trail`, `frequency`, `duration`; for sell add `amount` or `holdings_percentage`; for buy add `amount`. Client receives `trailing_twap_triggered` then `twap_execution` per slice.\n\n```json\n{\"action\": \"trailing_twap_sell\", \"token_address\": \"MINT\", \"trail\": 1000, \"frequency\": 5, \"duration\": 3600, \"holdings_percentage\": 10000, \"slippage\": 500, \"expiry_hours\": 144}\n{\"action\": \"trailing_twap_buy\", \"token_address\": \"MINT\", \"trail\": 1000, \"amount\": 100000000, \"frequency\": 5, \"duration\": 3600, \"slippage\": 500, \"expiry_hours\": 144}\n```\n\n### Limit + Trailing (limit_trailing_sell / limit_trailing_buy)\n\nWait for limit target, then trailing phase activates (server sends `limit_trailing_activated`). When the trailing stop triggers, single swap — client receives `order_filled` with `data.swap_tx`. Required: `token_address`, `target`, `trail`; for sell add `amount` or `holdings_percentage`; for buy add `amount`.\n\n```json\n{\"action\": \"limit_trailing_sell\", \"token_address\": \"MINT\", \"target\": 20000, \"trail\": 1000, \"holdings_percentage\": 10000, \"slippage\": 500, \"expiry_hours\": 144}\n{\"action\": \"limit_trailing_buy\", \"token_address\": \"MINT\", \"target\": 5000, \"trail\": 1000, \"amount\": 100000000, \"slippage\": 500, \"expiry_hours\": 144}\n```\n\n### Limit + Trailing + TWAP (limit_trailing_twap_sell / limit_trailing_twap_buy)\n\nLimit → trailing phase → when trail triggers, server spawns TWAP. Client receives `limit_trailing_activated` when trailing starts, then `limit_trailing_twap_triggered` when trail triggers, then `twap_execution` per slice. Required: `token_address`, `target`, `trail`, `frequency`, `duration`; for sell add `amount` or `holdings_percentage`; for buy add `amount`.\n\n```json\n{\"action\": \"limit_trailing_twap_sell\", \"token_address\": \"MINT\", \"target\": 20000, \"trail\": 1000, \"frequency\": 5, \"duration\": 3600, \"holdings_percentage\": 5000, \"slippage\": 500, \"expiry_hours\": 144}\n{\"action\": \"limit_trailing_twap_buy\", \"token_address\": \"MINT\", \"target\": 5000, \"trail\": 1000, \"amount\": 100000000, \"frequency\": 5, \"duration\": 3600, \"slippage\": 500, \"expiry_hours\": 144}\n```\n\n### Order management actions\n\n```json\n{\"action\": \"check_order\", \"order_id\": \"ORDER_ID\"}\n{\"action\": \"list_orders\"}\n{\"action\": \"cancel_order\", \"order_id\": \"ORDER_ID\"}\n{\"action\": \"extend_order\", \"order_id\": \"ORDER_ID\", \"expiry_hours\": 336}\n```\n\n### TWAP (time-weighted average price)\n\n`twap_buy` and `twap_sell` split a total amount 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.\n\n**twap_sell:** Either `amount` (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.\n\n```json\n{\n  \"action\": \"twap_sell\",\n  \"token_address\": \"SPL_TOKEN_MINT\",\n  \"frequency\": 5,\n  \"duration\": 3600,\n  \"holdings_percentage\": 5000,\n  \"slippage\": 500\n}\n```\n\n**twap_buy:** Use `amount` (SOL lamports) as total to spend over the duration.\n\n```json\n{\n  \"action\": \"twap_buy\",\n  \"token_address\": \"SPL_TOKEN_MINT\",\n  \"frequency\": 5,\n  \"duration\": 3600,\n  \"amount\": 1000000000,\n  \"slippage\": 500\n}\n```\n\n**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.\n\n### Order expiry\n\nOrders 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.\n\n### All WebSocket actions reference\n\n| Action | Required fields | Optional |\n|--------|----------------|----------|\n| register | wallet_address | signature (required when server sent challenge; base58 of nonce signed with wallet) |\n| sell | token_address, holdings_percentage (bps), target, slippage | expiry_hours (default 144), wallet_address |\n| buy | token_address, amount (lamports), target, slippage | expiry_hours, wallet_address |\n| trailing_sell | token_address, holdings_percentage, trail (bps), slippage | expiry_hours |\n| trailing_buy | token_address, amount, trail (bps), slippage | expiry_hours |\n| twap_sell | token_address, frequency, duration, amount or holdings_percentage (bps) | slippage (default 500) |\n| twap_buy | token_address, frequency, duration, amount (SOL lamports) | slippage (default 500) |\n| limit_twap_sell | token_address, target, frequency, duration, amount or holdings_percentage | slippage, expiry_hours |\n| limit_twap_buy | token_address, target, amount, frequency, duration | slippage, expiry_hours |\n| trailing_twap_sell | token_address, trail, frequency, duration, amount or holdings_percentage | slippage, expiry_hours |\n| trailing_twap_buy | token_address, trail, amount, frequency, duration | slippage, expiry_hours |\n| limit_trailing_sell | token_address, target, trail, amount or holdings_percentage | slippage, expiry_hours |\n| limit_trailing_buy | token_address, target, trail, amount | slippage, expiry_hours |\n| limit_trailing_twap_sell | token_address, target, trail, frequency, duration, amount or holdings_percentage | slippage, expiry_hours |\n| limit_trailing_twap_buy | token_address, target, trail, amount, frequency, duration | slippage, expiry_hours |\n| check_order | order_id | — |\n| list_orders | — | wallet_address |\n| cancel_order | order_id | — |\n| extend_order | order_id, expiry_hours (max 336) | — |\n\n**expiry_hours:** default 144, max 336.\n\n### Server → client message types\n\n| type | Payload fields | Description |\n|------|----------------|-------------|\n| challenge | nonce, message | Sent on connect; client must sign nonce and send register with wallet_address + signature |\n| registered | wallet_address, authenticated | Registration confirmed; only when authenticated true can client send order actions |\n| 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; order_type can be any of sell, buy, trailing_sell, trailing_buy, twap_*, limit_twap_*, trailing_twap_*, limit_trailing_*, limit_trailing_twap_*. When params_hash and server_signature are present, verify server_signature over params_hash (Rec 2) — see Verifying server signatures |\n| 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) |\n| limit_trailing_activated | order_id, order_type, token_address, limit_target_mcap, current_mcap, trailing_target_mcap | Limit-trailing order: limit target crossed, trailing phase now active |\n| trailing_twap_triggered | order_id, twap_order_id, token_address, … | Trailing+TWAP: trail triggered; then twap_order_created / twap_execution for the spawned TWAP |\n| limit_twap_triggered | order_id, twap_order_id, token_address, … | Limit+TWAP: limit crossed; then twap_order_created / twap_execution for the spawned TWAP |\n| limit_trailing_twap_triggered | order_id, twap_order_id, token_address, … | Limit+trailing+TWAP: trail triggered; then twap_order_created / twap_execution for the spawned TWAP |\n| twap_order_created | order_id, order_type, token_address, frequency, duration, interval_seconds, amount_per_execution, original_amount, expires_at, slippage, holdings_percentage (optional) | TWAP order accepted (standalone or spawned from combo) |\n| 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 |\n| twap_order_completed | order_id, order_type, token_address, executions_completed, status | All TWAP slices done |\n| twap_order_cancelled | order_id, status | TWAP order cancelled (response to cancel_order) |\n| order_status | order_id, status | Response to check_order |\n| order_list | orders[] | Response to list_orders |\n| order_cancelled | order_id | Order cancelled |\n| order_extended | order_id | TTL extended |\n| error | message | Error description |\n| heartbeat | — | Keepalive, ignore |\n\n### WebSocket authentication (required for orders)\n\nThe server sends a **challenge** with a **nonce** on connect. To place or manage orders you must:\n\n1. Sign the **nonce** (as UTF-8 bytes) with the wallet's private key (Ed25519).\n2. Send **one** message: `{\"action\": \"register\", \"wallet_address\": \"<pubkey>\", \"signature\": \"<base58 signature>\"}`. There is no separate `auth` action — the signature is sent in the same `register` message.\n3. Wait for `{\"type\": \"registered\", \"authenticated\": true}`. Only then send order actions.\n\nIf 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.\n\n### Verifying server signatures (order_filled and order_created)\n\n**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).\n\n**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.\n\n**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.\n\n**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).\n\n**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. \n### Handling order_filled\n\nWhen `order_filled` arrives:\n1. **Idempotency:** If `already_dispatched` is true, skip sign/submit; treat as idempotent ack (fill was already sent). Log and exit.\n2. **Verify:** Verify `server_signature` using the configured trust anchor (see \"Verifying server signatures\" above). On failure or if signature is missing when required, log and do not sign/submit.\n3. Read `order_id` from the message — use for logging and correlation throughout\n4. Read `data.swap_tx` — this is **base58** unsigned (when `already_dispatched` is false; when true, `data` or `data.swap_tx` may be omitted)\n5. **Decode** from base58 into raw bytes\n6. **Deserialize** as VersionedTransaction\n7. **Sign** with client wallet\n8. **Re-serialize** signed transaction into bytes\n9. **Encode** as **base64**\n10. Submit via `submitTx(signedBase64)` — handles /protect + fallback internally\n11. Log `order_id` + `signature` together for audit trail\n12. Use response to update holdings\n\n**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.\n\n**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.\n\n**⚠️ `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.\n\n**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.\n\n### `holdings_percentage` resolves at execution time\n\nFor 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.\n\n---\n\n## DCA (Dollar-Cost Averaging)\n\nDCA is implemented as repeated limit buy orders. It is **not automatic chaining** — each fill requires agent action:\n\n1. Place a `buy` order via WebSocket with the desired `amount` and `target`\n2. When `order_filled` arrives, sign the `swap_tx` and submit via `submitTx()` (follow the base58→base64 steps above)\n3. After successful submission, place the **next** `buy` order\n4. Repeat for as many intervals as desired\n\nThe server does not auto-chain orders. Each fill triggers `order_filled`, the agent must sign + submit, then explicitly place the next order.\n\n---\n\n## Troubleshooting\n\n| Issue | Fix |\n|-------|-----|\n| /holdings times out | Set HTTP timeout to at least 100 seconds. |\n| /protect hangs | Set a 30s timeout. On timeout, check tx status via RPC before retrying — tx may have landed. |\n| /protect returns 503 | `submitTx()` auto-falls back to RPC. No manual action needed. |\n| 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. |\n| \"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. |\n| Swap fails on-chain | Increase slippage (1500-2500 bps for memecoins), check SOL balance for fees, verify token/pool exists. |\n| 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. |\n| WebSocket disconnects | Reconnect, re-register with signature, check for pending fills. Active orders persist server-side. |\n| Sell fails on token from /holdings | Keep defensive filter `valueNative > MIN_VALUE_NATIVE` (`> 0` by default) and verify balance/pool just before sell. |\n| filled_mcap is 0 or null | Fill is still valid. Execute normally — mcap data is unreliable but tx works. |\n| Order seems to have disappeared | Orders silently expire at `expiry_hours`. Use `list_orders` to check. |\n\n---\n\n## Request pacing / rate limits\n\nNo hard limits are documented in this skill. Use conservative client pacing defaults unless the API owner gives stricter numbers:\n\n- REST (`/swap`, `/protect`, `/holdings`): target <= 2 requests/sec sustained per wallet (short bursts <= 5).\n- WebSocket mutating actions (`buy`, `sell`, `trailing_*`, `cancel_order`, `extend_order`): target <= 5 messages/sec per wallet.\n- On 429 or repeated 5xx: exponential backoff with jitter (1s, 2s, 4s, cap 30s).\n- Never tight-loop retries on the same token after `\"Error running simulation\"`; honor cooldown first.\n\n---\n\n## Important rules\n\n- **No API key needed.** Wallet address is the only identity.\n- **Never expose private keys.** Sign only in a secure client environment.\n- **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.\n- **Register with signature on WebSocket.** Server sends challenge; sign nonce and send register with wallet_address + signature. No orders before `{\"type\":\"registered\",\"authenticated\":true}`.\n- **Sell = holdings_percentage. Buy = amount.** Do not mix these parameters.\n- **Target basis:** WS `target` is relative to **current mcap at order placement**, not to your wallet entry price.\n- **Encoding: /swap returns base58, /protect expects base64.** Decode → deserialize → sign → serialize → encode base64.\n- **Slippage:** Default 500 (5%). **Use 1500-2500 bps for low-liquidity or newly launched tokens.** 500 bps will fail on most memecoins.\n- **Set timeouts:** 30s on /protect, 100s on /holdings.\n- **All transactions from the API are unsigned.** Client always signs.\n- **Always submit via `submitTx()`.** This function enforces /protect first for MEV protection. RPC fallback is internal and only fires on 503 or timeout. **Never call `connection.sendRawTransaction()` directly.**\n- **Unsellable routes:** \"Error running simulation\" should trigger cooldown, not spam retries.\n- **Holdings filtering:** Keep `valueNative > MIN_VALUE_NATIVE` (`> 0` by default) as a defensive guard before sells. The reference client's `getHoldings()` does this automatically.\n- **Order expiry is silent.** Server does not notify. Poll `list_orders` to detect.\n\n---\n\n## Definition of Done\n\nAn agent is production-ready only when it can execute all of the following with **zero manual steps**:\n\n- [ ] **Instant buy:** `POST /swap` (buy) → decode base58 → sign → encode base64 → `submitTx()` → verify signature\n- [ ] **Instant sell:** `POST /holdings` → defensive filter `valueNative > MIN_VALUE_NATIVE` (`> 0` by default) → `POST /swap` (sell) → sign → `submitTx()`\n- [ ] **WebSocket limit order:** connect → challenge → register with signature → registered → place sell order → receive `order_filled` → verify → sign → `submitTx()`\n- [ ] **WebSocket trailing order:** connect → challenge → register with signature → registered → place `trailing_sell` → receive `order_filled` → verify → sign → `submitTx()`\n- [ ] **TWAP order:** connect → register → place `twap_sell` or `twap_buy` (frequency, duration, amount or holdings_percentage) → receive `twap_execution` for each slice → verify server_signature → sign → `submitTx()` for each; receive `twap_order_completed` when done\n- [ ] **DCA cycle:** place buy order → handle fill → `submitTx()` → place next buy order\n- [ ] **Reconnection:** disconnect → reconnect → new challenge → re-register with signature → handle pending fills with staleness check (all fills)\n- [ ] **Error handling:** gracefully handle unsellable routes, 503, timeouts, stale fills, expired orders\n- [ ] **Preflight checks pass:** env loaded, wallet accessible, RPC reachable, WS registration succeeds\n\n---\n\n## Canonical Stack\n\n**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`.\n\n**Node.js (skill examples):** Pin these versions unless explicitly tested.\n\n```\nRuntime:    Node.js 20 LTS\ncrypto:     built-in (createHash for SHA-256; no npm install)\nweb3.js:    @solana/web3.js@1.95.8\nbs58:       bs58@6.0.0\nws:         ws@8.18.0\najv:        ajv@8.17.1\ntweetnacl:  tweetnacl@1.0.3\n```\n\n```bash\nnpm init -y\nnpm pkg set type=module\nnpm install @solana/web3.js@1.95.8 bs58@6.0.0 ws@8.18.0 ajv@8.17.1 tweetnacl@1.0.3\n```\n\n**The `type=module` line is required.** All code below uses ESM imports and top-level await, which fail under CommonJS.\n\n**Python:** `solders`, `websockets`, `httpx`, `cryptography`, `base58`. Encoding and verification logic are identical across runtimes; only library names differ.\n\n---\n\n## Reference Client\n\nMinimal 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.\n\n**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.**\n\n```javascript\nimport { Connection, Keypair, VersionedTransaction } from '@solana/web3.js';\nimport bs58 from 'bs58';\nimport WebSocket from 'ws';\nimport Ajv from 'ajv';\nimport nacl from 'tweetnacl';\nimport { createHash } from 'crypto';\n\nconst API = 'https://api.traderouter.ai';\nconst WS_URL = 'wss://api.traderouter.ai/ws';\nconst RPC_URL = process.env.RPC_URL || 'https://api.mainnet-beta.solana.com';\n\nconst connection = new Connection(RPC_URL, 'confirmed');\n\n// SAFE-BY-DEFAULT: DRY_RUN is true unless you explicitly set DRY_RUN=false to go live.\nconst DRY_RUN = process.env.DRY_RUN !== 'false';\n\n// Trust anchor: hardcoded or loaded from env. NEVER fetch from the server at runtime.\nconst SERVER_PUBKEY_BYTES = bs58.decode(\n  process.env.TRADEROUTER_SERVER_PUBKEY || 'EXX3nRzfDUvbjZSmxFzHDdiSYeGVP1EGr77iziFZ4Jd4'\n);\nconst SERVER_PUBKEY_NEXT_BYTES = process.env.TRADEROUTER_SERVER_PUBKEY_NEXT\n  ? bs58.decode(process.env.TRADEROUTER_SERVER_PUBKEY_NEXT)\n  : null;\nconst REQUIRE_SERVER_SIGNATURE = process.env.TRADEROUTER_REQUIRE_SERVER_SIGNATURE !== 'false';\n\n// ---------- Schema Validation (AJV, enforced at runtime) ----------\n\nconst ajv = new Ajv({ allErrors: true, strict: false });\n\nconst swapRequestSchema = {\n  type: 'object',\n  required: ['wallet_address', 'token_address', 'action'],\n  properties: {\n    wallet_address: { type: 'string', minLength: 32, maxLength: 44 },\n    token_address: { type: 'string', minLength: 32, maxLength: 44 },\n    action: { type: 'string', enum: ['buy', 'sell'] },\n    amount: { type: 'integer', minimum: 1 },\n    holdings_percentage: { type: 'integer', minimum: 1, maximum: 10000 },\n    slippage: { type: 'integer', minimum: 100, maximum: 2500 },\n  },\n  if: { properties: { action: { const: 'buy' } } },\n  then: { required: ['amount'], not: { required: ['holdings_percentage'] } },\n  else: { required: ['holdings_percentage'], not: { required: ['amount'] } },\n};\n\nconst protectRequestSchema = {\n  type: 'object',\n  required: ['signed_tx_base64'],\n  properties: {\n    signed_tx_base64: { type: 'string', minLength: 100, pattern: '^[A-Za-z0-9+/]+=*$' },\n  },\n};\n\n// When already_dispatched is true, server omits data or data.swap_tx; schema must allow that.\nconst orderFilledSchema = {\n  type: 'object',\n  required: ['type', 'order_id', 'order_type', 'status'],\n  properties: {\n    type: { const: 'order_filled' },\n    order_id: { type: 'string' },\n    order_type: { type: 'string', enum: ['sell', 'buy', 'trailing_sell', 'trailing_buy'] },\n    status: { type: 'string', enum: ['success'] },\n    already_dispatched: { type: 'boolean' },\n    data: {\n      type: 'object',\n      properties: {\n        swap_tx: { type: 'string', minLength: 100 },\n        token_address: { type: 'string' },\n        pool_type: { type: 'string' },\n      },\n    },\n  },\n};\n\nconst validateSwapRequest = ajv.compile(swapRequestSchema);\nconst validateProtectRequest = ajv.compile(protectRequestSchema);\nconst validateOrderFilled = ajv.compile(orderFilledSchema);\n\nfunction assertSchema(validateFn, payload, label) {\n  if (validateFn(payload)) return;\n  const detail = ajv.errorsText(validateFn.errors || [], { separator: '; ' });\n  throw new Error(`${label} validation failed: ${detail}`);\n}\n\n// ---------- Logging (JSON lines, one line per event) ----------\n\nfunction log(fields) {\n  console.log(JSON.stringify({ ts: new Date().toISOString(), wallet: _wallet?.publicKey?.toBase58() || 'unknown', ...fields }));\n}\n\n// ---------- Safety Guards (enforced in buildSwap + submitTx) ----------\n\nconst SAFETY = {\n  MAX_BUY_LAMPORTS: 500_000_000,        // 0.5 SOL max per buy (conservative starter)\n  MAX_SLIPPAGE_BPS: 2500,               // 25% absolute ceiling\n  MIN_SLIPPAGE_BPS: 100,                // 1% floor\n  MIN_VALUE_NATIVE: 0,                  // defensive min valueNative to attempt sell (> 0)\n  MAX_RETRIES_PER_TOKEN: 2,             // don't hammer unsellable routes\n  UNSWAPPABLE_COOLDOWN_MS: 15 * 60 * 1000, // 15m cooldown for transient unsellable routes\n  MAX_DAILY_LOSS_LAMPORTS: 2_000_000_000, // 2 SOL daily loss limit\n  DENYLIST: new Map(),                   // token mint -> retry_after_epoch_ms (session-scoped)\n  dailyLoss: 0,                          // tracked across swaps\n};\n\nlet KILL_SWITCH = false;   // set true to halt all execution immediately\n\nfunction isTokenOnCooldown(tokenAddress) {\n  const retryAfter = SAFETY.DENYLIST.get(tokenAddress);\n  if (!retryAfter) return false;\n  if (Date.now() >= retryAfter) {\n    SAFETY.DENYLIST.delete(tokenAddress);\n    return false;\n  }\n  return true;\n}\n\nfunction enforceSafety(action, tokenAddress, amount, slippage) {\n  if (KILL_SWITCH) throw new Error('KILL_SWITCH is active — all execution halted');\n  if (isTokenOnCooldown(tokenAddress)) {\n    const retryAfter = SAFETY.DENYLIST.get(tokenAddress);\n    log({ step: 'safety_blocked', token: tokenAddress, reason: 'cooldown_active', retry_after_ms: retryAfter });\n    throw new Error(`${tokenAddress} is on cooldown until ${new Date(retryAfter).toISOString()}`);\n  }\n  if (slippage > SAFETY.MAX_SLIPPAGE_BPS) throw new Error(`slippage ${slippage} exceeds max ${SAFETY.MAX_SLIPPAGE_BPS}`);\n  if (slippage < SAFETY.MIN_SLIPPAGE_BPS) throw new Error(`slippage ${slippage} below min ${SAFETY.MIN_SLIPPAGE_BPS}`);\n  if (action === 'buy' && amount > SAFETY.MAX_BUY_LAMPORTS) throw new Error(`amount ${amount} exceeds max ${SAFETY.MAX_BUY_LAMPORTS}`);\n  if (SAFETY.dailyLoss > SAFETY.MAX_DAILY_LOSS_LAMPORTS) {\n    KILL_SWITCH = true;\n    throw new Error('daily loss limit reached — KILL_SWITCH activated');\n  }\n}\n\nfunction markUnswappable(tokenAddress, errorMessage) {\n  const retryAfter = Date.now() + SAFETY.UNSWAPPABLE_COOLDOWN_MS;\n  SAFETY.DENYLIST.set(tokenAddress, retryAfter);\n  log({ step: 'safety_blocked', token: tokenAddress, reason: 'cooldown_set', retry_after_ms: retryAfter, source_error: errorMessage });\n}\n\n// ---------- Wallet (lazy init) ----------\n\nlet _wallet = null;\nfunction getWallet() {\n  if (!_wallet) {\n    if (!process.env.PRIVATE_KEY) throw new Error('PRIVATE_KEY env var not set');\n    try {\n      _wallet = Keypair.fromSecretKey(bs58.decode(process.env.PRIVATE_KEY));\n    } catch (e) {\n      throw new Error(`Invalid PRIVATE_KEY: ${e.message}`);\n    }\n  }\n  return _wallet;\n}\n\n// ---------- Server Signature Verification ----------\n\n// Canonical JSON for server signature: recursive sort_keys + ensure_ascii.\nfunction canonicalizeForSigning(value) {\n  if (Array.isArray(value)) return value.map(canonicalizeForSigning);\n  if (value && typeof value === 'object') {\n    const out = {};\n    for (const key of Object.keys(value).sort()) out[key] = canonicalizeForSigning(value[key]);\n    return out;\n  }\n  return value;\n}\nfunction canonicalJsonPythonStyle(obj) {\n  const canonicalObj = canonicalizeForSigning(obj);\n  const json = JSON.stringify(canonicalObj);\n  return json.replace(/[^\\x00-\\x7F]/g, (ch) => `\\\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`);\n}\n\n// verifyOrderFilledSignature — see \"Verifying server signatures\" above.\n// Must be called in handleOrderFilled before signVersionedTx/submitTx.\nfunction verifyOrderFilledSignature(msg) {\n  const { server_signature } = msg;\n\n  if (!server_signature) {\n    if (REQUIRE_SERVER_SIGNATURE) {\n      log({ step: 'order_fill_verify_failed', order_id: msg.order_id, reason: 'missing_server_signature' });\n      return false;\n    }\n    // Signature not present and not required — pass through.\n    return true;\n  }\n\n  // Build canonical payload — only include keys present and not null.\n  const CANONICAL_KEYS = [\n    'order_id', 'order_type', 'status', 'token_address',\n    'entry_mcap', 'triggered_mcap', 'filled_mcap', 'target_mcap',\n    'triggered_at', 'filled_at', 'data',\n  ];\n  const payload = {};\n  for (const key of CANONICAL_KEYS) {\n    if (msg[key] !== undefined && msg[key] !== null) {\n      payload[key] = msg[key];\n    }\n  }\n\n  // Canonical JSON: sorted keys (recursive), no extra whitespace, ensure_ascii — then SHA-256 of UTF-8 bytes.\n  const canonical = canonicalJsonPythonStyle(payload);\n  const digest = createHash('sha256').update(Buffer.from(canonical, 'utf-8')).digest();\n\n  const sigBytes = bs58.decode(server_signature);\n\n  // Try primary key, then rotation key if present.\n  const keysToTry = [SERVER_PUBKEY_BYTES];\n  if (SERVER_PUBKEY_NEXT_BYTES) keysToTry.push(SERVER_PUBKEY_NEXT_BYTES);\n\n  for (const pubkeyBytes of keysToTry) {\n    try {\n      const ok = nacl.sign.detached.verify(digest, sigBytes, pubkeyBytes);\n      if (ok) return true;\n    } catch (_) {\n      // Try next key.\n    }\n  }\n\n  log({ step: 'order_fill_verify_failed', order_id: msg.order_id, reason: 'signature_invalid' });\n  return false;\n}\n\n// ---------- REST ----------\n\nasync function buildSwap({ tokenAddress, action, amount, holdingsPercentage, slippage = 1500 }) {\n  // Safety check BEFORE network call\n  enforceSafety(action, tokenAddress, amount, slippage);\n\n  const body = {\n    wallet_address: getWallet().publicKey.toBase58(),\n    token_address: tokenAddress,\n    action,\n    slippage,\n  };\n  if (action === 'buy') body.amount = amount;\n  if (action === 'sell') body.holdings_percentage = holdingsPercentage;\n  assertSchema(validateSwapRequest, body, 'swap request');\n\n  log({ step: 'swap_request', token: tokenAddress, action, amount: amount || holdingsPercentage, slippage });\n\n  const res = await fetch(`${API}/swap`, {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify(body),\n    signal: AbortSignal.timeout(15000),\n  });\n  const json = await res.json();\n\n  if (json.status !== 'success') {\n    // Session cooldown for unsellable routes (avoid retry loops, allow later retry).\n    if (json.error?.includes('Error running simulation')) {\n      markUnswappable(tokenAddress, json.error);\n    }\n    log({ step: 'swap_error', token: tokenAddress, error: json.error });\n    throw new Error(json.error || 'swap failed');\n  }\n\n  log({ step: 'swap_response', token: tokenAddress, pool_type: json.data.pool_type, amount_in: json.data.amount_in });\n  return json.data;\n}\n\nfunction signVersionedTx(swapTxBase58) {\n  const txBytes = bs58.decode(swapTxBase58);\n  const tx = VersionedTransaction.deserialize(txBytes);\n  tx.sign([getWallet()]);\n  const signedBytes = tx.serialize();\n  return Buffer.from(signedBytes).toString('base64');\n}\n\n// ⚠️ THIS IS THE ONLY FUNCTION THAT SUBMITS TRANSACTIONS.\n// /protect is ALWAYS tried first. RPC fallback is internal and fires on 503,\n// or after timeout only if RPC status check shows the tx did not land.\n// Do NOT call connection.sendRawTransaction() directly anywhere else.\n// dailyLoss is updated on every successful spend path (protect, timeout_recovery, rpc_fallback).\nasync function submitTx(signedTxBase64, { token, action } = {}) {\n  if (KILL_SWITCH) throw new Error('KILL_SWITCH is active — all execution halted');\n  if (DRY_RUN) {\n    log({ step: 'dry_run_skip', token, action, message: 'would submit but DRY_RUN=true' });\n    return { signature: null, dry_run: true };\n  }\n\n  let balanceBefore = null;\n  const isBuy = action === 'buy' || action === 'trailing_buy';\n  if (isBuy) {\n    balanceBefore = await connection.getBalance(getWallet().publicKey);\n  }\n\n  try {\n    const protectBody = { signed_tx_base64: signedTxBase64 };\n    assertSchema(validateProtectRequest, protectBody, 'protect request');\n\n    const res = await fetch(`${API}/protect`, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify(protectBody),\n      signal: AbortSignal.timeout(30000),\n    });\n\n    if (res.status === 503) {\n      log({ step: 'protect_503', message: 'falling back to RPC' });\n      return await _rpcFallback(signedTxBase64, { action, balanceBefore });\n    }\n\n    if (!res.ok) {\n      const text = await res.text().catch(() => '');\n      throw new Error(text || `protect failed with HTTP ${res.status}`);\n    }\n\n    const json = await res.json();\n    if (json.status !== 'success') throw new Error(json.error || 'protect failed');\n\n    // Track loss for daily limit (buy and trailing_buy both spend SOL)\n    if (isBuy && json.sol_balance_pre != null && json.sol_balance_post != null) {\n      SAFETY.dailyLoss += (json.sol_balance_pre - json.sol_balance_post);\n    }\n\n    log({ step: 'protect_success', token, signature: json.signature });\n    return json;\n\n  } catch (err) {\n    if (err.name === 'TimeoutError') {\n      log({ step: 'protect_timeout', token, message: 'checking if tx landed' });\n      const check = await checkTxLanded(signedTxBase64);\n\n      if (check.status === 'failed') {\n        log({ step: 'protect_timeout_failed', signature: check.sig, message: 'tx failed on-chain' });\n        throw new Error(`transaction ${check.sig} failed on-chain`);\n      }\n      if (check.status === 'confirmed' || check.status === 'finalized') {\n        if (isBuy && balanceBefore != null) {\n          const balanceAfter = await connection.getBalance(getWallet().publicKey);\n          SAFETY.dailyLoss += (balanceBefore - balanceAfter);\n        }\n        log({ step: 'protect_timeout_landed', signature: check.sig, status: check.status });\n        return { signature: check.sig, status: check.status, via: 'timeout_recovery' };\n      }\n      if (check.status === 'processed') {\n        log({ step: 'protect_timeout_processed', signature: check.sig, message: 'waiting for confirmation' });\n        await new Promise(r => setTimeout(r, 2000));\n        const recheck = await checkTxLanded(signedTxBase64);\n        if (recheck.status === 'failed') throw new Error(`transaction ${recheck.sig} failed on-chain`);\n        if (recheck.landed) {\n          if (isBuy && balanceBefore != null) {\n            const balanceAfter = await connection.getBalance(getWallet().publicKey);\n            SAFETY.dailyLoss += (balanceBefore - balanceAfter);\n          }\n          log({ step: 'protect_timeout_landed', signature: recheck.sig, status: recheck.status });\n          return { signature: recheck.sig, status: recheck.status, via: 'timeout_recovery' };\n        }\n      }\n      if (check.landed) {\n        if (isBuy && balanceBefore != null) {\n          const balanceAfter = await connection.getBalance(getWallet().publicKey);\n          SAFETY.dailyLoss += (balanceBefore - balanceAfter);\n        }\n        log({ step: 'protect_timeout_landed', signature: check.sig, status: check.status || 'unknown' });\n        return { signature: check.sig, status: check.status || 'unknown', via: 'timeout_recovery' };\n      }\n      log({ step: 'protect_timeout_not_landed', message: 'falling back to RPC' });\n      return await _rpcFallback(signedTxBase64, { action, balanceBefore });\n    }\n    throw err;\n  }\n}\n\n// INTERNAL ONLY — never call directly. Updates dailyLoss when action is buy/trailing_buy.\nasync function _rpcFallback(signedTxBase64, { action, balanceBefore } = {}) {\n  const txBytes = Buffer.from(signedTxBase64, 'base64');\n  const sig = await connection.sendRawTransaction(txBytes, { skipPreflight: false });\n  await connection.confirmTransaction(sig, 'confirmed');\n  const isBuy = action === 'buy' || action === 'trailing_buy';\n  if (isBuy && balanceBefore != null) {\n    const balanceAfter = await connection.getBalance(getWallet().publicKey);\n    SAFETY.dailyLoss += (balanceBefore - balanceAfter);\n  }\n  log({ step: 'rpc_fallback_success', signature: sig });\n  return { signature: sig, via: 'rpc_fallback' };\n}\n\nasync function checkTxLanded(signedBase64) {\n  const txBytes = Buffer.from(signedBase64, 'base64');\n  const tx = VersionedTransaction.deserialize(txBytes);\n  const sig = bs58.encode(tx.signatures[0]);\n  const status = await connection.getSignatureStatuses([sig]);\n  const result = status.value[0];\n  if (!result) return { landed: false, sig, status: 'not_found' };\n  if (result.err) return { landed: true, sig, status: 'failed', err: result.err };\n  return { landed: true, sig, status: result.confirmationStatus || 'unknown' };\n}\n\nasync function getHoldings() {\n  const res = await fetch(`${API}/holdings`, {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ wallet_address: getWallet().publicKey.toBase58() }),\n    signal: AbortSignal.timeout(100000),\n  });\n  const json = await res.json();\n  return (json.data || []).filter(t => t.valueNative > SAFETY.MIN_VALUE_NATIVE);\n}\n\n// ---------- WebSocket ----------\n\nfunction connectWsAndRegister(onOrderFilled) {\n  const client = { ws: null, registered: false, pendingQueue: [] };\n\n  function connect() {\n    const ws = new WebSocket(WS_URL);\n    client.ws = ws;\n    client.registered = false;\n\n    ws.on('open', () => log({ step: 'ws_connected' }));\n\n    ws.on('message', async (raw) => {\n      const msg = JSON.parse(raw);\n\n      // Listen for 'challenge', sign nonce, send register with signature.\n      if (msg.type === 'challenge') {\n        const nonce = msg.nonce;\n        const sigBytes = nacl.sign.detached(Buffer.from(nonce, 'utf-8'), getWallet().secretKey);\n        const signature = bs58.encode(sigBytes);\n        ws.send(JSON.stringify({\n          action: 'register',\n          wallet_address: getWallet().publicKey.toBase58(),\n          signature,\n        }));\n      }\n\n      if (msg.type === 'registered') {\n        if (!msg.authenticated) {\n          log({ step: 'ws_error', error: 'registered but authenticated: false — check signature' });\n          return;\n        }\n        client.registered = true;\n        log({ step: 'ws_registered' });\n        while (client.pendingQueue.length > 0) {\n          ws.send(JSON.stringify(client.pendingQueue.shift()));\n        }\n      }\n      if (msg.type === 'order_filled') {\n        await onOrderFilled(msg);\n      }\n      if (msg.type === 'order_created') {\n        log({ step: 'order_placed', order_id: msg.order_id, token: msg.token_address, action: msg.order_type, target_mcap: msg.target_mcap });\n      }\n      if (msg.type === 'heartbeat') return;\n      if (msg.type === 'error') log({ step: 'ws_error', error: msg.message });\n    });\n\n    ws.on('close', () => {\n      log({ step: 'ws_disconnected', message: 'reconnecting in 3s' });\n      client.registered = false;\n      setTimeout(connect, 3000);\n    });\n\n    ws.on('error', (err) => log({ step: 'ws_error', error: err.message }));\n  }\n\n  connect();\n\n  return {\n    send: (payload) => {\n      // Safety: kill switch halts everything\n      if (KILL_SWITCH) {\n        log({ step: 'safety_blocked', action: payload.action, reason: 'KILL_SWITCH active' });\n        return;\n      }\n      // DRY_RUN: only read-only actions pass through\n      const readOnlyActions = ['register', 'list_orders', 'check_order'];\n      if (DRY_RUN && !readOnlyActions.includes(payload.action)) {\n        log({ step: 'dry_run_skip', action: payload.action, token: payload.token_address, message: `would send ${payload.action} but DRY_RUN=true` });\n        return;\n      }\n      // Safety: check denylist for order placement\n      if (['sell', 'buy', 'trailing_sell', 'trailing_buy'].includes(payload.action)) {\n        if (isTokenOnCooldown(payload.token_address)) {\n          const retryAfter = SAFETY.DENYLIST.get(payload.token_address);\n          log({ step: 'safety_blocked', token: payload.token_address, reason: 'cooldown_active', retry_after_ms: retryAfter });\n          return;\n        }\n        // Enforce MAX_BUY_LAMPORTS for WebSocket buy and trailing_buy (same as buildSwap)\n        if (payload.action === 'buy' || payload.action === 'trailing_buy') {\n          const amount = payload.amount;\n          if (typeof amount !== 'number' || amount > SAFETY.MAX_BUY_LAMPORTS) {\n            log({ step: 'safety_blocked', action: payload.action, token: payload.token_address, reason: 'amount exceeds MAX_BUY_LAMPORTS', amount, max: SAFETY.MAX_BUY_LAMPORTS });\n            return;\n          }\n        }\n      }\n      if (!client.registered) {\n        client.pendingQueue.push(payload);\n        log({ step: 'ws_queued', action: payload.action, message: 'not yet registered' });\n        return;\n      }\n      client.ws.send(JSON.stringify(payload));\n    },\n    close: () => client.ws.close(),\n  };\n}\n\nasync function handleOrderFilled(msg) {\n  try {\n    assertSchema(validateOrderFilled, msg, 'order_filled message');\n  } catch (e) {\n    log({ step: 'order_fill_error', order_id: msg.order_id || 'unknown', error: e.message });\n    return;\n  }\n\n  const { order_id, order_type, triggered_mcap, filled_mcap, token_address } = msg;\n  const swap_tx = msg.data?.swap_tx;\n\n  log({ step: 'order_filled', order_id, order_type, token: token_address, triggered_mcap, filled_mcap });\n\n  if (msg.already_dispatched) {\n    log({ step: 'order_fill_skipped', order_id, reason: 'already_dispatched' });\n    return;\n  }\n\n  // Verify server_signature before signing or submitting.\n  const verified = await verifyOrderFilledSignature(msg);\n  if (!verified) {\n    log({ step: 'order_fill_skipped', order_id, reason: 'server_signature_verification_failed' });\n    return;\n  }\n\n  if (!swap_tx) {\n    log({ step: 'order_fill_error', order_id, error: 'missing swap_tx' });\n    return;\n  }\n\n  // Staleness check (all fills; skip ratio when filled_mcap is 0 or null)\n  if (filled_mcap != null && filled_mcap > 0 && triggered_mcap != null && triggered_mcap / filled_mcap < 0.85) {\n    log({ step: 'order_fill_skipped', order_id, reason: 'stale', triggered_mcap, filled_mcap });\n    return;\n  }\n\n  const signedBase64 = signVersionedTx(swap_tx);\n  const result = await submitTx(signedBase64, { token: token_address, action: order_type });\n  log({ step: 'order_fill_submitted', order_id, signature: result.signature });\n}\n\n// ---------- Preflight ----------\n\nasync function preflight() {\n  const checks = [];\n\n  checks.push({ name: 'PRIVATE_KEY loaded', pass: !!process.env.PRIVATE_KEY });\n  if (!process.env.RPC_URL) {\n    console.log('⚠ RPC_URL not set — using default public RPC (rate-limited, not recommended for production)');\n  }\n  checks.push({ name: 'RPC_URL configured', pass: true, url: process.env.RPC_URL ? '(custom)' : '(default public)' });\n\n  try {\n    const pubkey = getWallet().publicKey.toBase58();\n    checks.push({ name: 'Wallet loads', pass: true, pubkey });\n  } catch (e) {\n    checks.push({ name: 'Wallet loads', pass: false, error: e.message });\n  }\n\n  try {\n    const slot = await connection.getSlot();\n    checks.push({ name: 'RPC reachable', pass: true, slot });\n  } catch (e) {\n    checks.push({ name: 'RPC reachable', pass: false, error: e.message });\n  }\n\n  try {\n    const balance = await connection.getBalance(getWallet().publicKey);\n    checks.push({ name: 'SOL balance > 0.01', pass: balance > 10_000_000, balance });\n  } catch (e) {\n    checks.push({ name: 'SOL balance', pass: false, error: e.message });\n  }\n\n  try {\n    const holdings = await getHoldings();\n    checks.push({ name: '/holdings responds', pass: true, token_count: holdings.length });\n  } catch (e) {\n    checks.push({ name: '/holdings responds', pass: false, error: e.message });\n  }\n\n  // Preflight WS check — listen for 'challenge', sign nonce, send register with signature,\n  // then verify authenticated: true in the 'registered' response.\n  try {\n    await new Promise((resolve, reject) => {\n      const ws = new WebSocket(WS_URL);\n      const timeout = setTimeout(() => { ws.close(); reject(new Error('ws timeout')); }, 10000);\n      ws.on('message', (raw) => {\n        const msg = JSON.parse(raw);\n        if (msg.type === 'challenge') {\n          const nonce = msg.nonce;\n          const sigBytes = nacl.sign.detached(Buffer.from(nonce, 'utf-8'), getWallet().secretKey);\n          const signature = bs58.encode(sigBytes);\n          ws.send(JSON.stringify({\n            action: 'register',\n            wallet_address: getWallet().publicKey.toBase58(),\n            signature,\n          }));\n        }\n        if (msg.type === 'registered') {\n          clearTimeout(timeout);\n          ws.close();\n          if (!msg.authenticated) {\n            reject(new Error('registered but authenticated: false — check server signature'));\n          } else {\n            resolve();\n          }\n        }\n      });\n      ws.on('error', (err) => { clearTimeout(timeout); reject(err); });\n    });\n    checks.push({ name: 'WS register', pass: true });\n  } catch (e) {\n    checks.push({ name: 'WS register', pass: false, error: e.message });\n  }\n\n  console.log('\\n=== PREFLIGHT ===');\n  checks.forEach(c => console.log(`${c.pass ? '✓' : '✗'} ${c.name}`, c.pass ? '' : `— ${c.error || ''}`));\n  const allPass = checks.every(c => c.pass);\n  console.log(`\\n${allPass ? '🟢 ALL CHECKS PASSED — ready to go live' : '🔴 CHECKS FAILED — fix before going live'}\\n`);\n  console.log(`Mode: ${DRY_RUN ? '📋 DRY RUN (set DRY_RUN=false to go live)' : '🔴 LIVE TRADING'}\\n`);\n  return allPass;\n}\n\n// ---------- Usage (call from main, never at module load) ----------\n\nasync function main() {\n  const ready = await preflight();\n  if (!ready) process.exit(1);\n\n  const demoTokenMint = process.env.DEMO_TOKEN_MINT;\n  if (!demoTokenMint) {\n    log({ step: 'demo_skipped', message: 'set DEMO_TOKEN_MINT to run write-path examples in main()' });\n    return;\n  }\n  const demoBuyAmountLamports = Number(process.env.DEMO_BUY_AMOUNT_LAMPORTS || 100_000_000);\n  if (!Number.isFinite(demoBuyAmountLamports) || demoBuyAmountLamports <= 0) {\n    throw new Error('DEMO_BUY_AMOUNT_LAMPORTS must be a positive integer');\n  }\n\n  // Instant buy\n  const swap = await buildSwap({ tokenAddress: demoTokenMint, action: 'buy', amount: demoBuyAmountLamports });\n  const signed = signVersionedTx(swap.swap_tx);\n  const result = await submitTx(signed, { token: demoTokenMint, action: 'buy' });\n\n  // Instant sell (only if wallet has sellable tokens)\n  const holdings = await getHoldings();\n  if (holdings.length === 0) {\n    log({ step: 'sell_skipped', message: 'no sellable tokens in wallet' });\n  } else {\n    const token = holdings[0];\n    const swap2 = await buildSwap({ tokenAddress: token.address, action: 'sell', holdingsPercentage: 10000 });\n    const signed2 = signVersionedTx(swap2.swap_tx);\n    const result2 = await submitTx(signed2, { token: token.address, action: 'sell' });\n  }\n\n  // Limit order via WS\n  const ws = connectWsAndRegister(handleOrderFilled);\n  // Silent expiry guard: poll open orders periodically (expiry has no server event).\n  setInterval(() => ws.send({ action: 'list_orders' }), 60000);\n  // after registered:\n  ws.send({ action: 'sell', token_address: demoTokenMint, holdings_percentage: 10000, target: 20000, slippage: 1500, expiry_hours: 144 });\n}\n\nmain().catch(console.error);\n```\n---\n\n## Signing / Encoding Test Vectors\n\nUse these to verify your signing pipeline produces valid output.\n\n### Test: base58 decode → sign → base64 encode\n\n```\nInput swap_tx (base58):\n  (any valid base58 string from /swap response)\n\nExpected pipeline:\n  bs58.decode(swap_tx)           → Uint8Array (raw bytes)\n  VersionedTransaction.deserialize(bytes)  → tx object (check: tx.message exists)\n  tx.sign([wallet])              → void (modifies tx in place, fills signature slots)\n  tx.serialize()                 → Uint8Array (same length — signature slots are pre-allocated)\n  Buffer.from(bytes).toString('base64') → string (starts with alphanumeric, contains +/=)\n\nSelf-check assertions:\n  1. Decoded bytes length > 0\n  2. tx.message exists (versioned tx from TradeRouter)\n  3. After sign: tx.signatures[0] is not all zeros (signature was written)\n  4. Signed bytes length === decoded bytes length (slots pre-allocated, signing fills them)\n  5. Base64 output does NOT start with a bracket or brace (not JSON)\n  6. Base64 output is NOT the same as the base58 input (different encoding)\n```\n\n### Quick validation function\n\n```javascript\nfunction validateSignedTx(swapTxBase58, signedBase64) {\n  const unsignedBytes = bs58.decode(swapTxBase58);\n  const signedBytes = Buffer.from(signedBase64, 'base64');\n  console.assert(unsignedBytes.length > 0, 'decoded bytes empty');\n  console.assert(signedBytes.length === unsignedBytes.length, 'signed and unsigned should be same length (signature slots pre-allocated)');\n  console.assert(signedBase64 !== swapTxBase58, 'output must differ from input (different encoding)');\n  console.assert(!/^[{[]/.test(signedBase64), 'base64 should not look like JSON');\n  // Deserialize signed to verify it's valid\n  const tx = VersionedTransaction.deserialize(signedBytes);\n  console.assert(tx.signatures[0].some(b => b !== 0), 'signature should not be zeros');\n  console.log('✓ signing pipeline valid');\n}\n```\n\n---\n\n## Request / Response Schemas\n\nMachine-readable schemas for pre-validation. The reference client compiles and enforces these required fields with `ajv` before network calls.\n\n### POST /swap — request\n\n```json\n{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"type\": \"object\",\n  \"required\": [\"wallet_address\", \"token_address\", \"action\"],\n  \"properties\": {\n    \"wallet_address\": { \"type\": \"string\", \"minLength\": 32, \"maxLength\": 44 },\n    \"token_address\": { \"type\": \"string\", \"minLength\": 32, \"maxLength\": 44 },\n    \"action\": { \"type\": \"string\", \"enum\": [\"buy\", \"sell\"] },\n    \"amount\": { \"type\": \"integer\", \"minimum\": 1 },\n    \"holdings_percentage\": { \"type\": \"integer\", \"minimum\": 1, \"maximum\": 10000 },\n    \"slippage\": { \"type\": \"integer\", \"minimum\": 100, \"maximum\": 2500, \"default\": 500 }\n  },\n  \"if\": { \"properties\": { \"action\": { \"const\": \"buy\" } } },\n  \"then\": { \"required\": [\"amount\"], \"not\": { \"required\": [\"holdings_percentage\"] } },\n  \"else\": { \"required\": [\"holdings_percentage\"], \"not\": { \"required\": [\"amount\"] } }\n}\n```\n\n### POST /swap — success response\n\n```json\n{\n  \"type\": \"object\",\n  \"required\": [\"status\", \"data\"],\n  \"properties\": {\n    \"status\": { \"const\": \"success\" },\n    \"data\": {\n      \"type\": \"object\",\n      \"required\": [\"swap_tx\"],\n      \"properties\": {\n        \"swap_tx\": { \"type\": \"string\", \"minLength\": 100 },\n        \"pool_type\": { \"type\": \"string\" },\n        \"pool_address\": { \"type\": \"string\" },\n        \"amount_in\": { \"type\": \"integer\" },\n        \"min_amount_out\": { \"type\": \"integer\" },\n        \"price_impact\": { \"type\": \"number\" },\n        \"slippage\": { \"type\": \"integer\" },\n        \"decimals\": { \"type\": \"integer\" }\n      }\n    }\n  }\n}\n```\n\n### POST /protect — request\n\n```json\n{\n  \"type\": \"object\",\n  \"required\": [\"signed_tx_base64\"],\n  \"properties\": {\n    \"signed_tx_base64\": { \"type\": \"string\", \"minLength\": 100, \"pattern\": \"^[A-Za-z0-9+/]+=*$\" }\n  }\n}\n```\n\n### POST /holdings — response (non-empty)\n\n```json\n{\n  \"type\": \"object\",\n  \"properties\": {\n    \"data\": {\n      \"type\": \"array\",\n      \"items\": {\n        \"type\": \"object\",\n        \"required\": [\"address\", \"amount\", \"decimals\"],\n        \"properties\": {\n          \"address\": { \"type\": \"string\" },\n          \"valueNative\": { \"type\": [\"integer\", \"null\"] },\n          \"amount\": { \"type\": \"integer\" },\n          \"decimals\": { \"type\": \"integer\" }\n        }\n      }\n    }\n  }\n}\n```\n\n### WebSocket order_filled\n\nWhen `already_dispatched` is true, the server may omit `data` or `data.swap_tx` (idempotent ack). Schema must not require them.\n\n```json\n{\n  \"type\": \"object\",\n  \"required\": [\"type\", \"order_id\", \"order_type\", \"status\"],\n  \"properties\": {\n    \"type\": { \"const\": \"order_filled\" },\n    \"order_id\": { \"type\": \"string\", \"format\": \"uuid\" },\n    \"order_type\": { \"type\": \"string\", \"enum\": [\"sell\", \"buy\", \"trailing_sell\", \"trailing_buy\"] },\n    \"status\": { \"type\": \"string\", \"enum\": [\"success\"] },\n    \"already_dispatched\": { \"type\": \"boolean\" },\n    \"entry_mcap\": { \"type\": \"number\" },\n    \"triggered_mcap\": { \"type\": \"number\" },\n    \"filled_mcap\": { \"type\": [\"number\", \"null\"] },\n    \"target_mcap\": { \"type\": \"number\" },\n    \"triggered_at\": { \"type\": \"number\" },\n    \"filled_at\": { \"type\": \"number\" },\n    \"token_address\": { \"type\": \"string\" },\n    \"server_signature\": { \"type\": \"string\" },\n    \"data\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"swap_tx\": { \"type\": \"string\", \"minLength\": 100 },\n        \"token_address\": { \"type\": \"string\" },\n        \"pool_type\": { \"type\": \"string\" },\n        \"pool_address\": { \"type\": \"string\" },\n        \"amount_in\": { \"type\": \"integer\" },\n        \"min_amount_out\": { \"type\": \"integer\" },\n        \"price_impact\": { \"type\": \"number\" },\n        \"slippage\": { \"type\": \"integer\" },\n        \"decimals\": { \"type\": \"integer\" }\n      }\n    }\n  }\n}\n```\n\n---\n\n## Retry / Idempotency Policy\n\n| Error class | Retry? | Max attempts | Backoff | Notes |\n|-------------|--------|-------------|---------|-------|\n| 400 from /swap | **No** | 0 | — | Bad request. Fix payload. |\n| 422 from /swap | **No** | 0 | — | Missing fields. Fix payload. |\n| \"Error running simulation\" | **Cooldown** | 0 immediate | 15m | Mark token unswappable for the session and retry later only if needed. |\n| 503 from /protect | **Auto-fallback** | 1 | — | `submitTx()` falls back to RPC internally. No manual action. |\n| /protect timeout (30s) | **Check first** | 1 | — | Check tx status via `connection.getSignatureStatuses([sig])`. Only retry if `value[0]` is null (not found). |\n| /holdings timeout | **Yes** | 2 | 5s | Endpoint is slow, not broken. |\n| /swap 5xx | **Yes** | 2 | 3s | Server issue, may resolve. |\n| WS disconnect | **Reconnect** | ∞ | 3s fixed | Re-register. Orders persist. |\n| WS error message | **No** | 0 | — | Log and surface to user. |\n| On-chain swap failure | **Maybe** | 1 | — | Increase slippage to 2500, retry once. If fails again, skip. |\n\n**Idempotency:** Solana transactions include a recent blockhash. A signed tx can only land once. If /protect times out, the tx may have landed — always check via RPC before re-signing a new tx.\n\n---\n\n## Timeout and Confirmation Contracts\n\n| Endpoint | Timeout | On timeout |\n|----------|---------|------------|\n| `POST /swap` | 15s | Retry once with 3s backoff |\n| `POST /protect` | 30s | Call `connection.getSignatureStatuses([sig])`. If `value[0]` is null → tx didn't land, safe to retry. If `value[0].err` → tx failed on-chain, do not retry. If `confirmationStatus` is `'processed'` → transitional, wait 2s and re-check. If `'confirmed'` or `'finalized'` → landed, do not retry. `submitTx()` handles all this internally. |\n| `POST /holdings` | 100s | Retry once with 5s backoff |\n| WebSocket connect | 10s | Retry with 3s backoff |\n\n### Post-timeout /protect check\n\n`checkTxLanded()` is defined in the Reference Client above. Returns `{ landed, sig, status }`:\n- `{ landed: false, sig, status: 'not_found' }` — safe to retry via RPC fallback\n- `{ landed: true, sig, status: 'failed', err }` — tx errored on-chain, do not retry\n- `{ landed: true, sig, status: 'processed' }` — transitional, wait 2s and re-check\n- `{ landed: true, sig, status: 'confirmed' }` — tx landed, do not retry\n- `{ landed: true, sig, status: 'finalized' }` — tx landed and finalized, do not retry\n\n`sig` is the real base58 transaction signature for reconciliation. `submitTx()` handles all of this internally.\n\n---\n\n## WebSocket Lifecycle State Machine\n\n```\n ┌──────────────┐\n │ DISCONNECTED │ ←── ws.close / error / timeout\n └──────┬───────┘\n        │ connect\n        ▼\n ┌──────────────┐\n │  CHALLENGE   │ ←── server sends {\"type\":\"challenge\",\"nonce\":\"...\"}\n └──────┬───────┘\n        │ sign nonce; send register with wallet_address + signature\n        ▼\n ┌──────────────┐\n │  REGISTERED  │ ←── server sends {\"type\":\"registered\",\"authenticated\":true}\n └──────┬───────┘\n        │ (can now send orders)\n        ▼\n ┌──────────────┐\n │    ACTIVE    │ ←── orders placed, listening for fills\n └──────────────┘\n```\n\n**Rules:**\n- **DISCONNECTED:** no sends allowed. Reconnect immediately.\n- **CHALLENGE:** sign nonce and send `register` with wallet_address + signature only. All other sends rejected until REGISTERED.\n- **REGISTERED / ACTIVE:** all actions allowed. Only enter REGISTERED state when `authenticated: true` is confirmed.\n- On any disconnect, state resets to DISCONNECTED. Orders persist server-side.\n- Queue any order sends that arrive during DISCONNECTED/CHALLENGE and flush after REGISTERED.\n\n---\n\n\n## Execution Safety Guards\n\nAll safety enforcement is **built into the reference client** — `enforceSafety()` is called inside `buildSwap()`, `KILL_SWITCH` is checked in `submitTx()` and `ws.send()`, `markUnswappable()` is called on \"Error running simulation\" responses, token cooldown is checked before WS order placement, and **dailyLoss** is updated on every successful spend path (protect success, timeout recovery, RPC fallback) for both `buy` and `trailing_buy`. **MAX_BUY_LAMPORTS** is enforced in `ws.send()` for `buy` and `trailing_buy` orders so WebSocket orders cannot bypass the per-trade limit.\n\n**Defaults (adjust per deployment):**\n\n`MAX_BUY_LAMPORTS = 0.5 SOL` is intentionally conservative for first deploys. Increase only after risk limits, kill switch, and monitoring are verified.\n\n| Guard | Default | Enforced in |\n|-------|---------|-------------|\n| MAX_BUY_LAMPORTS | 500,000,000 (0.5 SOL, conservative starter value) | `buildSwap()` via `enforceSafety()`, `ws.send()` for buy/trailing_buy |\n| MAX_SLIPPAGE_BPS | 2500 (25%) | `buildSwap()` via `enforceSafety()` |\n| MIN_SLIPPAGE_BPS | 100 (1%) | `buildSwap()` via `enforceSafety()` |\n| MIN_VALUE_NATIVE | 0 (`valueNative > 0`, defensive) | `getHoldings()` filter |\n| UNSWAPPABLE_COOLDOWN_MS | 900,000 (15 minutes) | `markUnswappable()` |\n| MAX_DAILY_LOSS_LAMPORTS | 2,000,000,000 (2 SOL) | Updated on every spend path: `/protect` success, timeout_recovery, `_rpcFallback()`; `enforceSafety()` → activates KILL_SWITCH |\n| DENYLIST | empty Map (session-scoped cooldown) | `buildSwap()`, `ws.send()`, auto-populated by `markUnswappable()` |\n| KILL_SWITCH | false | `submitTx()`, `ws.send()`, auto-activated on daily loss limit |\n\n**Unswappable token cooldown:** When `/swap` returns \"Error running simulation\", `buildSwap()` calls `markUnswappable(tokenAddress)`, placing that token on a 15-minute session cooldown. This avoids retry loops while allowing future retries later.\n\n**Emergency kill:** Set `KILL_SWITCH = true` to halt all execution. Also auto-activates when `dailyLoss > MAX_DAILY_LOSS_LAMPORTS`.\n\n---\n\n## Logging Format\n\nAll logging is through the `log()` function in the reference client, which outputs JSON lines with automatic timestamp and wallet fields.\n\n**Every step emitted by the reference client:**\n\n| Step | Emitted by | Required fields |\n|------|-----------|----------------|\n| swap_request | `buildSwap()` | token, action, amount/holdings_percentage, slippage |\n| swap_response | `buildSwap()` | token, pool_type, amount_in |\n| swap_error | `buildSwap()` | token, error |\n| safety_blocked | `enforceSafety()`, `markUnswappable()`, `ws.send()` | token, reason |\n| dry_run_skip | `submitTx()`, `ws.send()` | token, action, message |\n| protect_success | `submitTx()` | token, signature |\n| protect_503 | `submitTx()` | message |\n| protect_timeout | `submitTx()` | token, message |\n| protect_timeout_landed | `submitTx()` | signature, status |\n| protect_timeout_processed | `submitTx()` | signature, message |\n| protect_timeout_failed | `submitTx()` | signature, message |\n| protect_timeout_not_landed | `submitTx()` | message |\n| rpc_fallback_success | `_rpcFallback()` | signature |\n| sell_skipped | `main()` | message |\n| ws_connected | WS `open` handler | — |\n| ws_registered | WS `registered` handler | — |\n| ws_disconnected | WS `close` handler | message |\n| ws_error | WS `error` handler | error |\n| ws_queued | `ws.send()` | action, message |\n| order_placed | WS `order_created` handler | order_id, token, action, target_mcap |\n| order_filled | `handleOrderFilled()` | order_id, order_type, token, triggered_mcap, filled_mcap |\n| order_fill_error | `handleOrderFilled()` | order_id, error |\n| order_fill_skipped | `handleOrderFilled()` | order_id, reason, triggered_mcap, filled_mcap |\n| order_fill_submitted | `handleOrderFilled()` | order_id, signature |\n| order_fill_verify_failed | `verifyOrderFilledSignature()` | order_id, reason |\n\n---\n\n## Dry-Run / Paper Mode\n\n**Safe-by-default:** `DRY_RUN` is **true** unless you explicitly set `DRY_RUN=false`. A fresh agent runs paper mode automatically — no accidental live trades on first run.\n\nDry-run is enforced at the two chokepoints in the reference client — `submitTx()` and `ws.send()`. The agent runs the full pipeline (fetches swap quotes, enforces safety guards, logs everything) but:\n\n- **`submitTx()`** short-circuits before submission and returns `{ signature: null, dry_run: true }`\n- **`ws.send()`** blocks all mutating actions (`sell`, `buy`, `trailing_sell`, `trailing_buy`, `cancel_order`, `extend_order`) and logs intent\n- Only read-only operations pass through: `register`, `list_orders`, `check_order`\n\nThere is no separate \"paper mode function.\" The same `main()` code path runs in both modes — `DRY_RUN` just gates the irreversible actions.\n\n```bash\n# Paper mode (default — no DRY_RUN env needed)\nPRIVATE_KEY=... node agent.js\n\n# Live mode — must explicitly opt in\nDRY_RUN=false PRIVATE_KEY=... node agent.js\n```\n\n---\n\n## Preflight Startup Checklist\n\nThe `preflight()` function is built into the reference client. It runs automatically at the start of `main()` and exits the process if any check fails.\n\n**Checks performed:**\n\n| Check | Fails if |\n|-------|----------|\n| PRIVATE_KEY loaded | env var missing |\n| RPC_URL configured | always passes (warns if using default public RPC) |\n| Wallet loads | key is invalid/undecodable |\n| RPC reachable | `getSlot()` fails |\n| SOL balance > 0.01 | balance < 10,000,000 lamports |\n| /holdings responds | endpoint unreachable or times out |\n| WS register | WebSocket connect, challenge–response, or `authenticated: true` confirmation fails within 10s |\n\nAfter all checks, preflight reports mode (`DRY RUN` or `LIVE TRADING`).","tags":{"latest":"1.3.0"},"stats":{"comments":0,"downloads":1288,"installsAllTime":49,"installsCurrent":1,"stars":0,"versions":7},"createdAt":1771372682631,"updatedAt":1779077061319},"latestVersion":{"version":"1.3.0","createdAt":1772691179526,"changelog":"- Quantity removed for TWAP (and TWAP combo) orders; use \"amount\" or \"holdings_percentage\" for sell instead.\n- Added support and documentation for combo WebSocket order types, including limit+TWAP, trailing+TWAP, limit+trailing, and limit+trailing+TWAP orders.\n- Updated endpoint reference table to list new combo order actions and their associated WebSocket commands.\n- No changes to REST endpoints or existing order types.","license":null},"metadata":null,"owner":{"handle":"re-bruce-wayne","userId":"s173rgwzsqgdhbybff2szjz5kx85e20v","displayName":"Bruce Wayne","image":"https://avatars.githubusercontent.com/u/196209605?v=4"},"moderation":{"isSuspicious":false,"isMalwareBlocked":false,"verdict":"clean","reasonCodes":["review.llm_review"],"summary":"Review: review.llm_review","engineVersion":"v2.4.24","updatedAt":1779919383671}}