solana-traderclaw 1.0.36 → 1.0.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -619,7 +619,7 @@ function traderCronPrescriptiveJobs(agentId) {
619
619
  schedule: "0 * * * *",
620
620
  agentId,
621
621
  message:
622
- "CRON_JOB: alpha_scan — Call solana_scan_launches to find new token launches from the last hour. For each candidate, call solana_token_snapshot to check price, volume, and age. Filter for: 24h volume above 50000 USD, market cap above 10000 USD. For tokens that pass, call solana_token_holders to check holder distribution (skip if top holder owns more than 30 percent), then solana_token_risk to check for mint/freeze authority (hard skip if present). If a token passes all checks, call solana_alpha_submit to add it to the alpha buffer for the CTO's next heartbeat evaluation. Log all scanned tokens and results using solana_memory_write. Do not execute trades directly — only submit to alpha buffer. Do not ask questions.",
622
+ "CRON_JOB: alpha_scan — Call solana_scan_launches to find new token launches from the last hour. For each candidate, call solana_token_snapshot to check price, volume, and age. Filter for: 24h volume above 50000 USD, market cap above 10000 USD. For tokens that pass, call solana_token_holders to check holder distribution (skip if top holder owns more than 30 percent), then solana_token_risk to check for mint/freeze authority (hard skip if present). If a token passes all checks, use solana_trade_precheck and consider trade execution using solana_trade_execute. Log all scanned tokens and results using solana_memory_write. Do not ask questions.",
623
623
  enabled: true,
624
624
  },
