{
  "schemaVersion": "1.0",
  "item": {
    "slug": "trade-router",
    "name": "Solana Trading Api",
    "source": "tencent",
    "type": "skill",
    "category": "开发工具",
    "sourceUrl": "https://clawhub.ai/re-bruce-wayne/trade-router",
    "canonicalUrl": "https://clawhub.ai/re-bruce-wayne/trade-router",
    "targetPlatform": "OpenClaw"
  },
  "install": {
    "downloadMode": "redirect",
    "downloadUrl": "/downloads/trade-router",
    "sourceDownloadUrl": "https://wry-manatee-359.convex.site/api/v1/download?slug=trade-router",
    "sourcePlatform": "tencent",
    "targetPlatform": "OpenClaw",
    "installMethod": "Manual import",
    "extraction": "Extract archive",
    "prerequisites": [
      "OpenClaw"
    ],
    "packageFormat": "ZIP package",
    "includedAssets": [
      "SKILL.md"
    ],
    "primaryDoc": "SKILL.md",
    "quickSetup": [
      "Download the package from Yavira.",
      "Extract the archive and review SKILL.md first.",
      "Import or place the package into your OpenClaw setup."
    ],
    "agentAssist": {
      "summary": "Hand the extracted package to your coding agent with a concrete install brief instead of figuring it out manually.",
      "steps": [
        "Download the package from Yavira.",
        "Extract it into a folder your agent can access.",
        "Paste one of the prompts below and point your agent at the extracted folder."
      ],
      "prompts": [
        {
          "label": "New install",
          "body": "I downloaded a skill package from Yavira. Read SKILL.md from the extracted folder and install it by following the included instructions. Tell me what you changed and call out any manual steps you could not complete."
        },
        {
          "label": "Upgrade existing",
          "body": "I downloaded an updated skill package from Yavira. Read SKILL.md from the extracted folder, compare it with my current installation, and upgrade it while preserving any custom configuration unless the package docs explicitly say otherwise. Summarize what changed and any follow-up checks I should run."
        }
      ]
    },
    "sourceHealth": {
      "source": "tencent",
      "status": "healthy",
      "reason": "direct_download_ok",
      "recommendedAction": "download",
      "checkedAt": "2026-05-07T17:22:31.273Z",
      "expiresAt": "2026-05-14T17:22:31.273Z",
      "httpStatus": 200,
      "finalUrl": "https://wry-manatee-359.convex.site/api/v1/download?slug=afrexai-annual-report",
      "contentType": "application/zip",
      "probeMethod": "head",
      "details": {
        "probeUrl": "https://wry-manatee-359.convex.site/api/v1/download?slug=afrexai-annual-report",
        "contentDisposition": "attachment; filename=\"afrexai-annual-report-1.0.0.zip\"",
        "redirectLocation": null,
        "bodySnippet": null
      },
      "scope": "source",
      "summary": "Source download looks usable.",
      "detail": "Yavira can redirect you to the upstream package for this source.",
      "primaryActionLabel": "Download for OpenClaw",
      "primaryActionHref": "/downloads/trade-router"
    },
    "validation": {
      "installChecklist": [
        "Use the Yavira download entry.",
        "Review SKILL.md after the package is downloaded.",
        "Confirm the extracted package contains the expected setup assets."
      ],
      "postInstallChecks": [
        "Confirm the extracted package includes the expected docs or setup files.",
        "Validate the skill or prompts are available in your target agent workspace.",
        "Capture any manual follow-up steps the agent could not complete."
      ]
    },
    "downloadPageUrl": "https://openagent3.xyz/downloads/trade-router",
    "agentPageUrl": "https://openagent3.xyz/skills/trade-router/agent",
    "manifestUrl": "https://openagent3.xyz/skills/trade-router/agent.json",
    "briefUrl": "https://openagent3.xyz/skills/trade-router/agent.md"
  },
  "agentAssist": {
    "summary": "Hand the extracted package to your coding agent with a concrete install brief instead of figuring it out manually.",
    "steps": [
      "Download the package from Yavira.",
      "Extract it into a folder your agent can access.",
      "Paste one of the prompts below and point your agent at the extracted folder."
    ],
    "prompts": [
      {
        "label": "New install",
        "body": "I downloaded a skill package from Yavira. Read SKILL.md from the extracted folder and install it by following the included instructions. Tell me what you changed and call out any manual steps you could not complete."
      },
      {
        "label": "Upgrade existing",
        "body": "I downloaded an updated skill package from Yavira. Read SKILL.md from the extracted folder, compare it with my current installation, and upgrade it while preserving any custom configuration unless the package docs explicitly say otherwise. Summarize what changed and any follow-up checks I should run."
      }
    ]
  },
  "documentation": {
    "source": "clawhub",
    "primaryDoc": "SKILL.md",
    "sections": [
      {
        "title": "TradeRouter",
        "body": "Solana swap builder and limit-order engine.\n\nBase URL: https://api.traderouter.ai\nWebSocket: wss://api.traderouter.ai/ws\nWebsite: https://traderouter.ai\nAuth: None. No API key. Wallet address is the only identity.\nContent-Type: All REST requests require Content-Type: application/json."
      },
      {
        "title": "Before you use this skill",
        "body": "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\nAuthentication 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\nService origin: This skill documents the API only. The service website is https://traderouter.ai (API at api.traderouter.ai).\n\nMEV protection: The POST /protect endpoint accepts signed transactions and uses Jito and a staked connection lane to process your transaction.\n\nRisk: No API key is requested; identity is the wallet address (and for WebSocket orders, proof via the signed challenge)."
      },
      {
        "title": "When to use which endpoint",
        "body": "User intentEndpointMethodInstant buy or sell of a tokenPOST /swap → sign → POST /protectRESTCheck wallet token balancesPOST /holdingsRESTSubmit an already-signed transaction with MEV protectionPOST /protectRESTMarket cap / price for token(s)GET /mcap?tokens=MINT1,MINT2RESTFlex trade card image for wallet + tokenGET /flex?wallet_address=…&token_address=…RESTLimit order (take-profit, stop-loss, dip buy, breakout)WebSocket sell or buy actionWSTrailing stop (auto-adjust with market)WebSocket trailing_sell or trailing_buyWSTWAP (time-weighted buy/sell over duration)WebSocket twap_buy or twap_sellWSLimit then TWAPWebSocket limit_twap_sell or limit_twap_buyWSTrailing then TWAPWebSocket trailing_twap_sell or trailing_twap_buyWSLimit then trailing (single swap on trail trigger)WebSocket limit_trailing_sell or limit_trailing_buyWSLimit then trailing then TWAPWebSocket limit_trailing_twap_sell or limit_trailing_twap_buyWSManage orders (check, list, cancel, extend)WebSocket actionsWSDCA (recurring small buys)WebSocket buy orders — see DCA section belowWS"
      },
      {
        "title": "POST /swap — Build unsigned swap transaction",
        "body": "Returns an unsigned transaction (base58). Client must sign it, then submit via POST /protect.\n\nSell uses holdings_percentage (bps). Buy uses amount (lamports). Never mix them."
      },
      {
        "title": "Request",
        "body": "{\n  \"wallet_address\": \"SOLANA_PUBKEY\",\n  \"token_address\": \"SPL_TOKEN_MINT\",\n  \"action\": \"buy\",\n  \"amount\": 100000000,\n  \"slippage\": 1500\n}\n\nFieldTypeRequiredNoteswallet_addressstringyesSolana pubkeytoken_addressstringyesSPL token mint addressactionstringyes\"buy\" or \"sell\"amountintegerbuy onlyLamports. Only for buy.holdings_percentageintegersell onlyBps (10000 = 100%). Only for sell.slippageintegernoBps, 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."
      },
      {
        "title": "Success response",
        "body": "{\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\npool_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."
      },
      {
        "title": "Error response",
        "body": "{\n  \"status\": \"error\",\n  \"error\": \"Insufficient balance\",\n  \"code\": 400\n}\n\ncode 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."
      },
      {
        "title": "POST /protect — Submit signed transaction (MEV protected)",
        "body": "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.\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."
      },
      {
        "title": "Request",
        "body": "{\n  \"signed_tx_base64\": \"<base64_signed_transaction>\"\n}"
      },
      {
        "title": "Success response",
        "body": "{\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\nUse sol_balance_post and token_balances to update holdings after the swap."
      },
      {
        "title": "Error handling",
        "body": "{\"status\":\"error\",\"error\":\"message\"} — general error.\n503 — 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.\nTimeout — the tx may have landed on-chain. Check tx status via RPC before retrying."
      },
      {
        "title": "POST /holdings — Scan wallet token balances",
        "body": "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."
      },
      {
        "title": "Request",
        "body": "{\n  \"wallet_address\": \"SOLANA_PUBKEY\"\n}"
      },
      {
        "title": "Response",
        "body": "Empty wallet: {}\n\nWallet with holdings:\n\n{\n  \"data\": [\n    {\n      \"address\": \"SPL_TOKEN_MINT\",\n      \"valueNative\": 1500000000,\n      \"amount\": 25952334242,\n      \"decimals\": 6\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."
      },
      {
        "title": "GET /mcap — Market cap data",
        "body": "Return market cap (and optional price/pool) for given token addresses.\n\nRequest: GET https://api.traderouter.ai/mcap?tokens=MINT1,MINT2 (comma-delimited Solana mint addresses).\n\nResponse: Object keyed by token address. Each value can include marketCap, pair_address, pool_type, priceUsd. Empty object if no tokens provided or none found."
      },
      {
        "title": "GET /flex — Flex trade card PNG",
        "body": "Generate a flex trade card image for a wallet and token mint.\n\nRequest: GET https://api.traderouter.ai/flex?wallet_address=WALLET&token_address=MINT.\n\nResponse: image/png. 400 on invalid params; 501 if flex_card_image deps not available; 500 on server error."
      },
      {
        "title": "Instant swap workflow (step by step)",
        "body": "The encoding changes: /swap returns base58, /protect expects base64. Do not send base58 to /protect.\n\nPOST /swap with wallet_address, token_address, action, amount or holdings_percentage, slippage\nRead data.swap_tx from response — this is base58 encoded\nDecode from base58 into raw bytes\nDeserialize as VersionedTransaction\nSign with wallet private key\nRe-serialize the signed transaction into bytes\nEncode as base64\nSubmit via submitTx(signedBase64) — this calls /protect first (30s timeout), auto-falls back to RPC on 503\nOn success: signature = tx hash, use sol_balance_post and token_balances to update state\nOn timeout: submitTx checks if tx landed via RPC before falling back — no manual handling needed"
      },
      {
        "title": "WebSocket — Limit and trailing orders",
        "body": "URL: wss://api.traderouter.ai/ws\n\nYou 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\nReference 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."
      },
      {
        "title": "Connection sequence (MUST follow this exact order)",
        "body": "The server sends a challenge on connect (not subscribed). Registration is challenge–response only; there is no unauthenticated path for placing orders.\n\nConnect to wss://api.traderouter.ai/ws\nServer sends: {\"type\": \"challenge\", \"nonce\": \"<nonce>\", \"message\": \"...\"}. The current protocol always sends challenge as the first message.\nClient: 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.\nClient 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.\nServer sends: {\"type\": \"registered\", \"wallet_address\": \"<pubkey>\", \"authenticated\": true}.\nOnly 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\nDo 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."
      },
      {
        "title": "Reconnection",
        "body": "On WebSocket disconnect:\n\nReconnect to wss://api.traderouter.ai/ws\nServer sends a new challenge (new nonce). Send {\"action\": \"register\", \"wallet_address\": \"...\", \"signature\": \"<base58 of nonce signed with wallet>\"}.\nWait for {\"type\": \"registered\", \"authenticated\": true}\nCheck for any pending order_filled messages\nUse 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."
      },
      {
        "title": "Limit sell (take-profit or stop-loss)",
        "body": "{\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\ntarget (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)."
      },
      {
        "title": "Limit buy (dip buy or breakout entry)",
        "body": "{\n  \"action\": \"buy\",\n  \"token_address\": \"SPL_TOKEN_MINT\",\n  \"amount\": 100000000,\n  \"target\": 5000,\n  \"slippage\": 1500,\n  \"expiry_hours\": 144\n}\n\ntarget (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)."
      },
      {
        "title": "Trailing sell / Trailing buy",
        "body": "{\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\ntrail is bps — percentage callback from peak before triggering.\n\nExample: 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."
      },
      {
        "title": "Limit + TWAP (limit_twap_sell / limit_twap_buy)",
        "body": "Wait 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{\"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}"
      },
      {
        "title": "Trailing + TWAP (trailing_twap_sell / trailing_twap_buy)",
        "body": "When 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{\"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}"
      },
      {
        "title": "Limit + Trailing (limit_trailing_sell / limit_trailing_buy)",
        "body": "Wait 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{\"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}"
      },
      {
        "title": "Limit + Trailing + TWAP (limit_trailing_twap_sell / limit_trailing_twap_buy)",
        "body": "Limit → 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{\"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}"
      },
      {
        "title": "Order management actions",
        "body": "{\"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}"
      },
      {
        "title": "TWAP (time-weighted average price)",
        "body": "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\ntwap_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{\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\ntwap_buy: Use amount (SOL lamports) as total to spend over the duration.\n\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\nServer 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."
      },
      {
        "title": "Order expiry",
        "body": "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."
      },
      {
        "title": "All WebSocket actions reference",
        "body": "ActionRequired fieldsOptionalregisterwallet_addresssignature (required when server sent challenge; base58 of nonce signed with wallet)selltoken_address, holdings_percentage (bps), target, slippageexpiry_hours (default 144), wallet_addressbuytoken_address, amount (lamports), target, slippageexpiry_hours, wallet_addresstrailing_selltoken_address, holdings_percentage, trail (bps), slippageexpiry_hourstrailing_buytoken_address, amount, trail (bps), slippageexpiry_hourstwap_selltoken_address, frequency, duration, amount or holdings_percentage (bps)slippage (default 500)twap_buytoken_address, frequency, duration, amount (SOL lamports)slippage (default 500)limit_twap_selltoken_address, target, frequency, duration, amount or holdings_percentageslippage, expiry_hourslimit_twap_buytoken_address, target, amount, frequency, durationslippage, expiry_hourstrailing_twap_selltoken_address, trail, frequency, duration, amount or holdings_percentageslippage, expiry_hourstrailing_twap_buytoken_address, trail, amount, frequency, durationslippage, expiry_hourslimit_trailing_selltoken_address, target, trail, amount or holdings_percentageslippage, expiry_hourslimit_trailing_buytoken_address, target, trail, amountslippage, expiry_hourslimit_trailing_twap_selltoken_address, target, trail, frequency, duration, amount or holdings_percentageslippage, expiry_hourslimit_trailing_twap_buytoken_address, target, trail, amount, frequency, durationslippage, expiry_hourscheck_orderorder_id—list_orders—wallet_addresscancel_orderorder_id—extend_orderorder_id, expiry_hours (max 336)—\n\nexpiry_hours: default 144, max 336."
      },
      {
        "title": "Server → client message types",
        "body": "typePayload fieldsDescriptionchallengenonce, messageSent on connect; client must sign nonce and send register with wallet_address + signatureregisteredwallet_address, authenticatedRegistration confirmed; only when authenticated true can client send order actionsorder_createdorder_id, order_type, token_address, entry_mcap, target_mcap, target_bps (limit), trail_bps (trailing), slippage, expiry_hours, amount, holdings_percentage, params_hash, server_signatureOrder 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 signaturesorder_filledorder_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)limit_trailing_activatedorder_id, order_type, token_address, limit_target_mcap, current_mcap, trailing_target_mcapLimit-trailing order: limit target crossed, trailing phase now activetrailing_twap_triggeredorder_id, twap_order_id, token_address, …Trailing+TWAP: trail triggered; then twap_order_created / twap_execution for the spawned TWAPlimit_twap_triggeredorder_id, twap_order_id, token_address, …Limit+TWAP: limit crossed; then twap_order_created / twap_execution for the spawned TWAPlimit_trailing_twap_triggeredorder_id, twap_order_id, token_address, …Limit+trailing+TWAP: trail triggered; then twap_order_created / twap_execution for the spawned TWAPtwap_order_createdorder_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)twap_executionorder_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 successtwap_order_completedorder_id, order_type, token_address, executions_completed, statusAll TWAP slices donetwap_order_cancelledorder_id, statusTWAP order cancelled (response to cancel_order)order_statusorder_id, statusResponse to check_orderorder_listorders[]Response to list_ordersorder_cancelledorder_idOrder cancelledorder_extendedorder_idTTL extendederrormessageError descriptionheartbeat—Keepalive, ignore"
      },
      {
        "title": "WebSocket authentication (required for orders)",
        "body": "The server sends a challenge with a nonce on connect. To place or manage orders you must:\n\nSign the nonce (as UTF-8 bytes) with the wallet's private key (Ed25519).\nSend 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.\nWait 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."
      },
      {
        "title": "Verifying server signatures (order_filled and order_created)",
        "body": "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\nKey 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\nRejection 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\norder_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\norder_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."
      },
      {
        "title": "Handling order_filled",
        "body": "When order_filled arrives:\n\nIdempotency: If already_dispatched is true, skip sign/submit; treat as idempotent ack (fill was already sent). Log and exit.\nVerify: 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.\nRead order_id from the message — use for logging and correlation throughout\nRead data.swap_tx — this is base58 unsigned (when already_dispatched is false; when true, data or data.swap_tx may be omitted)\nDecode from base58 into raw bytes\nDeserialize as VersionedTransaction\nSign with client wallet\nRe-serialize signed transaction into bytes\nEncode as base64\nSubmit via submitTx(signedBase64) — handles /protect + fallback internally\nLog order_id + signature together for audit trail\nUse response to update holdings\n\nIdempotency: 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\nLogging: 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\nStaleness 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."
      },
      {
        "title": "holdings_percentage resolves at execution time",
        "body": "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."
      },
      {
        "title": "DCA (Dollar-Cost Averaging)",
        "body": "DCA is implemented as repeated limit buy orders. It is not automatic chaining — each fill requires agent action:\n\nPlace a buy order via WebSocket with the desired amount and target\nWhen order_filled arrives, sign the swap_tx and submit via submitTx() (follow the base58→base64 steps above)\nAfter successful submission, place the next buy order\nRepeat 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."
      },
      {
        "title": "Troubleshooting",
        "body": "IssueFix/holdings times outSet HTTP timeout to at least 100 seconds./protect hangsSet a 30s timeout. On timeout, check tx status via RPC before retrying — tx may have landed./protect returns 503submitTx() auto-falls back to RPC. No manual action needed.422 from /swapInvalid 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 /swapRoute is unsellable now (dead/rugged pool, zero effective balance, or route failure). Put token on cooldown; avoid tight retry loops.Swap fails on-chainIncrease slippage (1500-2500 bps for memecoins), check SOL balance for fees, verify token/pool exists.No order_filled receivedVerify 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 disconnectsReconnect, re-register with signature, check for pending fills. Active orders persist server-side.Sell fails on token from /holdingsKeep defensive filter valueNative > MIN_VALUE_NATIVE (> 0 by default) and verify balance/pool just before sell.filled_mcap is 0 or nullFill is still valid. Execute normally — mcap data is unreliable but tx works.Order seems to have disappearedOrders silently expire at expiry_hours. Use list_orders to check."
      },
      {
        "title": "Request pacing / rate limits",
        "body": "No hard limits are documented in this skill. Use conservative client pacing defaults unless the API owner gives stricter numbers:\n\nREST (/swap, /protect, /holdings): target <= 2 requests/sec sustained per wallet (short bursts <= 5).\nWebSocket mutating actions (buy, sell, trailing_*, cancel_order, extend_order): target <= 5 messages/sec per wallet.\nOn 429 or repeated 5xx: exponential backoff with jitter (1s, 2s, 4s, cap 30s).\nNever tight-loop retries on the same token after \"Error running simulation\"; honor cooldown first."
      },
      {
        "title": "Important rules",
        "body": "No API key needed. Wallet address is the only identity.\nNever expose private keys. Sign only in a secure client environment.\nKeep 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.\nRegister with signature on WebSocket. Server sends challenge; sign nonce and send register with wallet_address + signature. No orders before {\"type\":\"registered\",\"authenticated\":true}.\nSell = holdings_percentage. Buy = amount. Do not mix these parameters.\nTarget basis: WS target is relative to current mcap at order placement, not to your wallet entry price.\nEncoding: /swap returns base58, /protect expects base64. Decode → deserialize → sign → serialize → encode base64.\nSlippage: Default 500 (5%). Use 1500-2500 bps for low-liquidity or newly launched tokens. 500 bps will fail on most memecoins.\nSet timeouts: 30s on /protect, 100s on /holdings.\nAll transactions from the API are unsigned. Client always signs.\nAlways 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.\nUnsellable routes: \"Error running simulation\" should trigger cooldown, not spam retries.\nHoldings filtering: Keep valueNative > MIN_VALUE_NATIVE (> 0 by default) as a defensive guard before sells. The reference client's getHoldings() does this automatically.\nOrder expiry is silent. Server does not notify. Poll list_orders to detect."
      },
      {
        "title": "Definition of Done",
        "body": "An agent is production-ready only when it can execute all of the following with zero manual steps:\n\nInstant 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"
      },
      {
        "title": "Canonical Stack",
        "body": "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\nNode.js (skill examples): Pin these versions unless explicitly tested.\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\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\nThe type=module line is required. All code below uses ESM imports and top-level await, which fail under CommonJS.\n\nPython: solders, websockets, httpx, cryptography, base58. Encoding and verification logic are identical across runtimes; only library names differ."
      },
      {
        "title": "Reference Client",
        "body": "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.\n\nOne 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\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);"
      },
      {
        "title": "Signing / Encoding Test Vectors",
        "body": "Use these to verify your signing pipeline produces valid output."
      },
      {
        "title": "Test: base58 decode → sign → base64 encode",
        "body": "Input 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)"
      },
      {
        "title": "Quick validation function",
        "body": "function 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}"
      },
      {
        "title": "Request / Response Schemas",
        "body": "Machine-readable schemas for pre-validation. The reference client compiles and enforces these required fields with ajv before network calls."
      },
      {
        "title": "POST /swap — request",
        "body": "{\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}"
      },
      {
        "title": "POST /swap — success response",
        "body": "{\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}"
      },
      {
        "title": "POST /protect — request",
        "body": "{\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}"
      },
      {
        "title": "POST /holdings — response (non-empty)",
        "body": "{\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}"
      },
      {
        "title": "WebSocket order_filled",
        "body": "When already_dispatched is true, the server may omit data or data.swap_tx (idempotent ack). Schema must not require them.\n\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}"
      },
      {
        "title": "Retry / Idempotency Policy",
        "body": "Error classRetry?Max attemptsBackoffNotes400 from /swapNo0—Bad request. Fix payload.422 from /swapNo0—Missing fields. Fix payload.\"Error running simulation\"Cooldown0 immediate15mMark token unswappable for the session and retry later only if needed.503 from /protectAuto-fallback1—submitTx() falls back to RPC internally. No manual action./protect timeout (30s)Check first1—Check tx status via connection.getSignatureStatuses([sig]). Only retry if value[0] is null (not found)./holdings timeoutYes25sEndpoint is slow, not broken./swap 5xxYes23sServer issue, may resolve.WS disconnectReconnect∞3s fixedRe-register. Orders persist.WS error messageNo0—Log and surface to user.On-chain swap failureMaybe1—Increase slippage to 2500, retry once. If fails again, skip.\n\nIdempotency: 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."
      },
      {
        "title": "Timeout and Confirmation Contracts",
        "body": "EndpointTimeoutOn timeoutPOST /swap15sRetry once with 3s backoffPOST /protect30sCall 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.POST /holdings100sRetry once with 5s backoffWebSocket connect10sRetry with 3s backoff"
      },
      {
        "title": "Post-timeout /protect check",
        "body": "checkTxLanded() is defined in the Reference Client above. Returns { landed, sig, status }:\n\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\nsig is the real base58 transaction signature for reconciliation. submitTx() handles all of this internally."
      },
      {
        "title": "WebSocket Lifecycle State Machine",
        "body": "┌──────────────┐\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\nRules:\n\nDISCONNECTED: no sends allowed. Reconnect immediately.\nCHALLENGE: sign nonce and send register with wallet_address + signature only. All other sends rejected until REGISTERED.\nREGISTERED / ACTIVE: all actions allowed. Only enter REGISTERED state when authenticated: true is confirmed.\nOn any disconnect, state resets to DISCONNECTED. Orders persist server-side.\nQueue any order sends that arrive during DISCONNECTED/CHALLENGE and flush after REGISTERED."
      },
      {
        "title": "Execution Safety Guards",
        "body": "All 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\nDefaults (adjust per deployment):\n\nMAX_BUY_LAMPORTS = 0.5 SOL is intentionally conservative for first deploys. Increase only after risk limits, kill switch, and monitoring are verified.\n\nGuardDefaultEnforced inMAX_BUY_LAMPORTS500,000,000 (0.5 SOL, conservative starter value)buildSwap() via enforceSafety(), ws.send() for buy/trailing_buyMAX_SLIPPAGE_BPS2500 (25%)buildSwap() via enforceSafety()MIN_SLIPPAGE_BPS100 (1%)buildSwap() via enforceSafety()MIN_VALUE_NATIVE0 (valueNative > 0, defensive)getHoldings() filterUNSWAPPABLE_COOLDOWN_MS900,000 (15 minutes)markUnswappable()MAX_DAILY_LOSS_LAMPORTS2,000,000,000 (2 SOL)Updated on every spend path: /protect success, timeout_recovery, _rpcFallback(); enforceSafety() → activates KILL_SWITCHDENYLISTempty Map (session-scoped cooldown)buildSwap(), ws.send(), auto-populated by markUnswappable()KILL_SWITCHfalsesubmitTx(), ws.send(), auto-activated on daily loss limit\n\nUnswappable 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\nEmergency kill: Set KILL_SWITCH = true to halt all execution. Also auto-activates when dailyLoss > MAX_DAILY_LOSS_LAMPORTS."
      },
      {
        "title": "Logging Format",
        "body": "All logging is through the log() function in the reference client, which outputs JSON lines with automatic timestamp and wallet fields.\n\nEvery step emitted by the reference client:\n\nStepEmitted byRequired fieldsswap_requestbuildSwap()token, action, amount/holdings_percentage, slippageswap_responsebuildSwap()token, pool_type, amount_inswap_errorbuildSwap()token, errorsafety_blockedenforceSafety(), markUnswappable(), ws.send()token, reasondry_run_skipsubmitTx(), ws.send()token, action, messageprotect_successsubmitTx()token, signatureprotect_503submitTx()messageprotect_timeoutsubmitTx()token, messageprotect_timeout_landedsubmitTx()signature, statusprotect_timeout_processedsubmitTx()signature, messageprotect_timeout_failedsubmitTx()signature, messageprotect_timeout_not_landedsubmitTx()messagerpc_fallback_success_rpcFallback()signaturesell_skippedmain()messagews_connectedWS open handler—ws_registeredWS registered handler—ws_disconnectedWS close handlermessagews_errorWS error handlererrorws_queuedws.send()action, messageorder_placedWS order_created handlerorder_id, token, action, target_mcaporder_filledhandleOrderFilled()order_id, order_type, token, triggered_mcap, filled_mcaporder_fill_errorhandleOrderFilled()order_id, errororder_fill_skippedhandleOrderFilled()order_id, reason, triggered_mcap, filled_mcaporder_fill_submittedhandleOrderFilled()order_id, signatureorder_fill_verify_failedverifyOrderFilledSignature()order_id, reason"
      },
      {
        "title": "Dry-Run / Paper Mode",
        "body": "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\nsubmitTx() short-circuits before submission and returns { signature: null, dry_run: true }\nws.send() blocks all mutating actions (sell, buy, trailing_sell, trailing_buy, cancel_order, extend_order) and logs intent\nOnly 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# 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"
      },
      {
        "title": "Preflight Startup Checklist",
        "body": "The 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\nChecks performed:\n\nCheckFails ifPRIVATE_KEY loadedenv var missingRPC_URL configuredalways passes (warns if using default public RPC)Wallet loadskey is invalid/undecodableRPC reachablegetSlot() failsSOL balance > 0.01balance < 10,000,000 lamports/holdings respondsendpoint unreachable or times outWS registerWebSocket connect, challenge–response, or authenticated: true confirmation fails within 10s\n\nAfter all checks, preflight reports mode (DRY RUN or LIVE TRADING)."
      }
    ],
    "body": "TradeRouter\n\nSolana swap builder and limit-order engine.\n\nBase 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.\n\nBefore you use this skill\n\nMaintaining 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\nAuthentication 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\nService origin: This skill documents the API only. The service website is https://traderouter.ai (API at api.traderouter.ai).\n\nMEV protection: The POST /protect endpoint accepts signed transactions and uses Jito and a staked connection lane to process your transaction.\n\nRisk: No API key is requested; identity is the wallet address (and for WebSocket orders, proof via the signed challenge).\n\nWhen to use which endpoint\nUser intent\tEndpoint\tMethod\nInstant buy or sell of a token\tPOST /swap → sign → POST /protect\tREST\nCheck wallet token balances\tPOST /holdings\tREST\nSubmit an already-signed transaction with MEV protection\tPOST /protect\tREST\nMarket cap / price for token(s)\tGET /mcap?tokens=MINT1,MINT2\tREST\nFlex trade card image for wallet + token\tGET /flex?wallet_address=…&token_address=…\tREST\nLimit order (take-profit, stop-loss, dip buy, breakout)\tWebSocket sell or buy action\tWS\nTrailing stop (auto-adjust with market)\tWebSocket trailing_sell or trailing_buy\tWS\nTWAP (time-weighted buy/sell over duration)\tWebSocket twap_buy or twap_sell\tWS\nLimit then TWAP\tWebSocket limit_twap_sell or limit_twap_buy\tWS\nTrailing then TWAP\tWebSocket trailing_twap_sell or trailing_twap_buy\tWS\nLimit then trailing (single swap on trail trigger)\tWebSocket limit_trailing_sell or limit_trailing_buy\tWS\nLimit then trailing then TWAP\tWebSocket limit_trailing_twap_sell or limit_trailing_twap_buy\tWS\nManage orders (check, list, cancel, extend)\tWebSocket actions\tWS\nDCA (recurring small buys)\tWebSocket buy orders — see DCA section below\tWS\nPOST /swap — Build unsigned swap transaction\n\nReturns an unsigned transaction (base58). Client must sign it, then submit via POST /protect.\n\nSell uses holdings_percentage (bps). Buy uses amount (lamports). Never mix them.\n\nRequest\n{\n  \"wallet_address\": \"SOLANA_PUBKEY\",\n  \"token_address\": \"SPL_TOKEN_MINT\",\n  \"action\": \"buy\",\n  \"amount\": 100000000,\n  \"slippage\": 1500\n}\n\nField\tType\tRequired\tNotes\nwallet_address\tstring\tyes\tSolana pubkey\ntoken_address\tstring\tyes\tSPL token mint address\naction\tstring\tyes\t\"buy\" or \"sell\"\namount\tinteger\tbuy only\tLamports. Only for buy.\nholdings_percentage\tinteger\tsell only\tBps (10000 = 100%). Only for sell.\nslippage\tinteger\tno\tBps, 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\nSuccess response\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\npool_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\nError response\n{\n  \"status\": \"error\",\n  \"error\": \"Insufficient balance\",\n  \"code\": 400\n}\n\n\ncode 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\nPOST /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\nRequest\n{\n  \"signed_tx_base64\": \"<base64_signed_transaction>\"\n}\n\nSuccess response\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\nError handling\n{\"status\":\"error\",\"error\":\"message\"} — general error.\n503 — 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.\nTimeout — the tx may have landed on-chain. Check tx status via RPC before retrying.\nPOST /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\nRequest\n{\n  \"wallet_address\": \"SOLANA_PUBKEY\"\n}\n\nResponse\n\nEmpty wallet: {}\n\nWallet with holdings:\n\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\nGET /mcap — Market cap data\n\nReturn market cap (and optional price/pool) for given token addresses.\n\nRequest: GET https://api.traderouter.ai/mcap?tokens=MINT1,MINT2 (comma-delimited Solana mint addresses).\n\nResponse: 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\nGET /flex — Flex trade card PNG\n\nGenerate a flex trade card image for a wallet and token mint.\n\nRequest: GET https://api.traderouter.ai/flex?wallet_address=WALLET&token_address=MINT.\n\nResponse: image/png. 400 on invalid params; 501 if flex_card_image deps not available; 500 on server error.\n\nInstant swap workflow (step by step)\n\nThe encoding changes: /swap returns base58, /protect expects base64. Do not send base58 to /protect.\n\nPOST /swap with wallet_address, token_address, action, amount or holdings_percentage, slippage\nRead data.swap_tx from response — this is base58 encoded\nDecode from base58 into raw bytes\nDeserialize as VersionedTransaction\nSign with wallet private key\nRe-serialize the signed transaction into bytes\nEncode as base64\nSubmit via submitTx(signedBase64) — this calls /protect first (30s timeout), auto-falls back to RPC on 503\nOn success: signature = tx hash, use sol_balance_post and token_balances to update state\nOn timeout: submitTx checks if tx landed via RPC before falling back — no manual handling needed\nWebSocket — Limit and trailing orders\n\nURL: wss://api.traderouter.ai/ws\n\nYou 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\nReference 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\nConnection 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\nConnect to wss://api.traderouter.ai/ws\nServer sends: {\"type\": \"challenge\", \"nonce\": \"<nonce>\", \"message\": \"...\"}. The current protocol always sends challenge as the first message.\nClient: 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.\nClient 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.\nServer sends: {\"type\": \"registered\", \"wallet_address\": \"<pubkey>\", \"authenticated\": true}.\nOnly 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\nDo 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\nReconnection\n\nOn WebSocket disconnect:\n\nReconnect to wss://api.traderouter.ai/ws\nServer sends a new challenge (new nonce). Send {\"action\": \"register\", \"wallet_address\": \"...\", \"signature\": \"<base58 of nonce signed with wallet>\"}.\nWait for {\"type\": \"registered\", \"authenticated\": true}\nCheck for any pending order_filled messages\nUse 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\nLimit sell (take-profit or stop-loss)\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\ntarget (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\nLimit buy (dip buy or breakout entry)\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\ntarget (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\nTrailing sell / Trailing buy\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\ntrail is bps — percentage callback from peak before triggering.\n\nExample: 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\nLimit + 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{\"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\nTrailing + 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{\"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\nLimit + 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{\"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\nLimit + 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{\"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\nOrder management actions\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\nTWAP (time-weighted average price)\n\ntwap_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\ntwap_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{\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\ntwap_buy: Use amount (SOL lamports) as total to spend over the duration.\n\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\nServer 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\nOrder 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\nAll WebSocket actions reference\nAction\tRequired fields\tOptional\nregister\twallet_address\tsignature (required when server sent challenge; base58 of nonce signed with wallet)\nsell\ttoken_address, holdings_percentage (bps), target, slippage\texpiry_hours (default 144), wallet_address\nbuy\ttoken_address, amount (lamports), target, slippage\texpiry_hours, wallet_address\ntrailing_sell\ttoken_address, holdings_percentage, trail (bps), slippage\texpiry_hours\ntrailing_buy\ttoken_address, amount, trail (bps), slippage\texpiry_hours\ntwap_sell\ttoken_address, frequency, duration, amount or holdings_percentage (bps)\tslippage (default 500)\ntwap_buy\ttoken_address, frequency, duration, amount (SOL lamports)\tslippage (default 500)\nlimit_twap_sell\ttoken_address, target, frequency, duration, amount or holdings_percentage\tslippage, expiry_hours\nlimit_twap_buy\ttoken_address, target, amount, frequency, duration\tslippage, expiry_hours\ntrailing_twap_sell\ttoken_address, trail, frequency, duration, amount or holdings_percentage\tslippage, expiry_hours\ntrailing_twap_buy\ttoken_address, trail, amount, frequency, duration\tslippage, expiry_hours\nlimit_trailing_sell\ttoken_address, target, trail, amount or holdings_percentage\tslippage, expiry_hours\nlimit_trailing_buy\ttoken_address, target, trail, amount\tslippage, expiry_hours\nlimit_trailing_twap_sell\ttoken_address, target, trail, frequency, duration, amount or holdings_percentage\tslippage, expiry_hours\nlimit_trailing_twap_buy\ttoken_address, target, trail, amount, frequency, duration\tslippage, expiry_hours\ncheck_order\torder_id\t—\nlist_orders\t—\twallet_address\ncancel_order\torder_id\t—\nextend_order\torder_id, expiry_hours (max 336)\t—\n\nexpiry_hours: default 144, max 336.\n\nServer → client message types\ntype\tPayload fields\tDescription\nchallenge\tnonce, message\tSent on connect; client must sign nonce and send register with wallet_address + signature\nregistered\twallet_address, authenticated\tRegistration confirmed; only when authenticated true can client send order actions\norder_created\torder_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\tOrder 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\norder_filled\torder_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)\tTarget hit — verify server_signature, then sign data.swap_tx and submit; when already_dispatched true, data/swap_tx may be omitted (idempotent ack)\nlimit_trailing_activated\torder_id, order_type, token_address, limit_target_mcap, current_mcap, trailing_target_mcap\tLimit-trailing order: limit target crossed, trailing phase now active\ntrailing_twap_triggered\torder_id, twap_order_id, token_address, …\tTrailing+TWAP: trail triggered; then twap_order_created / twap_execution for the spawned TWAP\nlimit_twap_triggered\torder_id, twap_order_id, token_address, …\tLimit+TWAP: limit crossed; then twap_order_created / twap_execution for the spawned TWAP\nlimit_trailing_twap_triggered\torder_id, twap_order_id, token_address, …\tLimit+trailing+TWAP: trail triggered; then twap_order_created / twap_execution for the spawned TWAP\ntwap_order_created\torder_id, order_type, token_address, frequency, duration, interval_seconds, amount_per_execution, original_amount, expires_at, slippage, holdings_percentage (optional)\tTWAP order accepted (standalone or spawned from combo)\ntwap_execution\torder_id, order_type, status, token_address, execution_num, executions_total, executions_remaining, next_execution_at, server_signature, data (optional), error (optional)\tOne TWAP slice — verify server_signature, then sign data.swap_tx and submit when status success\ntwap_order_completed\torder_id, order_type, token_address, executions_completed, status\tAll TWAP slices done\ntwap_order_cancelled\torder_id, status\tTWAP order cancelled (response to cancel_order)\norder_status\torder_id, status\tResponse to check_order\norder_list\torders[]\tResponse to list_orders\norder_cancelled\torder_id\tOrder cancelled\norder_extended\torder_id\tTTL extended\nerror\tmessage\tError description\nheartbeat\t—\tKeepalive, ignore\nWebSocket authentication (required for orders)\n\nThe server sends a challenge with a nonce on connect. To place or manage orders you must:\n\nSign the nonce (as UTF-8 bytes) with the wallet's private key (Ed25519).\nSend 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.\nWait 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\nVerifying server signatures (order_filled and order_created)\n\nTrust 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\nKey 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\nRejection 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\norder_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\norder_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\nHandling order_filled\n\nWhen order_filled arrives:\n\nIdempotency: If already_dispatched is true, skip sign/submit; treat as idempotent ack (fill was already sent). Log and exit.\nVerify: 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.\nRead order_id from the message — use for logging and correlation throughout\nRead data.swap_tx — this is base58 unsigned (when already_dispatched is false; when true, data or data.swap_tx may be omitted)\nDecode from base58 into raw bytes\nDeserialize as VersionedTransaction\nSign with client wallet\nRe-serialize signed transaction into bytes\nEncode as base64\nSubmit via submitTx(signedBase64) — handles /protect + fallback internally\nLog order_id + signature together for audit trail\nUse response to update holdings\n\nIdempotency: 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\nLogging: 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\nStaleness 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\nholdings_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\nDCA (Dollar-Cost Averaging)\n\nDCA is implemented as repeated limit buy orders. It is not automatic chaining — each fill requires agent action:\n\nPlace a buy order via WebSocket with the desired amount and target\nWhen order_filled arrives, sign the swap_tx and submit via submitTx() (follow the base58→base64 steps above)\nAfter successful submission, place the next buy order\nRepeat 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\nTroubleshooting\nIssue\tFix\n/holdings times out\tSet HTTP timeout to at least 100 seconds.\n/protect hangs\tSet a 30s timeout. On timeout, check tx status via RPC before retrying — tx may have landed.\n/protect returns 503\tsubmitTx() auto-falls back to RPC. No manual action needed.\n422 from /swap\tInvalid 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\tRoute is unsellable now (dead/rugged pool, zero effective balance, or route failure). Put token on cooldown; avoid tight retry loops.\nSwap fails on-chain\tIncrease slippage (1500-2500 bps for memecoins), check SOL balance for fees, verify token/pool exists.\nNo order_filled received\tVerify 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.\nWebSocket disconnects\tReconnect, re-register with signature, check for pending fills. Active orders persist server-side.\nSell fails on token from /holdings\tKeep defensive filter valueNative > MIN_VALUE_NATIVE (> 0 by default) and verify balance/pool just before sell.\nfilled_mcap is 0 or null\tFill is still valid. Execute normally — mcap data is unreliable but tx works.\nOrder seems to have disappeared\tOrders silently expire at expiry_hours. Use list_orders to check.\nRequest 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\nREST (/swap, /protect, /holdings): target <= 2 requests/sec sustained per wallet (short bursts <= 5).\nWebSocket mutating actions (buy, sell, trailing_*, cancel_order, extend_order): target <= 5 messages/sec per wallet.\nOn 429 or repeated 5xx: exponential backoff with jitter (1s, 2s, 4s, cap 30s).\nNever tight-loop retries on the same token after \"Error running simulation\"; honor cooldown first.\nImportant rules\nNo API key needed. Wallet address is the only identity.\nNever expose private keys. Sign only in a secure client environment.\nKeep 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.\nRegister with signature on WebSocket. Server sends challenge; sign nonce and send register with wallet_address + signature. No orders before {\"type\":\"registered\",\"authenticated\":true}.\nSell = holdings_percentage. Buy = amount. Do not mix these parameters.\nTarget basis: WS target is relative to current mcap at order placement, not to your wallet entry price.\nEncoding: /swap returns base58, /protect expects base64. Decode → deserialize → sign → serialize → encode base64.\nSlippage: Default 500 (5%). Use 1500-2500 bps for low-liquidity or newly launched tokens. 500 bps will fail on most memecoins.\nSet timeouts: 30s on /protect, 100s on /holdings.\nAll transactions from the API are unsigned. Client always signs.\nAlways 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.\nUnsellable routes: \"Error running simulation\" should trigger cooldown, not spam retries.\nHoldings filtering: Keep valueNative > MIN_VALUE_NATIVE (> 0 by default) as a defensive guard before sells. The reference client's getHoldings() does this automatically.\nOrder expiry is silent. Server does not notify. Poll list_orders to detect.\nDefinition 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\nCanonical Stack\n\nReference 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\nNode.js (skill examples): Pin these versions unless explicitly tested.\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\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\nThe type=module line is required. All code below uses ESM imports and top-level await, which fail under CommonJS.\n\nPython: solders, websockets, httpx, cryptography, base58. Encoding and verification logic are identical across runtimes; only library names differ.\n\nReference 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\nOne 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\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\nSigning / Encoding Test Vectors\n\nUse these to verify your signing pipeline produces valid output.\n\nTest: base58 decode → sign → base64 encode\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\nQuick validation function\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\nRequest / Response Schemas\n\nMachine-readable schemas for pre-validation. The reference client compiles and enforces these required fields with ajv before network calls.\n\nPOST /swap — request\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\nPOST /swap — success response\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\nPOST /protect — request\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\nPOST /holdings — response (non-empty)\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\nWebSocket 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{\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\nRetry / Idempotency Policy\nError class\tRetry?\tMax attempts\tBackoff\tNotes\n400 from /swap\tNo\t0\t—\tBad request. Fix payload.\n422 from /swap\tNo\t0\t—\tMissing fields. Fix payload.\n\"Error running simulation\"\tCooldown\t0 immediate\t15m\tMark token unswappable for the session and retry later only if needed.\n503 from /protect\tAuto-fallback\t1\t—\tsubmitTx() falls back to RPC internally. No manual action.\n/protect timeout (30s)\tCheck first\t1\t—\tCheck tx status via connection.getSignatureStatuses([sig]). Only retry if value[0] is null (not found).\n/holdings timeout\tYes\t2\t5s\tEndpoint is slow, not broken.\n/swap 5xx\tYes\t2\t3s\tServer issue, may resolve.\nWS disconnect\tReconnect\t∞\t3s fixed\tRe-register. Orders persist.\nWS error message\tNo\t0\t—\tLog and surface to user.\nOn-chain swap failure\tMaybe\t1\t—\tIncrease slippage to 2500, retry once. If fails again, skip.\n\nIdempotency: 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\nTimeout and Confirmation Contracts\nEndpoint\tTimeout\tOn timeout\nPOST /swap\t15s\tRetry once with 3s backoff\nPOST /protect\t30s\tCall 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.\nPOST /holdings\t100s\tRetry once with 5s backoff\nWebSocket connect\t10s\tRetry with 3s backoff\nPost-timeout /protect check\n\ncheckTxLanded() is defined in the Reference Client above. Returns { landed, sig, status }:\n\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\nsig is the real base58 transaction signature for reconciliation. submitTx() handles all of this internally.\n\nWebSocket Lifecycle State Machine\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\nRules:\n\nDISCONNECTED: no sends allowed. Reconnect immediately.\nCHALLENGE: sign nonce and send register with wallet_address + signature only. All other sends rejected until REGISTERED.\nREGISTERED / ACTIVE: all actions allowed. Only enter REGISTERED state when authenticated: true is confirmed.\nOn any disconnect, state resets to DISCONNECTED. Orders persist server-side.\nQueue any order sends that arrive during DISCONNECTED/CHALLENGE and flush after REGISTERED.\nExecution 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\nDefaults (adjust per deployment):\n\nMAX_BUY_LAMPORTS = 0.5 SOL is intentionally conservative for first deploys. Increase only after risk limits, kill switch, and monitoring are verified.\n\nGuard\tDefault\tEnforced in\nMAX_BUY_LAMPORTS\t500,000,000 (0.5 SOL, conservative starter value)\tbuildSwap() via enforceSafety(), ws.send() for buy/trailing_buy\nMAX_SLIPPAGE_BPS\t2500 (25%)\tbuildSwap() via enforceSafety()\nMIN_SLIPPAGE_BPS\t100 (1%)\tbuildSwap() via enforceSafety()\nMIN_VALUE_NATIVE\t0 (valueNative > 0, defensive)\tgetHoldings() filter\nUNSWAPPABLE_COOLDOWN_MS\t900,000 (15 minutes)\tmarkUnswappable()\nMAX_DAILY_LOSS_LAMPORTS\t2,000,000,000 (2 SOL)\tUpdated on every spend path: /protect success, timeout_recovery, _rpcFallback(); enforceSafety() → activates KILL_SWITCH\nDENYLIST\tempty Map (session-scoped cooldown)\tbuildSwap(), ws.send(), auto-populated by markUnswappable()\nKILL_SWITCH\tfalse\tsubmitTx(), ws.send(), auto-activated on daily loss limit\n\nUnswappable 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\nEmergency kill: Set KILL_SWITCH = true to halt all execution. Also auto-activates when dailyLoss > MAX_DAILY_LOSS_LAMPORTS.\n\nLogging Format\n\nAll logging is through the log() function in the reference client, which outputs JSON lines with automatic timestamp and wallet fields.\n\nEvery step emitted by the reference client:\n\nStep\tEmitted by\tRequired fields\nswap_request\tbuildSwap()\ttoken, action, amount/holdings_percentage, slippage\nswap_response\tbuildSwap()\ttoken, pool_type, amount_in\nswap_error\tbuildSwap()\ttoken, error\nsafety_blocked\tenforceSafety(), markUnswappable(), ws.send()\ttoken, reason\ndry_run_skip\tsubmitTx(), ws.send()\ttoken, action, message\nprotect_success\tsubmitTx()\ttoken, signature\nprotect_503\tsubmitTx()\tmessage\nprotect_timeout\tsubmitTx()\ttoken, message\nprotect_timeout_landed\tsubmitTx()\tsignature, status\nprotect_timeout_processed\tsubmitTx()\tsignature, message\nprotect_timeout_failed\tsubmitTx()\tsignature, message\nprotect_timeout_not_landed\tsubmitTx()\tmessage\nrpc_fallback_success\t_rpcFallback()\tsignature\nsell_skipped\tmain()\tmessage\nws_connected\tWS open handler\t—\nws_registered\tWS registered handler\t—\nws_disconnected\tWS close handler\tmessage\nws_error\tWS error handler\terror\nws_queued\tws.send()\taction, message\norder_placed\tWS order_created handler\torder_id, token, action, target_mcap\norder_filled\thandleOrderFilled()\torder_id, order_type, token, triggered_mcap, filled_mcap\norder_fill_error\thandleOrderFilled()\torder_id, error\norder_fill_skipped\thandleOrderFilled()\torder_id, reason, triggered_mcap, filled_mcap\norder_fill_submitted\thandleOrderFilled()\torder_id, signature\norder_fill_verify_failed\tverifyOrderFilledSignature()\torder_id, reason\nDry-Run / Paper Mode\n\nSafe-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\nsubmitTx() short-circuits before submission and returns { signature: null, dry_run: true }\nws.send() blocks all mutating actions (sell, buy, trailing_sell, trailing_buy, cancel_order, extend_order) and logs intent\nOnly 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# 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\nPreflight 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\nChecks performed:\n\nCheck\tFails if\nPRIVATE_KEY loaded\tenv var missing\nRPC_URL configured\talways passes (warns if using default public RPC)\nWallet loads\tkey is invalid/undecodable\nRPC reachable\tgetSlot() fails\nSOL balance > 0.01\tbalance < 10,000,000 lamports\n/holdings responds\tendpoint unreachable or times out\nWS register\tWebSocket connect, challenge–response, or authenticated: true confirmation fails within 10s\n\nAfter all checks, preflight reports mode (DRY RUN or LIVE TRADING)."
  },
  "trust": {
    "sourceLabel": "tencent",
    "provenanceUrl": "https://clawhub.ai/re-bruce-wayne/trade-router",
    "publisherUrl": "https://clawhub.ai/re-bruce-wayne/trade-router",
    "owner": "re-bruce-wayne",
    "version": "1.3.0",
    "license": null,
    "verificationStatus": "Indexed source record"
  },
  "links": {
    "detailUrl": "https://openagent3.xyz/skills/trade-router",
    "downloadUrl": "https://openagent3.xyz/downloads/trade-router",
    "agentUrl": "https://openagent3.xyz/skills/trade-router/agent",
    "manifestUrl": "https://openagent3.xyz/skills/trade-router/agent.json",
    "briefUrl": "https://openagent3.xyz/skills/trade-router/agent.md"
  }
}