solana-traderclaw 1.0.37 → 1.0.39

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.
@@ -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";
@@ -1015,11 +1015,6 @@ async function cmdSetup(args) {
1015
1015
  printWarn(
1016
1016
  ` For the OpenClaw gateway (Telegram/agent tools), the same env must be set on the gateway service — not only in this shell. See: ${TRADERCLAW_SESSION_TROUBLESHOOTING_URL}`,
1017
1017
  );
1018
- print("Next steps:");
1019
- print(" 1. Install the plugin: openclaw plugins install solana-traderclaw (or: npm install -g solana-traderclaw)");
1020
- print(" 2. Restart the gateway: openclaw gateway --restart");
1021
- print(" 3. Start trading: Ask OpenClaw to scan for opportunities");
1022
- print("");
1023
1018
  print("Session commands:");
1024
1019
  print(" traderclaw status Check connection health (auto-refreshes session)");
1025
1020
  print(" traderclaw login Re-authenticate (challenge flow)");
@@ -2596,6 +2591,242 @@ async function cmdInstall(args) {
2596
2591
  printInfo("Press Ctrl+C to stop the wizard server.");
2597
2592
  }
2598
2593
 
2594
+ async function cmdTestSession(args) {
2595
+ const config = readConfig();
2596
+ const pluginConfig = getPluginConfig(config);
2597
+
2598
+ if (!pluginConfig) {
2599
+ printError("No plugin configuration found. Run 'traderclaw setup' first.");
2600
+ process.exit(1);
2601
+ }
2602
+
2603
+ const orchestratorUrl = pluginConfig.orchestratorUrl;
2604
+ if (!orchestratorUrl) {
2605
+ printError("orchestratorUrl not set in config. Run 'traderclaw setup' to fix.");
2606
+ process.exit(1);
2607
+ }
2608
+
2609
+ const dataDir = pluginConfig.dataDir || join(process.cwd(), ".traderclaw-v1-data");
2610
+ const sessionTokensPath = join(dataDir, "session-tokens.json");
2611
+
2612
+ let sidecar = null;
2613
+ try {
2614
+ if (existsSync(sessionTokensPath)) {
2615
+ sidecar = JSON.parse(readFileSync(sessionTokensPath, "utf-8"));
2616
+ }
2617
+ } catch { /* ignore */ }
2618
+
2619
+ const effectiveRefreshToken =
2620
+ (sidecar?.refreshToken && sidecar.refreshToken.length > 0)
2621
+ ? sidecar.refreshToken
2622
+ : pluginConfig.refreshToken;
2623
+
2624
+ const walletPrivateKeyInput = args.includes("--wallet-private-key")
2625
+ ? args[args.indexOf("--wallet-private-key") + 1] || ""
2626
+ : "";
2627
+
2628
+ print("\nTraderClaw V1 — Session Auth Test\n");
2629
+ print("=".repeat(50));
2630
+ printInfo(` Orchestrator: ${orchestratorUrl}`);
2631
+ printInfo(` API key: ${pluginConfig.apiKey ? maskKey(pluginConfig.apiKey) : "MISSING"}`);
2632
+ printInfo(` Refresh token: ${effectiveRefreshToken ? maskKey(effectiveRefreshToken) : "MISSING"}`);
2633
+ printInfo(` Sidecar file: ${sidecar ? sessionTokensPath : "not found"}`);
2634
+ printInfo(` Wallet pub key: ${pluginConfig.walletPublicKey || "not set"}`);
2635
+ printInfo(` Wallet priv key: ${getRuntimeWalletPrivateKey(walletPrivateKeyInput) ? "available" : "NOT AVAILABLE"}`);
2636
+ print("");
2637
+
2638
+ const results = [];
2639
+ let currentAccessToken = null;
2640
+ let currentRefreshToken = effectiveRefreshToken;
2641
+
2642
+ // --- Test 1: Initial refresh ---
2643
+ print(" [1/5] Token refresh...");
2644
+ const t1Start = Date.now();
2645
+ try {
2646
+ if (!currentRefreshToken) {
2647
+ throw new Error("No refresh token available — skip to challenge flow test");
2648
+ }
2649
+ const tokens = await doRefresh(orchestratorUrl, currentRefreshToken);
2650
+ if (!tokens) {
2651
+ printWarn(" Refresh returned null (token revoked/expired) — will test challenge flow");
2652
+ results.push({ test: "initial_refresh", status: "expired", ms: Date.now() - t1Start });
2653
+ currentRefreshToken = null;
2654
+ } else {
2655
+ const ms = Date.now() - t1Start;
2656
+ currentAccessToken = tokens.accessToken;
2657
+ currentRefreshToken = tokens.refreshToken;
2658
+ printSuccess(` OK (${ms}ms) — accessTokenTtl: ${tokens.accessTokenTtlSeconds}s, refreshTokenTtl: ${tokens.refreshTokenTtlSeconds}s`);
2659
+ results.push({
2660
+ test: "initial_refresh",
2661
+ status: "ok",
2662
+ ms,
2663
+ accessTokenTtl: tokens.accessTokenTtlSeconds,
2664
+ refreshTokenTtl: tokens.refreshTokenTtlSeconds,
2665
+ tier: tokens.session?.tier,
2666
+ });
2667
+ }
2668
+ } catch (err) {
2669
+ printError(` FAIL: ${err.message}`);
2670
+ results.push({ test: "initial_refresh", status: "fail", ms: Date.now() - t1Start, error: err.message });
2671
+ currentRefreshToken = null;
2672
+ }
2673
+
2674
+ // --- Test 2: Second refresh (verifies rotation worked) ---
2675
+ print(" [2/5] Second refresh (token rotation check)...");
2676
+ const t2Start = Date.now();
2677
+ try {
2678
+ if (!currentRefreshToken) {
2679
+ throw new Error("No refresh token — skipped (previous test failed)");
2680
+ }
2681
+ const tokens2 = await doRefresh(orchestratorUrl, currentRefreshToken);
2682
+ if (!tokens2) {
2683
+ printError(" FAIL: second refresh returned null — rotation may be broken");
2684
+ results.push({ test: "rotation_check", status: "fail", ms: Date.now() - t2Start, error: "null response" });
2685
+ } else {
2686
+ const ms = Date.now() - t2Start;
2687
+ const rotated = tokens2.refreshToken !== currentRefreshToken;
2688
+ currentAccessToken = tokens2.accessToken;
2689
+ currentRefreshToken = tokens2.refreshToken;
2690
+ if (rotated) {
2691
+ printSuccess(` OK (${ms}ms) — refresh token rotated correctly`);
2692
+ } else {
2693
+ printWarn(` OK (${ms}ms) — refresh token NOT rotated (server may use static tokens)`);
2694
+ }
2695
+ results.push({ test: "rotation_check", status: "ok", ms, rotated });
2696
+ }
2697
+ } catch (err) {
2698
+ printError(` FAIL: ${err.message}`);
2699
+ results.push({ test: "rotation_check", status: "fail", ms: Date.now() - t2Start, error: err.message });
2700
+ }
2701
+
2702
+ // --- Test 3: API call with access token ---
2703
+ print(" [3/5] Authenticated API call (/healthz)...");
2704
+ const t3Start = Date.now();
2705
+ try {
2706
+ if (!currentAccessToken) {
2707
+ throw new Error("No access token — skipped");
2708
+ }
2709
+ const health = await httpRequest(`${orchestratorUrl}/healthz`, {
2710
+ accessToken: currentAccessToken,
2711
+ timeout: 8000,
2712
+ });
2713
+ const ms = Date.now() - t3Start;
2714
+ if (health.ok) {
2715
+ printSuccess(` OK (${ms}ms) — orchestrator healthy`);
2716
+ results.push({ test: "api_call", status: "ok", ms });
2717
+ } else {
2718
+ printError(` FAIL: HTTP ${health.status}`);
2719
+ results.push({ test: "api_call", status: "fail", ms, error: `HTTP ${health.status}` });
2720
+ }
2721
+ } catch (err) {
2722
+ printError(` FAIL: ${err.message}`);
2723
+ results.push({ test: "api_call", status: "fail", ms: Date.now() - t3Start, error: err.message });
2724
+ }
2725
+
2726
+ // --- Test 4: Challenge flow (re-auth from scratch) ---
2727
+ print(" [4/5] Challenge flow (full re-authentication)...");
2728
+ const t4Start = Date.now();
2729
+ try {
2730
+ if (!pluginConfig.apiKey) {
2731
+ throw new Error("No API key — cannot test challenge flow");
2732
+ }
2733
+ const challenge = await doChallenge(orchestratorUrl, pluginConfig.apiKey, pluginConfig.walletPublicKey);
2734
+ const ms = Date.now() - t4Start;
2735
+ if (challenge.walletProofRequired) {
2736
+ const wpk = getRuntimeWalletPrivateKey(walletPrivateKeyInput);
2737
+ if (wpk) {
2738
+ try {
2739
+ const walletSig = signChallengeLocally(challenge.challenge, wpk);
2740
+ const tokens = await doSessionStart(
2741
+ orchestratorUrl,
2742
+ pluginConfig.apiKey,
2743
+ challenge.challengeId,
2744
+ challenge.walletPublicKey || pluginConfig.walletPublicKey,
2745
+ walletSig,
2746
+ );
2747
+ const totalMs = Date.now() - t4Start;
2748
+ currentAccessToken = tokens.accessToken;
2749
+ currentRefreshToken = tokens.refreshToken;
2750
+ printSuccess(` OK (${totalMs}ms) — challenge + wallet proof succeeded`);
2751
+ results.push({ test: "challenge_flow", status: "ok", ms: totalMs, walletProof: true });
2752
+ } catch (sigErr) {
2753
+ printError(` FAIL (wallet signing): ${sigErr.message}`);
2754
+ results.push({ test: "challenge_flow", status: "fail", ms: Date.now() - t4Start, error: sigErr.message });
2755
+ }
2756
+ } else {
2757
+ printWarn(` PARTIAL (${ms}ms) — challenge OK but wallet proof needed and TRADERCLAW_WALLET_PRIVATE_KEY not available`);
2758
+ printWarn(" Set TRADERCLAW_WALLET_PRIVATE_KEY env var or pass --wallet-private-key to test fully");
2759
+ results.push({ test: "challenge_flow", status: "partial", ms, walletProofRequired: true, keyAvailable: false });
2760
+ }
2761
+ } else {
2762
+ const tokens = await doSessionStart(orchestratorUrl, pluginConfig.apiKey, challenge.challengeId);
2763
+ const totalMs = Date.now() - t4Start;
2764
+ currentAccessToken = tokens.accessToken;
2765
+ currentRefreshToken = tokens.refreshToken;
2766
+ printSuccess(` OK (${totalMs}ms) — challenge flow succeeded (no wallet proof needed)`);
2767
+ results.push({ test: "challenge_flow", status: "ok", ms: totalMs, walletProof: false });
2768
+ }
2769
+ } catch (err) {
2770
+ printError(` FAIL: ${err.message}`);
2771
+ results.push({ test: "challenge_flow", status: "fail", ms: Date.now() - t4Start, error: err.message });
2772
+ }
2773
+
2774
+ // --- Test 5: Persist rotated tokens back to sidecar ---
2775
+ print(" [5/5] Persist tokens to sidecar...");
2776
+ try {
2777
+ if (currentRefreshToken && currentAccessToken) {
2778
+ mkdirSync(dataDir, { recursive: true });
2779
+ const payload = {
2780
+ refreshToken: currentRefreshToken,
2781
+ accessToken: currentAccessToken,
2782
+ accessTokenExpiresAt: Date.now() + 900_000,
2783
+ walletPublicKey: pluginConfig.walletPublicKey || undefined,
2784
+ };
2785
+ const tmp = `${sessionTokensPath}.${process.pid}.${Date.now()}.tmp`;
2786
+ writeFileSync(tmp, JSON.stringify(payload, null, 2) + "\n", "utf-8");
2787
+ const { renameSync } = await import("fs");
2788
+ renameSync(tmp, sessionTokensPath);
2789
+ printSuccess(` OK — written to ${sessionTokensPath}`);
2790
+ results.push({ test: "persist_sidecar", status: "ok" });
2791
+
2792
+ pluginConfig.refreshToken = currentRefreshToken;
2793
+ removeLegacyWalletPrivateKey(pluginConfig);
2794
+ setPluginConfig(config, pluginConfig);
2795
+ writeConfig(config);
2796
+ printSuccess(" Config updated with latest refresh token");
2797
+ } else {
2798
+ printWarn(" SKIP — no valid tokens to persist");
2799
+ results.push({ test: "persist_sidecar", status: "skip" });
2800
+ }
2801
+ } catch (err) {
2802
+ printError(` FAIL: ${err.message}`);
2803
+ results.push({ test: "persist_sidecar", status: "fail", error: err.message });
2804
+ }
2805
+
2806
+ // --- Summary ---
2807
+ print("\n" + "=".repeat(50));
2808
+ const passed = results.filter((r) => r.status === "ok").length;
2809
+ const failed = results.filter((r) => r.status === "fail").length;
2810
+ const partial = results.filter((r) => r.status === "partial" || r.status === "expired" || r.status === "skip").length;
2811
+
2812
+ if (failed === 0 && passed > 0) {
2813
+ printSuccess(`\n ALL TESTS PASSED (${passed}/${results.length})`);
2814
+ } else if (failed > 0) {
2815
+ printError(`\n ${failed} FAILED, ${passed} passed, ${partial} skipped/partial`);
2816
+ } else {
2817
+ printWarn(`\n ${passed} passed, ${partial} skipped/partial`);
2818
+ }
2819
+
2820
+ print("\n Build-and-test workflow (no reinstall):");
2821
+ printInfo(" cd /path/to/plugin && npm run build");
2822
+ printInfo(" npm link");
2823
+ printInfo(" sudo systemctl restart openclaw");
2824
+ printInfo(" traderclaw test-session");
2825
+ print("");
2826
+
2827
+ if (failed > 0) process.exit(1);
2828
+ }
2829
+
2599
2830
  function printHelp() {
2600
2831
  print(`
2601
2832
  TraderClaw V1 CLI v${VERSION}
@@ -2612,6 +2843,7 @@ Commands:
2612
2843
  logout Revoke current session and clear tokens
2613
2844
  status Check connection health and wallet status
2614
2845
  config View and manage configuration
2846
+ test-session Test session auth flow (refresh, rotation, challenge) without reinstalling
2615
2847
 
2616
2848
  Setup options:
2617
2849
  --api-key, -k API key (skip interactive prompt)
@@ -2658,6 +2890,8 @@ Examples:
2658
2890
  traderclaw status
2659
2891
  traderclaw config show
2660
2892
  traderclaw config set apiTimeout 60000
2893
+ traderclaw test-session
2894
+ traderclaw test-session --wallet-private-key <base58_key>
2661
2895
  `);
2662
2896
  }
2663
2897
 
@@ -2703,6 +2937,9 @@ async function main() {
2703
2937
  case "config":
2704
2938
  await cmdConfig(args.slice(1));
2705
2939
  break;
2940
+ case "test-session":
2941
+ await cmdTestSession(args.slice(1));
2942
+ break;
2706
2943
  default:
2707
2944
  printError(`Unknown command: ${command}`);
2708
2945
  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";
@@ -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.37",
3
+ "version": "1.0.39",
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",