625
625
  {
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { createInterface } from "readline";
4
- import { readFileSync, writeFileSync, mkdirSync, appendFileSync } from "fs";
4
+ import { readFileSync, writeFileSync, mkdirSync, appendFileSync, existsSync } from "fs";
5
5
  import { join } from "path";
6
6
  import { homedir } from "os";
7
7
  import { randomUUID, createPrivateKey, sign as cryptoSign } from "crypto";
@@ -2596,6 +2596,242 @@ async function cmdInstall(args) {
2596
2596
  printInfo("Press Ctrl+C to stop the wizard server.");
2597
2597
  }
2598
2598
 
2599
+ async function cmdTestSession(args) {
2600
+ const config = readConfig();
2601
+ const pluginConfig = getPluginConfig(config);
2602
+
2603
+ if (!pluginConfig) {
2604
+ printError("No plugin configuration found. Run 'traderclaw setup' first.");
2605
+ process.exit(1);
2606
+ }
2607
+
2608
+ const orchestratorUrl = pluginConfig.orchestratorUrl;
2609
+ if (!orchestratorUrl) {
2610
+ printError("orchestratorUrl not set in config. Run 'traderclaw setup' to fix.");
2611
+ process.exit(1);
2612
+ }
2613
+
2614
+ const dataDir = pluginConfig.dataDir || join(process.cwd(), ".traderclaw-v1-data");
2615
+ const sessionTokensPath = join(dataDir, "session-tokens.json");
2616
+
2617
+ let sidecar = null;
2618
+ try {
2619
+ if (existsSync(sessionTokensPath)) {
2620
+ sidecar = JSON.parse(readFileSync(sessionTokensPath, "utf-8"));
2621
+ }
2622
+ } catch { /* ignore */ }
2623
+
2624
+ const effectiveRefreshToken =
2625
+ (sidecar?.refreshToken && sidecar.refreshToken.length > 0)
2626
+ ? sidecar.refreshToken
2627
+ : pluginConfig.refreshToken;
2628
+
2629
+ const walletPrivateKeyInput = args.includes("--wallet-private-key")
2630
+ ? args[args.indexOf("--wallet-private-key") + 1] || ""
2631
+ : "";
2632
+
2633
+ print("\nTraderClaw V1 — Session Auth Test\n");
2634
+ print("=".repeat(50));
2635
+ printInfo(` Orchestrator: ${orchestratorUrl}`);
2636
+ printInfo(` API key: ${pluginConfig.apiKey ? maskKey(pluginConfig.apiKey) : "MISSING"}`);
2637
+ printInfo(` Refresh token: ${effectiveRefreshToken ? maskKey(effectiveRefreshToken) : "MISSING"}`);
2638
+ printInfo(` Sidecar file: ${sidecar ? sessionTokensPath : "not found"}`);
2639
+ printInfo(` Wallet pub key: ${pluginConfig.walletPublicKey || "not set"}`);
2640
+ printInfo(` Wallet priv key: ${getRuntimeWalletPrivateKey(walletPrivateKeyInput) ? "available" : "NOT AVAILABLE"}`);
2641
+ print("");
2642
+
2643
+ const results = [];
2644
+ let currentAccessToken = null;
2645
+ let currentRefreshToken = effectiveRefreshToken;
2646
+
2647
+ // --- Test 1: Initial refresh ---
2648
+ print(" [1/5] Token refresh...");
2649
+ const t1Start = Date.now();
2650
+ try {
2651
+ if (!currentRefreshToken) {
2652
+ throw new Error("No refresh token available — skip to challenge flow test");
2653
+ }
2654
+ const tokens = await doRefresh(orchestratorUrl, currentRefreshToken);
2655
+ if (!tokens) {
2656
+ printWarn(" Refresh returned null (token revoked/expired) — will test challenge flow");
2657
+ results.push({ test: "initial_refresh", status: "expired", ms: Date.now() - t1Start });
2658
+ currentRefreshToken = null;
2659
+ } else {
2660
+ const ms = Date.now() - t1Start;
2661
+ currentAccessToken = tokens.accessToken;
2662
+ currentRefreshToken = tokens.refreshToken;
2663
+ printSuccess(` OK (${ms}ms) — accessTokenTtl: ${tokens.accessTokenTtlSeconds}s, refreshTokenTtl: ${tokens.refreshTokenTtlSeconds}s`);
2664
+ results.push({
2665
+ test: "initial_refresh",
2666
+ status: "ok",
2667
+ ms,
2668
+ accessTokenTtl: tokens.accessTokenTtlSeconds,
2669
+ refreshTokenTtl: tokens.refreshTokenTtlSeconds,
2670
+ tier: tokens.session?.tier,
2671
+ });
2672
+ }
2673
+ } catch (err) {
2674
+ printError(` FAIL: ${err.message}`);
2675
+ results.push({ test: "initial_refresh", status: "fail", ms: Date.now() - t1Start, error: err.message });
2676
+ currentRefreshToken = null;
2677
+ }
2678
+
2679
+ // --- Test 2: Second refresh (verifies rotation worked) ---
2680
+ print(" [2/5] Second refresh (token rotation check)...");
2681
+ const t2Start = Date.now();
2682
+ try {
2683
+ if (!currentRefreshToken) {
2684
+ throw new Error("No refresh token — skipped (previous test failed)");
2685
+ }
2686
+ const tokens2 = await doRefresh(orchestratorUrl, currentRefreshToken);
2687
+ if (!tokens2) {
2688
+ printError(" FAIL: second refresh returned null — rotation may be broken");
2689
+ results.push({ test: "rotation_check", status: "fail", ms: Date.now() - t2Start, error: "null response" });
2690
+ } else {
2691
+ const ms = Date.now() - t2Start;
2692
+ const rotated = tokens2.refreshToken !== currentRefreshToken;
2693
+ currentAccessToken = tokens2.accessToken;
2694
+ currentRefreshToken = tokens2.refreshToken;
2695
+ if (rotated) {
2696
+ printSuccess(` OK (${ms}ms) — refresh token rotated correctly`);
2697
+ } else {
2698
+ printWarn(` OK (${ms}ms) — refresh token NOT rotated (server may use static tokens)`);
2699
+ }
2700
+ results.push({ test: "rotation_check", status: "ok", ms, rotated });
2701
+ }
2702
+ } catch (err) {
2703
+ printError(` FAIL: ${err.message}`);
2704
+ results.push({ test: "rotation_check", status: "fail", ms: Date.now() - t2Start, error: err.message });
2705
+ }
2706
+
2707
+ // --- Test 3: API call with access token ---
2708
+ print(" [3/5] Authenticated API call (/healthz)...");
2709
+ const t3Start = Date.now();
2710
+ try {
2711
+ if (!currentAccessToken) {
2712
+ throw new Error("No access token — skipped");
2713
+ }
2714
+ const health = await httpRequest(`${orchestratorUrl}/healthz`, {
2715
+ accessToken: currentAccessToken,
2716
+ timeout: 8000,
2717
+ });
2718
+ const ms = Date.now() - t3Start;
2719
+ if (health.ok) {
2720
+ printSuccess(` OK (${ms}ms) — orchestrator healthy`);
2721
+ results.push({ test: "api_call", status: "ok", ms });
2722
+ } else {
2723
+ printError(` FAIL: HTTP ${health.status}`);
2724
+ results.push({ test: "api_call", status: "fail", ms, error: `HTTP ${health.status}` });
2725
+ }
2726
+ } catch (err) {
2727
+ printError(` FAIL: ${err.message}`);
2728
+ results.push({ test: "api_call", status: "fail", ms: Date.now() - t3Start, error: err.message });
2729
+ }
2730
+
2731
+ // --- Test 4: Challenge flow (re-auth from scratch) ---
2732
+ print(" [4/5] Challenge flow (full re-authentication)...");
2733
+ const t4Start = Date.now();
2734
+ try {
2735
+ if (!pluginConfig.apiKey) {
2736
+ throw new Error("No API key — cannot test challenge flow");
2737
+ }
2738
+ const challenge = await doChallenge(orchestratorUrl, pluginConfig.apiKey, pluginConfig.walletPublicKey);
2739
+ const ms = Date.now() - t4Start;
2740
+ if (challenge.walletProofRequired) {
2741
+ const wpk = getRuntimeWalletPrivateKey(walletPrivateKeyInput);
2742
+ if (wpk) {
2743
+ try {
2744
+ const walletSig = signChallengeLocally(challenge.challenge, wpk);
2745
+ const tokens = await doSessionStart(
2746
+ orchestratorUrl,
2747
+ pluginConfig.apiKey,
2748
+ challenge.challengeId,
2749
+ challenge.walletPublicKey || pluginConfig.walletPublicKey,
2750
+ walletSig,
2751
+ );
2752
+ const totalMs = Date.now() - t4Start;
2753
+ currentAccessToken = tokens.accessToken;
2754
+ currentRefreshToken = tokens.refreshToken;
2755
+ printSuccess(` OK (${totalMs}ms) — challenge + wallet proof succeeded`);
2756
+ results.push({ test: "challenge_flow", status: "ok", ms: totalMs, walletProof: true });
2757
+ } catch (sigErr) {
2758
+ printError(` FAIL (wallet signing): ${sigErr.message}`);
2759
+ results.push({ test: "challenge_flow", status: "fail", ms: Date.now() - t4Start, error: sigErr.message });
2760
+ }
2761
+ } else {
2762
+ printWarn(` PARTIAL (${ms}ms) — challenge OK but wallet proof needed and TRADERCLAW_WALLET_PRIVATE_KEY not available`);
2763
+ printWarn(" Set TRADERCLAW_WALLET_PRIVATE_KEY env var or pass --wallet-private-key to test fully");
2764
+ results.push({ test: "challenge_flow", status: "partial", ms, walletProofRequired: true, keyAvailable: false });
2765
+ }
2766
+ } else {
2767
+ const tokens = await doSessionStart(orchestratorUrl, pluginConfig.apiKey, challenge.challengeId);
2768
+ const totalMs = Date.now() - t4Start;
2769
+ currentAccessToken = tokens.accessToken;
2770
+ currentRefreshToken = tokens.refreshToken;
2771
+ printSuccess(` OK (${totalMs}ms) — challenge flow succeeded (no wallet proof needed)`);
2772
+ results.push({ test: "challenge_flow", status: "ok", ms: totalMs, walletProof: false });
2773
+ }
2774
+ } catch (err) {
2775
+ printError(` FAIL: ${err.message}`);
2776
+ results.push({ test: "challenge_flow", status: "fail", ms: Date.now() - t4Start, error: err.message });
2777
+ }
2778
+
2779
+ // --- Test 5: Persist rotated tokens back to sidecar ---
2780
+ print(" [5/5] Persist tokens to sidecar...");
2781
+ try {
2782
+ if (currentRefreshToken && currentAccessToken) {
2783
+ mkdirSync(dataDir, { recursive: true });
2784
+ const payload = {
2785
+ refreshToken: currentRefreshToken,
2786
+ accessToken: currentAccessToken,
2787
+ accessTokenExpiresAt: Date.now() + 900_000,
2788
+ walletPublicKey: pluginConfig.walletPublicKey || undefined,
2789
+ };
2790
+ const tmp = `${sessionTokensPath}.${process.pid}.${Date.now()}.tmp`;
2791
+ writeFileSync(tmp, JSON.stringify(payload, null, 2) + "\n", "utf-8");
2792
+ const { renameSync } = await import("fs");
2793
+ renameSync(tmp, sessionTokensPath);
2794
+ printSuccess(` OK — written to ${sessionTokensPath}`);
2795
+ results.push({ test: "persist_sidecar", status: "ok" });
2796
+
2797
+ pluginConfig.refreshToken = currentRefreshToken;
2798
+ removeLegacyWalletPrivateKey(pluginConfig);
2799
+ setPluginConfig(config, pluginConfig);
2800
+ writeConfig(config);
2801
+ printSuccess(" Config updated with latest refresh token");
2802
+ } else {
2803
+ printWarn(" SKIP — no valid tokens to persist");
2804
+ results.push({ test: "persist_sidecar", status: "skip" });
2805
+ }
2806
+ } catch (err) {
2807
+ printError(` FAIL: ${err.message}`);
2808
+ results.push({ test: "persist_sidecar", status: "fail", error: err.message });
2809
+ }
2810
+
2811
+ // --- Summary ---
2812
+ print("\n" + "=".repeat(50));
2813
+ const passed = results.filter((r) => r.status === "ok").length;
2814
+ const failed = results.filter((r) => r.status === "fail").length;
2815
+ const partial = results.filter((r) => r.status === "partial" || r.status === "expired" || r.status === "skip").length;
2816
+
2817
+ if (failed === 0 && passed > 0) {
2818
+ printSuccess(`\n ALL TESTS PASSED (${passed}/${results.length})`);
2819
+ } else if (failed > 0) {
2820
+ printError(`\n ${failed} FAILED, ${passed} passed, ${partial} skipped/partial`);
2821
+ } else {
2822
+ printWarn(`\n ${passed} passed, ${partial} skipped/partial`);
2823
+ }
2824
+
2825
+ print("\n Build-and-test workflow (no reinstall):");
2826
+ printInfo(" cd /path/to/plugin && npm run build");
2827
+ printInfo(" npm link");
2828
+ printInfo(" sudo systemctl restart openclaw");
2829
+ printInfo(" traderclaw test-session");
2830
+ print("");
2831
+
2832
+ if (failed > 0) process.exit(1);
2833
+ }
2834
+
2599
2835
  function printHelp() {
2600
2836
  print(`
2601
2837
  TraderClaw V1 CLI v${VERSION}
@@ -2612,6 +2848,7 @@ Commands:
2612
2848
  logout Revoke current session and clear tokens
2613
2849
  status Check connection health and wallet status
2614
2850
  config View and manage configuration
2851
+ test-session Test session auth flow (refresh, rotation, challenge) without reinstalling
2615
2852
 
2616
2853
  Setup options:
2617
2854
  --api-key, -k API key (skip interactive prompt)
@@ -2658,6 +2895,8 @@ Examples:
2658
2895
  traderclaw status
2659
2896
  traderclaw config show
2660
2897
  traderclaw config set apiTimeout 60000
2898
+ traderclaw test-session
2899
+ traderclaw test-session --wallet-private-key <base58_key>
2661
2900
  `);
2662
2901
  }
2663
2902
 
@@ -2703,6 +2942,9 @@ async function main() {
2703
2942
  case "config":
2704
2943
  await cmdConfig(args.slice(1));
2705
2944
  break;
2945
+ case "test-session":
2946
+ await cmdTestSession(args.slice(1));
2947
+ break;
2706
2948
  default:
2707
2949
  printError(`Unknown command: ${command}`);
2708
2950
  printHelp();
File without changes
@@ -144,6 +144,8 @@ var SessionManager = class {
144
144
  log;
145
145
  refreshInFlight = null;
146
146
  refreshTokenTtlMs = 0;
147
+ accessTokenTtlMs = 0;
148
+ tokenGeneration = 0;
147
149
  proactiveRefreshTimer = null;
148
150
  proactiveRefreshRunning = false;
149
151
  constructor(config) {
@@ -224,6 +226,7 @@ var SessionManager = class {
224
226
  if (!this.refreshTokenValue) {
225
227
  throw new Error("No refresh token available. Must authenticate via challenge flow.");
226
228
  }
229
+ const genBefore = this.tokenGeneration;
227
230
  const res = await rawFetch(
228
231
  `${this.baseUrl}/api/session/refresh`,
229
232
  "POST",
@@ -233,9 +236,13 @@ var SessionManager = class {
233
236
  );
234
237
  if (!res.ok) {
235
238
  if (res.status === 401 || res.status === 403) {
236
- this.accessToken = null;
237
- this.refreshTokenValue = null;
238
- this.accessTokenExpiresAt = 0;
239
+ if (this.tokenGeneration === genBefore) {
240
+ this.accessToken = null;
241
+ this.refreshTokenValue = null;
242
+ this.accessTokenExpiresAt = 0;
243
+ } else {
244
+ this.log.info("[session] Stale 401/403 ignored \u2014 tokens already rotated by concurrent call.");
245
+ }
239
246
  throw new Error("Refresh token expired or revoked. Must re-authenticate via challenge flow.");
240
247
  }
241
248
  throw new Error(`Token refresh failed (HTTP ${res.status}): ${JSON.stringify(res.data)}`);
@@ -303,14 +310,7 @@ var SessionManager = class {
303
310
  if (this.accessToken && Date.now() < this.accessTokenExpiresAt - 12e4) {
304
311
  return this.accessToken;
305
312
  }
306
- if (!this.refreshInFlight) {
307
- this.refreshInFlight = this.ensureRefreshed();
308
- }
309
- try {
310
- await this.refreshInFlight;
311
- } finally {
312
- this.refreshInFlight = null;
313
- }
313
+ await this.unifiedRefresh();
314
314
  if (!this.accessToken) {
315
315
  throw new Error(
316
316
  `Session expired and could not be refreshed. Re-authentication required. Troubleshooting: ${TRADERCLAW_SESSION_TROUBLESHOOTING}`
@@ -321,14 +321,7 @@ var SessionManager = class {
321
321
  async handleUnauthorized() {
322
322
  this.accessToken = null;
323
323
  this.accessTokenExpiresAt = 0;
324
- if (!this.refreshInFlight) {
325
- this.refreshInFlight = this.ensureRefreshed();
326
- }
327
- try {
328
- await this.refreshInFlight;
329
- } finally {
330
- this.refreshInFlight = null;
331
- }
324
+ await this.unifiedRefresh();
332
325
  if (!this.accessToken) {
333
326
  throw new Error(
334
327
  `Session expired and could not be refreshed. Re-authentication required. Troubleshooting: ${TRADERCLAW_SESSION_TROUBLESHOOTING}`
@@ -336,6 +329,20 @@ var SessionManager = class {
336
329
  }
337
330
  return this.accessToken;
338
331
  }
332
+ /**
333
+ * Single-mutex refresh: all paths (proactive timer, on-demand getAccessToken,
334
+ * handleUnauthorized) funnel through here so only one refresh HTTP call is
335
+ * ever in-flight. Prevents the race where two concurrent refresh() calls
336
+ * with the same rotating refresh token kill the session.
337
+ */
338
+ async unifiedRefresh() {
339
+ if (!this.refreshInFlight) {
340
+ this.refreshInFlight = this.ensureRefreshed().finally(() => {
341
+ this.refreshInFlight = null;
342
+ });
343
+ }
344
+ await this.refreshInFlight;
345
+ }
339
346
  isAuthenticated() {
340
347
  return !!this.accessToken;
341
348
  }
@@ -357,9 +364,11 @@ var SessionManager = class {
357
364
  return this.walletPublicKey;
358
365
  }
359
366
  applyTokens(tokens) {
367
+ this.tokenGeneration++;
360
368
  this.accessToken = tokens.accessToken;
361
369
  this.refreshTokenValue = tokens.refreshToken;
362
370
  this.accessTokenExpiresAt = Date.now() + tokens.accessTokenTtlSeconds * 1e3;
371
+ this.accessTokenTtlMs = tokens.accessTokenTtlSeconds * 1e3;
363
372
  this.refreshTokenTtlMs = (tokens.refreshTokenTtlSeconds || 0) * 1e3;
364
373
  this.sessionId = tokens.session.id;
365
374
  this.tier = tokens.session.tier;
@@ -375,14 +384,19 @@ var SessionManager = class {
375
384
  this.scheduleProactiveRefresh();
376
385
  }
377
386
  /**
378
- * Schedule a background token refresh well before the refresh token expires.
379
- * Uses 50% of refresh token TTL (clamped between 2 min and 20 min).
380
- * Each successful refresh rotates both tokens, keeping the chain alive
381
- * even when no tool calls are happening (idle heartbeat gaps, gateway restarts).
387
+ * Schedule a repeating background token refresh. Uses setInterval so the
388
+ * chain cannot silently break if a single cycle fails to re-schedule.
389
+ *
390
+ * Interval = min(50% refresh-token TTL, accessTokenTtl - 2.5 min buffer),
391
+ * clamped between 2 min and 20 min. Falls back to 10 min when TTLs unknown.
392
+ *
393
+ * Goes through unifiedRefresh() so it shares the same mutex as on-demand
394
+ * callers and can fall back to the full challenge flow when refresh tokens
395
+ * are permanently revoked.
382
396
  */
383
397
  scheduleProactiveRefresh() {
384
398
  if (this.proactiveRefreshTimer) {
385
- clearTimeout(this.proactiveRefreshTimer);
399
+ clearInterval(this.proactiveRefreshTimer);
386
400
  this.proactiveRefreshTimer = null;
387
401
  }
388
402
  const MIN_INTERVAL_MS = 2 * 60 * 1e3;
@@ -394,17 +408,20 @@ var SessionManager = class {
394
408
  } else {
395
409
  intervalMs = DEFAULT_INTERVAL_MS;
396
410
  }
397
- this.proactiveRefreshTimer = setTimeout(async () => {
411
+ if (this.accessTokenTtlMs > 0) {
412
+ const accessBasedMs = Math.max(MIN_INTERVAL_MS, this.accessTokenTtlMs - 15e4);
413
+ intervalMs = Math.min(intervalMs, accessBasedMs);
414
+ }
415
+ this.log.info(`[session] Proactive refresh scheduled every ${Math.round(intervalMs / 1e3)}s`);
416
+ this.proactiveRefreshTimer = setInterval(async () => {
398
417
  if (this.proactiveRefreshRunning) return;
399
418
  this.proactiveRefreshRunning = true;
400
419
  try {
401
- if (!this.refreshTokenValue) return;
402
420
  this.log.info(`[session] Proactive token refresh (interval: ${Math.round(intervalMs / 1e3)}s)...`);
403
- await this.refresh();
421
+ await this.unifiedRefresh();
404
422
  this.log.info("[session] Proactive refresh succeeded \u2014 token chain extended.");
405
423
  } catch (err) {
406
- this.log.warn(`[session] Proactive refresh failed: ${err.message}. Will retry next cycle or on-demand.`);
407
- this.scheduleProactiveRefresh();
424
+ this.log.warn(`[session] Proactive refresh failed: ${err.message}. Will retry next interval or on-demand.`);
408
425
  } finally {
409
426
  this.proactiveRefreshRunning = false;
410
427
  }
@@ -415,7 +432,7 @@ var SessionManager = class {
415
432
  }
416
433
  destroy() {
417
434
  if (this.proactiveRefreshTimer) {
418
- clearTimeout(this.proactiveRefreshTimer);
435
+ clearInterval(this.proactiveRefreshTimer);
419
436
  this.proactiveRefreshTimer = null;
420
437
  }
421
438
  }
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  } from "./chunk-T4YWGIIR.js";
10
10
  import {
11
11
  SessionManager
12
- } from "./chunk-QSICXLW7.js";
12
+ } from "./chunk-GYXPEC7O.js";
13
13
 
14
14
  // index.ts
15
15
  import { Type } from "@sinclair/typebox";
@@ -1254,14 +1254,31 @@ var solanaTraderPlugin = {
1254
1254
  });
1255
1255
  api.registerTool({
1256
1256
  name: "solana_trade_precheck",
1257
- description: "Pre-trade risk check \u2014 validates a proposed trade against risk rules, kill switch, entitlement limits, and on-chain conditions. Returns approved/denied with reasons and capped size. Always call this before executing a trade. Buy: sizeSol required; do not send sizeTokens or sellPct. Sell: send exactly one of sizeTokens or sellPct (not sizeSol). If both sellPct and sizeTokens are sent, sellPct is preferred and sizeTokens is ignored.",
1257
+ description: "Pre-trade risk check \u2014 validates a proposed trade against risk rules, kill switch, entitlement limits, and on-chain conditions. Returns approved/denied with reasons and capped size. Always call this before executing a trade. Buy: sizeSol required; do not send sizeTokens or sellPct. Sell: send exactly one of sizeTokens or sellPct (not sizeSol). If both sellPct and sizeTokens are sent, sellPct is preferred and sizeTokens is ignored. Optional exit fields (trailingStopPct, trailingStop) are accepted to mirror execute payloads; sizing logic ignores them.",
1258
1258
  parameters: Type.Object({
1259
1259
  tokenAddress: Type.String({ description: "Solana token mint address" }),
1260
1260
  side: Type.Union([Type.Literal("buy"), Type.Literal("sell")], { description: "Trade direction" }),
1261
1261
  sizeSol: Type.Optional(Type.Number({ description: "Position size in SOL \u2014 required for buy, omit for sell" })),
1262
1262
  sellPct: Type.Optional(Type.Number({ description: "Sell percentage 1\u2013100 (100 = full exit) \u2014 sell only; preferred over sizeTokens if both sent" })),
1263
1263
  sizeTokens: Type.Optional(Type.Number({ description: "Token amount to sell \u2014 sell only; ignored if sellPct is also provided" })),
1264
- slippageBps: Type.Optional(Type.Number({ description: "Slippage tolerance in basis points (e.g., 300 = 3%)" }))
1264
+ slippageBps: Type.Optional(Type.Number({ description: "Slippage tolerance in basis points (e.g., 300 = 3%)" })),
1265
+ trailingStopPct: Type.Optional(Type.Number({ description: "Optional \u2014 same as execute; ignored for policy sizing" })),
1266
+ trailingStop: Type.Optional(
1267
+ Type.Object({
1268
+ levels: Type.Array(
1269
+ Type.Object({
1270
+ percentage: Type.Number({ description: "Trailing drawdown % from the armed high once the level is active" }),
1271
+ amount: Type.Optional(Type.Number({ description: "% of position to sell at this level (1\u2013100). Server default 100." })),
1272
+ triggerAboveATH: Type.Optional(
1273
+ Type.Number({
1274
+ description: "Optional. % above session ATH before this level arms. If omitted, API defaults to 100 (2\xD7 ATH)."
1275
+ })
1276
+ )
1277
+ }),
1278
+ { minItems: 1, maxItems: 5, description: "Multi-level trailing (optional on precheck)" }
1279
+ )
1280
+ })
1281
+ )
1265
1282
  }),
1266
1283
  execute: wrapExecute(async (_id, params) => {
1267
1284
  const body = {
@@ -1269,6 +1286,13 @@ var solanaTraderPlugin = {
1269
1286
  side: params.side,
1270
1287
  slippageBps: params.slippageBps
1271
1288
  };
1289
+ if (params.trailingStopPct !== void 0) {
1290
+ body.trailingStopPct = params.trailingStopPct;
1291
+ }
1292
+ const ts = params.trailingStop;
1293
+ if (ts?.levels && Array.isArray(ts.levels) && ts.levels.length > 0) {
1294
+ body.trailingStop = ts;
1295
+ }
1272
1296
  if (params.side === "buy") {
1273
1297
  body.sizeSol = params.sizeSol;
1274
1298
  } else {
@@ -1283,7 +1307,7 @@ var solanaTraderPlugin = {
1283
1307
  });
1284
1308
  api.registerTool({
1285
1309
  name: "solana_trade_execute",
1286
- description: "Execute a trade on Solana via the SpyFly bot. Enforces risk rules before proxying to on-chain execution. Returns trade ID, position ID, and transaction signature. IMPORTANT: tpLevels alone (e.g. [10, 15]) means EACH level sells 100% of the position at that gain \u2014 use tpExits for partials (e.g. +10% sell 50%, +15% sell 100%). Buy: sizeSol required; do not send sizeTokens or sellPct. Sell: send exactly one of sizeTokens or sellPct (not sizeSol). If both sellPct and sizeTokens are sent, sellPct is preferred and sizeTokens is ignored.",
1310
+ description: "Execute a trade on Solana via the SpyFly bot. Enforces risk rules before proxying to on-chain execution. Returns trade ID, position ID, and transaction signature. IMPORTANT: tpLevels alone (e.g. [10, 15]) means EACH level sells 100% of the position at that gain \u2014 use tpExits for partials (e.g. +10% sell 50%, +15% sell 100%). Trailing: use `trailingStopPct` for a single simple trailing %, or `trailingStop.levels` (1\u20135) for multi-level trailing with optional `triggerAboveATH` per level (% above session ATH before that level arms; if omitted, server defaults to 100 i.e. 2\xD7 ATH). When both are sent, `trailingStop` wins. Buy: sizeSol required; do not send sizeTokens or sellPct. Sell: send exactly one of sizeTokens or sellPct (not sizeSol). If both sellPct and sizeTokens are sent, sellPct is preferred and sizeTokens is ignored.",
1287
1311
  parameters: Type.Object({
1288
1312
  tokenAddress: Type.String({ description: "Solana token mint address" }),
1289
1313
  side: Type.Union([Type.Literal("buy"), Type.Literal("sell")], { description: "Trade direction" }),
@@ -1318,7 +1342,37 @@ var solanaTraderPlugin = {
1318
1342
  { description: "Multi-level stop-loss with partial exits (optional). Otherwise use slPct for a single full exit." }
1319
1343
  )
1320
1344
  ),
1321
- trailingStopPct: Type.Optional(Type.Number({ description: "Trailing stop percentage" })),
1345
+ trailingStopPct: Type.Optional(
1346
+ Type.Number({
1347
+ description: "Single trailing-stop % (legacy). Ignored if `trailingStop` is provided."
1348
+ })
1349
+ ),
1350
+ trailingStop: Type.Optional(
1351
+ Type.Object({
1352
+ levels: Type.Array(
1353
+ Type.Object({
1354
+ percentage: Type.Number({
1355
+ description: "Once armed, sell when price drops this % from the high (trailing drawdown)."
1356
+ }),
1357
+ amount: Type.Optional(
1358
+ Type.Number({
1359
+ description: "% of position to sell when this level fires (1\u2013100). Server default 100."
1360
+ })
1361
+ ),
1362
+ triggerAboveATH: Type.Optional(
1363
+ Type.Number({
1364
+ description: "Optional. Session price must reach this % above session ATH before this level arms (e.g. 50 \u2192 1.5\xD7 ATH). If omitted, API defaults to 100 (2\xD7 ATH)."
1365
+ })
1366
+ )
1367
+ }),
1368
+ {
1369
+ minItems: 1,
1370
+ maxItems: 5,
1371
+ description: "Ordered trailing-stop levels (up to 5)."
1372
+ }
1373
+ )
1374
+ })
1375
+ ),
1322
1376
  managementMode: Type.Optional(
1323
1377
  Type.Union([Type.Literal("LOCAL_MANAGED"), Type.Literal("SERVER_MANAGED")], {
1324
1378
  description: "Advisory only \u2014 server decides position mode internally. Sent for future compatibility."
@@ -1337,9 +1391,14 @@ var solanaTraderPlugin = {
1337
1391
  symbol: params.symbol,
1338
1392
  slippageBps: params.slippageBps,
1339
1393
  slPct: params.slPct,
1340
- trailingStopPct: params.trailingStopPct,
1341
1394
  managementMode: params.managementMode
1342
1395
  };
1396
+ const tsExecute = params.trailingStop;
1397
+ if (tsExecute?.levels && Array.isArray(tsExecute.levels) && tsExecute.levels.length > 0) {
1398
+ body.trailingStop = tsExecute;
1399
+ } else if (params.trailingStopPct !== void 0) {
1400
+ body.trailingStopPct = params.trailingStopPct;
1401
+ }
1343
1402
  if (params.side === "buy") {
1344
1403
  body.sizeSol = params.sizeSol;
1345
1404
  } else {
@@ -1507,7 +1566,7 @@ var solanaTraderPlugin = {
1507
1566
  });
1508
1567
  api.registerTool({
1509
1568
  name: "solana_capital_status",
1510
- description: "Get your current capital status \u2014 SOL balance, open position count, unrealized PnL, daily notional used, daily loss, and effective limits (adjusted by entitlements).",
1569
+ description: "Get your current capital status \u2014 SOL balance, open position count, unrealized/realized PnL, daily notional used, daily loss, and effective limits. **PnL:** `totalUnrealizedPnl` / `totalRealizedPnl` are USD (DB); use `totalUnrealizedPnlSol` / `totalRealizedPnlSol` / `totalPnlSol` for SOL (derived via `solPriceUsd`, same as positions API).",
1511
1570
  parameters: Type.Object({}),
1512
1571
  execute: wrapExecute(
1513
1572
  async () => get(`/api/capital/status?walletId=${walletId}`)
@@ -1515,7 +1574,7 @@ var solanaTraderPlugin = {
1515
1574
  });
1516
1575
  api.registerTool({
1517
1576
  name: "solana_positions",
1518
- description: "List your current trading positions with unrealized PnL, entry price, current price, stop-loss/take-profit settings, and management mode. Call at the START of every trading cycle for interrupt check. Also use to detect dead money (flat positions).",
1577
+ description: "List trading positions with mark-to-market. **PnL:** `realizedPnl` / `unrealizedPnl` are USD as stored; prefer `realizedPnlSol` / `unrealizedPnlSol` when reasoning in SOL. `unrealizedReturnPct` is ROI on cost basis (for sweep-dead-tokens logic). See response `pnlNote`.",
1519
1578
  parameters: Type.Object({
1520
1579
  status: Type.Optional(Type.String({ description: "Filter by status: 'open', 'closed', or omit for all" }))
1521
1580
  }),
@@ -1525,6 +1584,38 @@ var solanaTraderPlugin = {
1525
1584
  return get(path2);
1526
1585
  })
1527
1586
  });
1587
+ api.registerTool({
1588
+ name: "solana_wallet_token_balance",
1589
+ description: "Read on-chain SPL token balance (UI amount) for your trading wallet and a token mint. Same balance path as server exit monitoring (`balanceOf`).",
1590
+ parameters: Type.Object({
1591
+ tokenAddress: Type.String({ description: "SPL token mint address" })
1592
+ }),
1593
+ execute: wrapExecute(
1594
+ async (_id, params) => post("/api/wallet/token-balance", {
1595
+ walletId,
1596
+ tokenAddress: params.tokenAddress
1597
+ })
1598
+ )
1599
+ });
1600
+ api.registerTool({
1601
+ name: "solana_sweep_dead_tokens",
1602
+ description: "Sell 100% of each OPEN position whose unrealizedReturnPct is \u2264 -maxLossPct (default 80), using the same mark-to-market as positions. Use dryRun:true first to list candidates. Executes sequential full exits (sellPct 100). Requires trade:execute scope.",
1603
+ parameters: Type.Object({
1604
+ maxLossPct: Type.Optional(
1605
+ Type.Number({ description: "Threshold: sweep when unrealizedReturnPct <= -maxLossPct (default 80)" })
1606
+ ),
1607
+ slippageBps: Type.Optional(Type.Number({ description: "Per-exit slippage in bps (default 300)" })),
1608
+ dryRun: Type.Optional(Type.Boolean({ description: "If true, only return candidate tokens without selling" }))
1609
+ }),
1610
+ execute: wrapExecute(
1611
+ async (_id, params) => post("/api/wallet/sweep-dead-tokens", {
1612
+ walletId,
1613
+ maxLossPct: params.maxLossPct,
1614
+ slippageBps: params.slippageBps,
1615
+ dryRun: params.dryRun
1616
+ })
1617
+ )
1618
+ });
1528
1619
  api.registerTool({
1529
1620
  name: "solana_funding_instructions",
1530
1621
  description: "Get deposit instructions for funding your trading wallet with SOL.",
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  SessionManager
3
- } from "../chunk-QSICXLW7.js";
3
+ } from "../chunk-GYXPEC7O.js";
4
4
  export {
5
5
  SessionManager
6
6
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solana-traderclaw",
3
- "version": "1.0.36",
3
+ "version": "1.0.38",
4
4
  "description": "TraderClaw V1 — autonomous Solana memecoin trading for OpenClaw (team edition: X/Twitter journal and engagement tools)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -14,7 +14,7 @@ The orchestrator gathers data, enforces execution policy, applies entitlement li
14
14
 
15
15
  ## How You Access the Orchestrator
16
16
 
17
- You interact with the orchestrator **exclusively through plugin tools** (e.g. `solana_system_status`, `solana_scan`, `solana_alpha_signals`, `solana_trade`, etc.). You have no other access method.
17
+ You interact with the orchestrator **exclusively through plugin tools** (e.g. `solana_system_status`, `solana_scan_hot_pairs`,`solana_scan_launches` , `solana_alpha_signals`, `solana_trade_precheck`, `solana_trade_execute`, etc.). You have no other access method.
18
18
 
19
19
  **Critical rules:**
20
20
  - **You do NOT have direct HTTP/API access.** Never attempt to call REST endpoints, use curl/fetch, or construct API URLs. You cannot reach the orchestrator that way.
@@ -234,8 +234,14 @@ All values are internal targets that must still comply with server policy caps.
234
234
  | Position size (exploratory) | 3–8% of capital | 5–10% of capital |
235
235
  | Max correlated cluster exposure | 40% of capital | 40% of capital |
236
236
  | Consecutive losses → kill switch | 5 | 7 |
237
- | Rapid drawdown defense trigger | -20% on any position | -15% on any position |
238
- | Partial profit trigger | +40–60% (optional) | +25–50% (take partial quickly) |
237
+ | Stop loss (slExits) | -20% on every position | -40% on every position |
238
+ | Trailing Stop loss ("trailingStop": {
239
+ "levels": [
240
+ { "percentage": 25, "amount": 50 },
241
+ { "percentage": 35, "amount": 100, "triggerAboveATH": 100 }
242
+ ]
243
+ }) where percent is the price decrease from entry price and amount is the size of position in percentage and in token holding | -20% on every position and optional triggerAboveATH | -40% on every position |
244
+ | Multiple Take Profit exits (tpExits) (e.g [{"percent": 100, "amountPct": 30},{ "percent": 200, "amountPct": 100" }], where percent is the price increase from entry price and amountPct is the size of position in percentage and in token holding) | +100–300% (multiple) | +200–500% (multiple) |
239
245
  | Exploration ratio | 20% experimental / 80% proven | 50% experimental / 50% proven |
240
246
  | Weight evolution (minimum trades) | ≥20 closed trades | ≥20 closed trades |
241
247
  | Max weight delta per update | ±0.10 | ±0.15 |
@@ -330,7 +336,7 @@ There is no context loss from this separation. Cron outputs flow into the persis
330
336
 
331
337
  2. Step 0: INTERRUPT CHECK — identify wake-up trigger, check kill switch, check dead money, STRATEGY INTEGRITY CHECK
332
338
 
333
- 3. Step 1: SCAN — call solana_scan for broad discovery, process Bitquery subscriptions
339
+ 3. Step 1: SCAN — call solana_scan_launches and solana_scan_hot_pairs for broad discovery, process Bitquery subscriptions
334
340
 
335
341
  4. Step 1.5b: ALPHA SIGNALS — poll solana_alpha_signals, score, classify priority
336
342
 
@@ -902,10 +908,6 @@ Discovery subscriptions complement — not replace — scan endpoints and alpha
902
908
 
903
909
  When multiple paths independently surface the same token = convergence = highest conviction. Log convergence events via `solana_memory_write` with tag `signal_convergence`.
904
910
 
905
- **Future enhancement (not yet available):**
906
-
907
- When `solana_firehose_config` and `solana_firehose_status` tools become available, you will be able to configure advanced filter parameters (volume thresholds, buyer counts, whale detection) directly on the orchestrator or local worker, and check health/stats. For now, use the existing `solana_bitquery_subscribe` with the available template keys and evolve your subscription strategy based on outcomes.
908
-
909
911
  ---
910
912
 
911
913
  ### Step 2: ANALYZE — Deep Dive
@@ -1128,9 +1130,11 @@ If BUY, you must define before executing:
1128
1130
  - `tpLevels` — staged take-profit levels as percentages
1129
1131
  - HARDENED: `[50, 100, 200]` (patient, ride trends)
1130
1132
  - DEGEN: `[25, 50, 100]` (lock gains faster)
1131
- - `trailingStopPct` — trailing stop activation
1132
- - HARDENED: 1218%
1133
- - DEGEN: 8–15%
1133
+ - `trailingStopPct` — single trailing stop (% drawdown from session high once active). Legacy/simple path on the server.
1134
+ - **`trailingStop`** (preferred for staging) — `{ levels: [ ... ] }` with **15** levels. Each level:
1135
+ - `percentage` — trailing drawdown % from the armed high once that level is active (required).
1136
+ - `amount` — % of position to sell at this level (1–100; server default **100**).
1137
+ - `triggerAboveATH` — **optional.** Price must reach this **% above the session ATH** before this level arms (e.g. `50` → 1.5× ATH). **If omitted, the API defaults to `100` (2× ATH).** Use a smaller value (e.g. `25`) to arm earlier; use `trailingStopPct` instead if you want the simpler single-level trailing that keys off ATH without this gate.
1134
1138
  - `managementMode` — LOCAL_MANAGED or SERVER_MANAGED
1135
1139
  - `slippageBps` — must scale with liquidity:
1136
1140
  - Pool depth > $500K: 100–200 bps
@@ -1227,7 +1231,7 @@ Call `solana_trade_execute` with:
1227
1231
  - **For buy:** `sizeSol` (amount in SOL to spend) — required
1228
1232
  - **For sell:** `sellPct` (percentage of position to sell, 1–100 where 100 = full exit) **or** `sizeTokens` (exact token count) — one is required. If both sent, `sellPct` wins. Do NOT send `sizeSol` for sells.
1229
1233
  - `slippageBps` (scaled to liquidity as defined in your exit plan — hard cap 800bps)
1230
- - `slPct`, `tpLevels`, `trailingStopPct`
1234
+ - `slPct`, `tpLevels`, **`trailingStopPct` and/or `trailingStop`** (see **Define Exit Plan** above — if both are sent, `trailingStop` wins)
1231
1235
  - `managementMode`
1232
1236
 
1233
1237
  Record the returned `tradeId` and `positionId`. You will need these for monitoring and review.
@@ -1669,7 +1673,6 @@ Your discovery subscriptions (Step 1.75) should evolve alongside your strategy w
1669
1673
  - **Adjusting discovery subscriptions** (broad, for token detection):
1670
1674
  Discovery subscriptions like `pumpFunTokenCreation` and `raydiumNewPools` are fire-and-forget — you set them up once and they run until you unsubscribe. Evolution here means deciding which broad subscriptions to keep vs remove, not changing their parameters.
1671
1675
 
1672
- When `solana_firehose_config` becomes available, you will be able to configure advanced filter parameters (volume thresholds, buyer counts, whale detection) on the orchestrator or local worker side without the unsub/resub cycle.
1673
1676
 
1674
1677
  5. Mode switches should trigger subscription review:
1675
1678
  - Switching to DEGEN → add more discovery subscriptions, broaden parameters
@@ -2064,8 +2067,10 @@ All authenticated endpoints use `Authorization: Bearer <accessToken>`.
2064
2067
  | `POST` | `/api/session/logout` | `refreshToken` | No auth needed. Revokes session |
2065
2068
  | `GET` | `/api/wallets` | — | List all wallets. Optional `?refresh=true` |
2066
2069
  | `POST` | `/api/wallet/create` | — | Create wallet. Optional: `label`, `publicKey`, `chain` (solana/bsc), `ownerRef`, `includePrivateKey`. Status `201` |
2067
- | `GET` | `/api/capital/status` | `?walletId=<uuid>` | Wallet capital and daily limits |
2068
- | `GET` | `/api/wallet/positions` | `?walletId=<uuid>` | Open positions. Optional `?status=` |
2070
+ | `GET` | `/api/capital/status` | `?walletId=<uuid>` | Wallet capital and daily limits. **PnL:** `totalUnrealizedPnl` / `totalRealizedPnl` are **USD** (stored); `totalUnrealizedPnlSol` / `totalRealizedPnlSol` / `totalPnlSol` are **SOL** derived with `solPriceUsd` (agent-safe). |
2071
+ | `GET` | `/api/wallet/positions` | `?walletId=<uuid>` | Positions. **`realizedPnl` / `unrealizedPnl` = USD** in DB; use **`realizedPnlSol` / `unrealizedPnlSol`** for SOL. `unrealizedReturnPct` = ROI vs cost (for sweep). |
2072
+ | `POST` | `/api/wallet/token-balance` | `walletId`, `tokenAddress` | On-chain SPL **uiAmount** for the wallet (same as server `balanceOf`) |
2073
+ | `POST` | `/api/wallet/sweep-dead-tokens` | `walletId` | Optional: `maxLossPct` (default **80**), `slippageBps`, `dryRun`. Sells **100%** of each **open** position with `unrealizedReturnPct` ≤ **-maxLossPct**. **trade:execute** scope. |
2069
2074
  | `GET` | `/api/funding/instructions` | `?walletId=<uuid>` | Deposit instructions |
2070
2075
  | `GET` | `/api/killswitch/status` | `?walletId=<uuid>` | Kill switch state |
2071
2076
  | `POST` | `/api/killswitch` | `walletId`, `enabled` | Toggle kill switch. Optional: `mode` (TRADES_ONLY / TRADES_AND_STREAMS). **Pro tier required** |
@@ -2073,7 +2078,7 @@ All authenticated endpoints use `Authorization: Bearer <accessToken>`.
2073
2078
  | `POST` | `/api/strategy/update` | `walletId`, `featureWeights` | Update weights. Optional: `strategyVersion`, `mode` (HARDENED/DEGEN) |
2074
2079
  | `POST` | `/api/thesis/build` | `walletId`, `tokenAddress` | Build full thesis package |
2075
2080
  | `POST` | `/api/trade/precheck` | `walletId`, `tokenAddress`, `side` (buy/sell), `slippageBps`. Buy: `sizeSol`. Sell: `sellPct` or `sizeTokens` | Risk/policy check, no execution |
2076
- | `POST` | `/api/trade/execute` | `walletId`, `tokenAddress`, `side`, `slippageBps`. Buy: `sizeSol`. Sell: `sellPct` or `sizeTokens` | Execute trade. Optional: `symbol`, `tpLevels[]`, `slPct`, `trailingStopPct`. Header: `x-idempotency-key` |
2081
+ | `POST` | `/api/trade/execute` | `walletId`, `tokenAddress`, `side`, `slippageBps`. Buy: `sizeSol`. Sell: `sellPct` or `sizeTokens` | Execute trade. Optional: `symbol`, `tpLevels[]`, `slPct`, `trailingStopPct`, `trailingStop: { levels: [{ percentage, amount?, triggerAboveATH? }] }` (1–5 levels; **omitted `triggerAboveATH` defaults to 100** = 2× ATH). Header: `x-idempotency-key` |
2077
2082
  | `POST` | `/api/trade/review` | `walletId`, `outcome` (win/loss/neutral), `notes` | Post-trade review. Optional: `tradeId`, `tokenAddress`, `pnlSol`, `tags[]`, `strategyVersion` (strict semver). Status `201` |
2078
2083
  | `POST` | `/api/memory/write` | `walletId`, `notes` | Journal entry. Optional: `tokenAddress`, `outcome` (win/loss/neutral), `tags[]`, `strategyVersion` (strict semver). Status `201` |
2079
2084
  | `POST` | `/api/memory/search` | `walletId`, `query` | Search memory entries |
@@ -2701,8 +2706,10 @@ Each user configures their own X/Twitter API developer account tokens in the plu
2701
2706
  | Thesis | `solana_build_thesis` | Full context package |
2702
2707
  | Trade | `solana_trade_precheck` | Pre-trade risk validation |
2703
2708
  | Trade | `solana_trade_execute` | Execute trade |
2704
- | Monitor | `solana_positions` | Position tracking |
2705
- | Monitor | `solana_capital_status` | Portfolio status |
2709
+ | Monitor | `solana_positions` | Position tracking (USD PnL in DB; use `*PnlSol` fields for SOL) |
2710
+ | Monitor | `solana_capital_status` | Portfolio status (same USD vs SOL split as API) |
2711
+ | Risk | `solana_sweep_dead_tokens` | Full-exit open positions below **-maxLossPct** ROI (default 80%); use `dryRun` first |
2712
+ | Wallet | `solana_wallet_token_balance` | On-chain SPL balance for a mint |
2706
2713
  | Review | `solana_trade_review` | Post-trade review |
2707
2714
  | Memory | `solana_memory_write` | Write journal entry (deployer profiles, filter evolution, convergence) |
2708
2715
  | Memory | `solana_memory_search` | Search memories (check deployer history before profiling) |