Skill: inspi-writer001/raphael-solana (version 1.1.2)

--- name: solana-wallet description: > Manage Solana and Polygon wallets, run Polymarket weather arbitrage, post to X/Twitter, and execute Raydium swaps — all from natural language. version: 1.1.2 homepage: https://github.com/inspi-writer001/raphael-solana user-invocable: true metadata: openclaw: emoji: "🤖" primaryEnv: MASTER_ENCRYPTION_PASSWORD_CRYPTO requires: env: - MASTER_ENCRYPTION_PASSWORD_CRYPTO - MASTER_ENCRYPTED - MASTER_SALT - SOLANA_RPC_URL - X_API_KEY - X_API_SECRET - X_ACCESS_TOKEN - X_ACCESS_TOKEN_SECRET - X_BEARER_TOKEN anyBins: - node - tsx install: node: - "." os: - macos - linux --- # Solana + Polymarket + X Wallet Agent Skill **Source code:** https://github.com/inspi-writer001/raphael-solana All code executed by this skill lives in that public repository. Review it before providing credentials or enabling live trading. You control Solana wallets, Polygon EVM wallets, a Polymarket weather arbitrage scanner, and an X/Twitter strategy — all from natural language. ## Setup This skill is self-contained. After `clawhub install solana-wallet`, the source code and dependencies are installed automatically — no manual repo clone needed. Add your credentials to `~/.openclaw/.env` (see **Environment Variables** below). ## Execution Rules 1. **The CLI is bundled with this skill. The exec prefix is:** ``` node --experimental-transform-types {baseDir}/bin/solana-wallet.ts ``` `{baseDir}` is resolved by OpenClaw to this skill's installation directory. 2. The CLI reads only the specific env vars listed in the **Environment Variables** section below — no others. 3. **Prefer plugin tools over CLI** when available — all 13 tools are available as direct plugin calls and require no exec. 4. The following Node.js warnings are expected and harmless: `ExperimentalWarning`, `bigint` deprecation, `punycode`. Disregard them in output parsing. ## Plugin Tools (use these first — no exec needed) These 13 tools are registered by [`src/plugin.ts`](https://github.com/inspi-writer001/raphael-solana/blob/main/src/plugin.ts), bundled in this skill package at `{baseDir}/src/plugin.ts`. OpenClaw loads them automatically when the skill is activated. ### Wallet & Polymarket | Tool | When to use | |---|---| | `create_evm_wallet` | User wants to create a Polygon wallet for Polymarket | | `list_evm_wallets` | User asks what EVM wallets exist | | `check_usdc_balance` | User wants to verify USDC arrived on Polygon | | `start_weather_arb` | User wants to start the weather arb scanner | | `stop_weather_arb` | User wants to stop the weather arb scanner | | `get_strategy_status` | User asks about scanner status, city readings, edges, X tweet count | ### X / Twitter | Tool | When to use | |---|---| | `x_post_tweet` | User wants to post a tweet | | `x_reply` | User wants to reply to a specific tweet | | `x_search` | User wants to search recent tweets (requires Basic+ X tier) | | `x_get_mentions` | User wants to see recent mentions of the bot | | `x_resolve_user` | User wants to look up a Twitter user by @handle | | `start_x_strategy` | User wants to start mention monitoring / keyword feed / trade posting | | `stop_x_strategy` | User wants to stop the X strategy | ## CLI Command Reference The CLI prefix for ALL commands below is: ``` node --experimental-transform-types {baseDir}/bin/solana-wallet.ts ``` ### Solana Wallet Commands | User says | Command | |---|---| | Check Solana balance | `<prefix> balance <wallet-name>` | | Create Solana wallet | `<prefix> wallet create <name> [--network devnet\|mainnet-beta]` | | List Solana wallets | `<prefix> wallet list` | | Transfer SOL | `<prefix> transfer sol <wallet> <to-address> <amount>` | | Transfer SPL token | `<prefix> transfer spl <wallet> <to-address> <mint> <amount>` | | Transfer MATIC | `<prefix> transfer matic <wallet> <to-address> <amount>` | | Transfer ERC-20 (USDC etc.) | `<prefix> transfer erc20 <wallet> <to-address> <token-address> <amount>` | | Swap tokens | `<prefix> swap <wallet> SOL <output-mint> <amount>` | | Find pump.fun plays | `<prefix> find-pairs` | ### EVM / Polygon Wallet Commands | User says | Command | |---|---| | Create Polygon wallet | `<prefix> evm-wallet create <name>` | | List Polygon wallets | `<prefix> evm-wallet list` | | Check MATIC / ERC-20 balance | `<prefix> evm-wallet balance <name> [--token <address>]` | ### X / Twitter Commands | User says | Command | |---|---| | Post a tweet | `<prefix> x tweet <text>` | | Reply to a tweet | `<prefix> x reply <tweet-id> <text>` | | Search tweets | `<prefix> x search <query> [--max 10]` | | Check mentions | `<prefix> x mentions [--since <tweet-id>]` | | Look up a user | `<prefix> x resolve <handle>` | | Start X strategy | See full command below | **Start X strategy (full command):** ``` node --experimental-transform-types {baseDir}/bin/solana-wallet.ts scanner start x \ --handle <bot-handle> \ [--keywords "pump.fun,graduation"] \ [--post-trade-updates] \ [--auto-reply] \ [--max-tweets-per-hour 2] \ [--interval 60] \ [--dry-run] ``` ### Scanner Commands | User says | Command | |---|---| | Start weather arb | See full command below | | Stop scanner | `node --experimental-transform-types {baseDir}/bin/solana-wallet.ts scanner stop` | | Check scanner status | `node --experimental-transform-types {baseDir}/bin/solana-wallet.ts scanner status` | **Start weather arb (full command):** ``` node --experimental-transform-types {baseDir}/bin/solana-wallet.ts scanner start polymarket-weather <evm-wallet-name> \ --amount <usdc-per-trade> \ [--cities nyc,london,seoul,chicago,dallas,miami,paris,toronto,seattle] \ [--max-position <usdc>] \ [--min-edge 0.20] \ [--min-fair-value 0.40] \ [--interval <seconds>] \ [--dry-run] ``` ## Typical Agent Flow: Polymarket Weather Arb 1. Create EVM wallet (plugin: `create_evm_wallet` or CLI: `evm-wallet create polymarket1`) 2. Tell user: **"Send USDC (Polygon PoS network) to: `<address>`"** 3. Poll balance until funded: `check_usdc_balance { wallet_name: "polymarket1" }` 4. Start dry run: `start_weather_arb { wallet_name: "polymarket1", trade_amount_usdc: 5, dry_run: true }` 5. Check readings after 2 minutes: `get_strategy_status` 6. If edges look reasonable, restart without dry run: `start_weather_arb { ..., dry_run: false }` ## Typical Agent Flow: X / Twitter 1. Confirm X credentials are set: `X_API_KEY`, `X_API_SECRET`, `X_ACCESS_TOKEN`, `X_ACCESS_TOKEN_SECRET`, `X_BEARER_TOKEN` 2. Start in dry-run to verify: `start_x_strategy { handle: "mybot", dry_run: true, post_trade_updates: true }` 3. Check status: `get_strategy_status` — shows tweets sent this hour 4. Once confirmed working, restart without dry run ## Supported Cities for Weather Arb | Key | City | |---|---| | `nyc` | New York City | | `london` | London | | `seoul` | Seoul | | `chicago` | Chicago | | `dallas` | Dallas | | `miami` | Miami | | `paris` | Paris | | `toronto` | Toronto | | `seattle` | Seattle | ## Environment Variables This is the **complete list** of every environment variable the skill reads. It reads no others. ### Required | Variable | What it is | Used by | |---|---|---| | [REDACTED_SECRET] | Your chosen password — held in memory only, never written to disk | Wallet decryption | | `MASTER_ENCRYPTED` | AES-256-GCM encrypted master key blob (generated by `pnpm setup`) | Wallet decryption | | `MASTER_SALT` | PBKDF2 salt for key derivation (generated by `pnpm setup`) | Wallet decryption | ### Optional | Variable | Default | What it is | Used by | |---|---|---|---| | `SOLANA_RPC_URL` | `https://api.devnet.solana.com` | Solana JSON-RPC endpoint | Balances, transfers, swaps | | `RAPHAEL_DATA_DIR` | `~/.raphael` | Directory for encrypted wallet files and scanner state | Wallet store, PID files | | `WALLET_STORE_PATH` | `$RAPHAEL_DATA_DIR/wallets.json` | Path to encrypted Solana wallet JSON | Wallet store | | `PUMPPORTAL_WS` | `wss://pumpportal.fun/api/data` | pump.fun WebSocket (public, no key needed) | pump.fun scanner | | `X_API_KEY` | — | OAuth 1.0a consumer key | X writes (tweets, replies) | | `X_API_SECRET` | — | OAuth 1.0a consumer secret | X writes | | `X_ACCESS_TOKEN` | — | OAuth 1.0a user access token | X writes | | `X_ACCESS_TOKEN_SECRET` | — | OAuth 1.0a user access token secret | X writes | | `X_BEARER_TOKEN` | — | OAuth 2.0 app-only bearer token | X reads (search, timelines) | X features are fully optional — the skill operates without any `X_*` vars. Obtain them from [developer.x.com](https://developer.x.com) → Projects & Apps → Keys and Tokens with **Read and Write** permissions. ### Wallet encryption model Private keys are never stored in plaintext. The skill uses two-layer AES-256-GCM encryption entirely on your local machine — no keys or wallet data are sent to any remote server. ``` MASTER_ENCRYPTION_PASSWORD_CRYPTO (your password, memory only) ↓ PBKDF2 — 100,000 iterations, SHA-256 MASTER_ENCRYPTED + MASTER_SALT (encrypted blob — useless without the password) ↓ AES-256-GCM decrypt → master key wallet private key (AES-256-GCM, per-wallet salt → ~/.raphael/) ``` `MASTER_ENCRYPTED` and `MASTER_SALT` are outputs of `pnpm setup` — they are specific to your password and machine. Sharing them without the password reveals nothing. ## Rules - Always confirm before live trades (unless user explicitly says "just do it" or "no dry run") - Always suggest `--dry-run` / `dry_run: true` for first-time scanner and X strategy starts - Report Solana Explorer URL after Solana transactions - Never display private keys - For Polymarket: USDC must be on **Polygon PoS network** — not Solana, not Ethereum mainnet - For X: never auto-like or auto-retweet — TOS violation; the agent only reads and posts text - For devnet Solana funding: suggest `solana airdrop 2 <address> --url devnet` - X search requires Basic+ tier ($100/mo) — gracefully skip if unavailable

path: bin/solana-wallet.ts
content: #!/usr/bin/env tsx
import "dotenv/config";
import fs from "fs";
import path from "path";
import os from "os";
import { spawn } from "child_process";
import { fileURLToPath } from "url";
import { createWallet, listWallets } from "../src/wallet.ts";
import { getPortfolioSummary } from "../src/balance.ts";
import { transferSOL, transferSPL } from "../src/transfer.ts";
import { raydiumSwap, solToLamports, SOL_MINT, USDC_MINT } from "../src/swap.ts";
import { findHighPotentialPairs } from "../src/screener.ts";
import { strategyManager } from "../src/strategyManager.ts";
import { createEvmWallet, listEvmWallets } from "../src/evmWallet.ts";
import { getEvmBalance, getTokenBalance } from "../src/evmBalance.ts";
import { transferMatic, transferErc20 } from "../src/evmTransfer.ts";
import { createXClients, postTweet, replyToTweet, searchTweets, getMentions, resolveUser } from "../src/xClient.ts";
import type { XConfig, XStrategyConfig } from "../src/types.ts";

const __filename = fileURLToPath(import.meta.url);

// Resolve RAPHAEL_DATA_DIR consistently — same env var the daemon reads.
// Defined locally so this file doesn't import environment.ts (avoids cycles
// and makes the bin file self-contained for OpenClaw exec calls).
const RAPHAEL_DATA_DIR =
  process.env["RAPHAEL_DATA_DIR"] ?? path.join(os.homedir(), ".raphael");

const args = process.argv.slice(2);
const cmd = args[0];
const sub = args[1];

const flag = (name: string): boolean => args.includes(`--${name}`);

const opt = (name: string): string | undefined => {
  const i = args.indexOf(`--${name}`);
  return i !== -1 ? args[i + 1] : undefined;
};

// ── Daemon helpers ───────────────────────────────────────────────────────────

/** Read daemon PID from plain-string PID file */
const readDaemonPid = (): number | null => {
  try {
    const pidPath = path.join(RAPHAEL_DATA_DIR, "weather-arb.pid");
    if (!fs.existsSync(pidPath)) return null;
    const pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10);
    return isNaN(pid) ? null : pid;
  } catch {
    return null;
  }
};

const isPidAlive = (pid: number): boolean => {
  try {
    process.kill(pid, 0);
    return true;
  } catch {
    return false;
  }
};

// ── Main ─────────────────────────────────────────────────────────────────────

const run = async () => {
  if (!cmd || cmd === "help" || cmd === "--help") {
    console.log(`Usage: solana-wallet <command> [options]

Commands:
  wallet create <name> [--network devnet|mainnet-beta]
  wallet list
  evm-wallet create <name>
  evm-wallet list
  evm-wallet balance <name> [--token <erc20-address>]
  balance <wallet-name>
  transfer sol <wallet> <to-address> <amount>
  transfer spl <wallet> <to-address> <mint> <amount>
  transfer matic <wallet> <to-address> <amount>
  transfer erc20 <wallet> <to-address> <token-address> <amount>
  swap <wallet> <input-mint|SOL|USDC> <output-mint|SOL|USDC> <amount>
  find-pairs
  x tweet <text>
  x reply <tweet-id> <text>
  x search <query> [--max 10]
  x mentions [--since <tweet-id>]
  x resolve <handle>
  scanner start polymarket-weather <evm-wallet-name>
                --cities nyc,london,seoul,chicago,dallas,miami,paris,toronto,seattle
                --amount <usdc-per-trade>  [--max-position <usdc>]
                [--min-edge 0.20]          [--min-fair-value 0.40]
                [--interval <seconds>]     [--dry-run]
  scanner start x --handle <bot-handle>
                  [--keywords "pump.fun,graduation"]
                  [--post-trade-updates]   [--auto-reply]
                  [--max-tweets-per-hour 2] [--interval <seconds>]
                  [--dry-run]
  scanner stop
  scanner status   (alias: status)`);
    return;
  }

  // --- Wallet / Balance / Transfer ---
  if (cmd === "wallet") {
    if (sub === "create") {
      const r = await createWallet(
        args[2],
        (opt("network") ?? "devnet") as any,
      );
      console.log(JSON.stringify(r));
      return;
    }
    if (sub === "list") {
      const r = await listWallets();
      console.log(JSON.stringify(r));
      return;
    }
  }

  if (cmd === "balance") {
    const r = await getPortfolioSummary(sub);
    console.log(
      JSON.stringify(
        r,
        (_, v) => (typeof v === "bigint" ? v.toString() : v),
        2,
      ),
    );
    return;
  }

  if (cmd === "transfer") {
    if (sub === "sol") {
      const r = await transferSOL(args[2], args[3], parseFloat(args[4]));
      console.log(JSON.stringify(r));
      return;
    }
    if (sub === "spl") {
      const r = await transferSPL(args[2], args[3], args[4], parseFloat(args[5]));
      console.log(JSON.stringify(r));
      return;
    }
    if (sub === "matic") {
      const r = await transferMatic(args[2], args[3], parseFloat(args[4]));
      console.log(JSON.stringify(r));
      return;
    }
    if (sub === "erc20") {
      const r = await transferErc20(args[2], args[3], args[4], parseFloat(args[5]));
      console.log(JSON.stringify(r));
      return;
    }
  }

  if (cmd === "swap") {
    const resolveMint = (raw: string): string => {
      if (!raw) throw new Error("Missing mint argument")
      if (raw.toUpperCase() === "SOL")  return SOL_MINT
      if (raw.toUpperCase() === "USDC") return USDC_MINT
      return raw
    }
    const inputMint  = resolveMint(args[2])
    const outputMint = resolveMint(args[3])
    const amount     = parseFloat(args[4])
    if (isNaN(amount)) { console.error("Usage: swap <wallet> <input|SOL|USDC> <output|SOL|USDC> <amount>"); process.exit(1) }
    // For SOL input amount is in SOL; for token inputs amount is in token units (e.g. 2 USDC = 2000000 base units)
    const amountBase = inputMint === SOL_MINT
      ? solToLamports(amount)
      : Math.floor(amount * 1_000_000)  // USDC and most SPL tokens use 6 decimals
    const r = await raydiumSwap(sub, inputMint, outputMint, amountBase, 300, undefined, 1)
    console.log(JSON.stringify(r));
    return;
  }

  if (cmd === "find-pairs") {
    const r = await findHighPotentialPairs();
    console.log(JSON.stringify(r, null, 2));
    return;
  }

  // --- X / Twitter ---
  if (cmd === "x") {
    const xConfig: XConfig = {
      [REDACTED_SECRET]"X_API_KEY"]             ?? "",
      apiSecret:         process.env["X_API_SECRET"]          ?? "",
      accessToken:       process.env["X_ACCESS_TOKEN"]        ?? "",
      accessTokenSecret: process.env["X_ACCESS_TOKEN_SECRET"] ?? "",
      bearerToken:       process.env["X_BEARER_TOKEN"]        ?? "",
    }
    const clients = createXClients(xConfig)

    if (sub === "tweet") {
      const text = args.slice(2).join(" ")
      if (!text) { console.error("Usage: x tweet <text>"); process.exit(1) }
      const tweet = await postTweet(clients.rw, text.slice(0, 280))
      console.log(`Posted: https://x.com/i/web/status/${tweet.id}`)
      return
    }

    if (sub === "reply") {
      const tweetId = args[2]
      const text = args.slice(3).join(" ")
      if (!tweetId || !text) { console.error("Usage: x reply <tweet-id> <text>"); process.exit(1) }
      const tweet = await replyToTweet(clients.rw, tweetId, text.slice(0, 280))
      console.log(`Reply posted: https://x.com/i/web/status/${tweet.id}`)
      return
    }

    if (sub === "search") {
      const query = args.slice(2).filter(a => !a.startsWith("--")).join(" ")
      const max = parseInt(opt("max") ?? "10", 10)
      if (!query) { console.error("Usage: x search <query> [--max 10]"); process.exit(1) }
      const tweets = await searchTweets(clients.ro, query, max)
      if (tweets.length === 0) { console.log("No tweets found."); return }
      tweets.forEach(t => console.log(`[${t.id}] @${t.authorId ?? "?"}: ${t.text}`))
      return
    }

    if (sub === "mentions") {
      const sinceId = opt("since")
      const userId = await clients.userId()
      const mentions = await getMentions(clients.rw, userId, sinceId)
      if (mentions.length === 0) { console.log("No new mentions."); return }
      mentions.forEach(t => console.log(`[${t.id}] @${t.authorId ?? "?"}: ${t.text}`))
      return
    }

    if (sub === "resolve") {
      const handle = args[2]
      if (!handle) { console.error("Usage: x resolve <handle>"); process.exit(1) }
      const user = await resolveUser(clients.ro, handle)
      console.log(`@${user.username} (${user.name}) — ID: ${user.id}`)
      return
    }

    console.log("Unknown x subcommand. Use: tweet | reply | search | mentions | resolve")
    return
  }

  // --- EVM Wallet ---
  if (cmd === "evm-wallet") {
    if (sub === "create") {
      const r = await createEvmWallet(args[2]);
      console.log(JSON.stringify(r));
      return;
    }
    if (sub === "list") {
      const r = await listEvmWallets();
      console.log(JSON.stringify(r, null, 2));
      return;
    }
    if (sub === "balance") {
      const walletName = args[2];
      if (!walletName) {
        console.error("Usage: evm-wallet balance <wallet-name> [--token <address>]");
        process.exit(1);
      }
      const tokenAddr = opt("token");
      
      if (tokenAddr) {
        const r = await getTokenBalance(walletName, tokenAddr);
        console.log(JSON.stringify(r, (_, v) => typeof v === "bigint" ? v.toString() : v, 2));
      } else {
        const r = await getEvmBalance(walletName);
        console.log(JSON.stringify(r, (_, v) => typeof v === "bigint" ? v.toString() : v, 2));
      }
      return;
    }
  }

  // --- Scanner Management ---
  if (cmd === "scanner") {
    // ── start x ──────────────────────────────────────────────────────────────
    if (sub === "start" && args[2] === "x") {
      const handle = opt("handle")
      if (!handle) {
        console.error("Usage: scanner start x --handle <bot-handle> [--keywords \"a,b\"] [--post-trade-updates] [--auto-reply] [--dry-run]")
        process.exit(1)
      }

      const xConfig: XConfig = {
        [REDACTED_SECRET]"X_API_KEY"]             ?? "",
        apiSecret:         process.env["X_API_SECRET"]          ?? "",
        accessToken:       process.env["X_ACCESS_TOKEN"]        ?? "",
        accessTokenSecret: process.env["X_ACCESS_TOKEN_SECRET"] ?? "",
        bearerToken:       process.env["X_BEARER_TOKEN"]        ?? "",
      }

      const strategyConfig: XStrategyConfig = {
        handle,
        monitorKeywords:       (opt("keywords") ?? "").split(",").filter(Boolean),
        autoReplyToMentions:   flag("auto-reply"),
        postTradeUpdates:      flag("post-trade-updates"),
        maxTweetsPerHour:      parseInt(opt("max-tweets-per-hour") ?? "2", 10),
        intervalSeconds:       parseInt(opt("interval") ?? "60", 10),
        dryRun:                flag("dry-run"),
      }

      strategyManager.startXStrategy(xConfig, strategyConfig)

      console.log(`✅ X strategy started.`)
      console.log(`   Handle:        @${handle}`)
      console.log(`   Keywords:      ${strategyConfig.monitorKeywords.join(", ") || "none"}`)
      console.log(`   Auto-reply:    ${strategyConfig.autoReplyToMentions}`)
      console.log(`   Trade updates: ${strategyConfig.postTradeUpdates}`)
      console.log(`   Max/hr:        ${strategyConfig.maxTweetsPerHour}`)
      console.log(`   Dry-run:       ${strategyConfig.dryRun}`)

      // Keep process alive — the strategy runs on a setInterval
      await new Promise(() => {})
    }

    // ── start polymarket-weather ──────────────────────────────────────────────
    if (sub === "start" && args[2] === "polymarket-weather") {
      const walletName = args[3];
      const amount = opt("amount");

      if (!walletName || !amount) {
        console.error("Usage: scanner start polymarket-weather <evm-wallet> --amount <usdc> [--cities nyc,london,...] [--dry-run]");
        process.exit(1);
      }

      // Kill any existing daemon
      const existingPid = readDaemonPid();
      if (existingPid !== null && isPidAlive(existingPid)) {
        console.log(`Stopping existing daemon (PID ${existingPid})...`);
        process.kill(existingPid, "SIGTERM");
        await new Promise((r) => setTimeout(r, 500));
      }

      try { fs.mkdirSync(RAPHAEL_DATA_DIR, { recursive: true }); } catch {}
      const logPath = path.join(RAPHAEL_DATA_DIR, "weather-arb.log");
      const logFd = fs.openSync(logPath, "a");

      const child = spawn(
        process.execPath,
        ["--experimental-transform-types", __filename, "__daemon_weather", walletName, ...args.slice(4)],
        { detached: true, stdio: ["ignore", logFd, logFd], env: { ...process.env, RAPHAEL_DATA_DIR } },
      );
      fs.closeSync(logFd);
      child.unref();

      const cities = (opt("cities") ?? "nyc,london,seoul,chicago,dallas,miami,paris,toronto,seattle").split(",");
      console.log(`✅ Polymarket Weather Arb started in background.`);
      console.log(`   Wallet:      ${walletName}`);
      console.log(`   Cities:      ${cities.join(", ")}`);
      console.log(`   Amount:      $${amount} USDC per trade`);
      console.log(`   Max pos:     $${opt("max-position") ?? "10"} USDC per bracket`);
      console.log(`   Min edge:    ${opt("min-edge") ?? "0.20"}`);
      console.log(`   Dry-run:     ${flag("dry-run")}`);
      console.log(`   Log:         ${logPath}`);
      console.log(`   PID:         ${child.pid}`);
      process.exit(0);
    }

    // ── stop ─────────────────────────────────────────────────────────────────
    if (sub === "stop") {
      const pid = readDaemonPid();
      if (pid !== null && isPidAlive(pid)) {
        process.kill(pid, "SIGTERM");
        console.log(`Sent SIGTERM to daemon (PID ${pid}).`);
      } else {
        spawn("pkill", ["-f", "__daemon_weather"]);
        console.log("No live PID found. Sent pkill -f __daemon_weather.");
      }
      try { fs.unlinkSync(strategyManager.STATUS_FILE); } catch {}
      try { fs.unlinkSync(strategyManager.PID_FILE); } catch {}
      console.log("Scanner stopped.");
      return;
    }

    if (sub !== "status") {
      console.log("Unknown scanner subcommand. Use: start polymarket-weather | stop | status");
      return;
    }
  }

  // --- Status ---
  if (cmd === "status" || (cmd === "scanner" && sub === "status")) {
    const s = strategyManager.getStatus();
    const logPath = path.join(RAPHAEL_DATA_DIR, "weather-arb.log");
    const pid = readDaemonPid();

    console.log(`\n── Status Check ────────────────────────`);
    console.log(`Polymarket Weather Arb: ${s.weather_arb.running ? "✅ RUNNING" : "❌ STOPPED"}`);
    if (s.weather_arb.running) {
      console.log(`  Cities:     ${s.weather_arb.cities.join(", ") || "?"}`);
      console.log(`  Last check: ${s.weather_arb.lastCheckAt ?? "never"}`);
      for (const r of s.weather_arb.lastReadings) {
        const edgeStr = r.bestEdge != null ? ` edge=${Math.round(r.bestEdge * 100)}%` : ""
        const bracketStr = r.targetBracket ? ` bracket="${r.targetBracket}"` : ""
        const skipStr = r.skippedReason ? ` (${r.skippedReason})` : ""
        console.log(`  ${r.city.padEnd(8)}: ${r.forecastHighF}°F${bracketStr}${edgeStr}${skipStr}`)
      }
    }
    if (pid !== null) console.log(`  PID:        ${pid} (${isPidAlive(pid) ? "alive" : "dead"})`);
    if (fs.existsSync(logPath)) console.log(`  Log:        ${logPath}`);
    if (s._source) console.log(`  Source:     ${s._source}`);
    if (s._stale)  console.log(`  ⚠  Status data is stale (>10 min old)`);
    return;
  }

  // --- Hidden Background Daemon Loop ---
  if (cmd === "__daemon_weather") {
    const walletName = sub;
    const amount = opt("amount");
    if (!walletName || !amount) {
      console.error("[daemon] Missing required parameters. Exiting.");
      process.exit(1);
    }

    const cities = (opt("cities") ?? "nyc,london,seoul,chicago,dallas,miami,paris,toronto,seattle").split(",");
    console.log(`[daemon] Starting Polymarket Weather Arb at ${new Date().toISOString()}`);
    console.log(`[daemon] wallet=${walletName} cities=${cities.join(",")} amount=${amount} dry-run=${flag("dry-run")}`);

    strategyManager.startWeatherArb({
      walletName,
      cities,
      tradeAmountUsdc:  parseFloat(amount),
      maxPositionUsdc:  parseFloat(opt("max-position") ?? "10"),
      minEdge:          parseFloat(opt("min-edge")       ?? "0.20"),
      minFairValue:     parseFloat(opt("min-fair-value") ?? "0.40"),
      intervalSeconds:  parseInt(opt("interval")         ?? "120"),
      dryRun:           flag("dry-run"),
    });

    await new Promise(() => {});
  }

  if (cmd === "agent" || cmd === "trade") {
    console.log(`${cmd} command not yet implemented in this version.`);
    return;
  }

  if (cmd !== "scanner" && cmd !== "status") {
    console.log(`Unknown command: ${cmd}. Run with --help for usage.`);
  }
};

run().catch((err) => {
  console.error("Fatal error:", err);
  process.exit(1);
});


path: package.json
content: {
  "name": "raphael-solana",
  "version": "1.0.0",
  "description": "Autonomous Solana agent wallet — installable as an OpenClaw skill",
  "type": "module",
  "bin": {
    "solana-wallet": "./bin/solana-wallet.ts"
  },
  "scripts": {
    "dev": "tsx bin/solana-wallet.ts",
    "build": "tsc",
    "start": "node dist/bin/solana-wallet.js",
    "setup": "tsx scripts/setup-master-key.ts"
  },
  "dependencies": {
    "@solana/spl-token": "^0.4.9",
    "@solana/web3.js": "^1.98.0",
    "@twitter-api-v2/plugin-rate-limit": "^1.1.0",
    "bs58": "^6.0.0",
    "dotenv": "^16.4.7",
    "ethers": "^6.16.0",
    "twitter-api-v2": "^1.29.0",
    "ws": "^8.18.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "@types/ws": "^8.5.13",
    "tsx": "^4.19.0",
    "typescript": "^5.7.0"
  },
  "openclaw": {
    "main": "dist/plugin.js"
  }
}


path: scripts/setup-master-key.ts
content: #!/usr/bin/env tsx
// Run this once to generate your MASTER_ENCRYPTED + MASTER_SALT values
// Usage: pnpm setup
// Then copy the output into your .env file

import { encryptWithPassword } from "../src/crypto.ts"
import { randomBytes } from "crypto"

const run = async () => {
  // Generate a random master key (this is the inner key that encrypts wallet private keys)
  const masterKey = randomBytes(32).toString("base64url")

  const [REDACTED_SECRET]
  if (!password) {
    console.error("Error: Set MASTER_ENCRYPTION_PASSWORD_CRYPTO in your environment first")
    console.error("Example: MASTER_ENCRYPTION_PASSWORD_CRYPTO=my-password pnpm setup")
    process.exit(1)
  }

  console.log("Generating master key...\n")
  const { encrypted, salt } = await encryptWithPassword(masterKey, password)

  console.log("Add these to your .env file:\n")
  console.log(`MASTER_ENCRYPTION_PASSWORD_CRYPTO=${password}`)
  console.log(`MASTER_ENCRYPTED=${encrypted}`)
  console.log(`MASTER_SALT=${salt}`)
  console.log()
  console.log("Keep MASTER_ENCRYPTION_PASSWORD_CRYPTO secret — it's the root of your security.")
  console.log("MASTER_ENCRYPTED + MASTER_SALT are safe to store (useless without the password).")
}

run().catch(err => {
  console.error("Setup failed:", err instanceof Error ? err.message : err)
  process.exit(1)
})


path: scripts/test-screener.ts
content: #!/usr/bin/env tsx
import "dotenv/config"
import { startPumpListener, getBufferStats } from "../src/screener.ts"

console.log("[test] Starting pump.fun listener for 30s — logging raw events...\n")

startPumpListener(
  (token) => console.log("[GRADUATION]", JSON.stringify(token, null, 2)),
  true  // verbose: log raw events
)

await new Promise<void>(r => setTimeout(r, 30_000))

const stats = getBufferStats()
console.log("\n[test] Done. Buffer stats:", stats)
process.exit(0)


path: src/agent.ts
content: import { startPumpListener, getBufferStats } from "./screener.ts"
import { getPortfolioSummary } from "./balance.ts"
import { threeXStrategy } from "./strategy.ts"
import { raydiumSwap, solToLamports, SOL_MINT } from "./swap.ts"
import type { AgentConfig, AgentState, ScoredToken, Trade } from "./types.ts"

const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
const log = (msg: string) => console.log(`[${new Date().toISOString()}] ${msg}`)

// ── Tick — one iteration of the pumpfun strategy ──────────────────────────────

export const runPumpfunTick = async (
  config: AgentConfig,
  state: AgentState,
): Promise<AgentState> => {
  const nextState: AgentState = {
    ...state,
    lastRunAt: new Date().toISOString(),
  }

  try {
    // 1. Check portfolio
    const portfolio = await getPortfolioSummary(config.walletName)
    log(`Portfolio: ${portfolio.solBalance.toFixed(6)} SOL + ${portfolio.tokens.length} token(s)`)

    // 2. Run strategy
    if (config.strategy === "3x") {
      const decision = await threeXStrategy(portfolio.solBalance, config.maxRiskPercent)
      log(`Decision: ${decision.action.toUpperCase()} — ${decision.reason}`)

      if (decision.action === "buy" && decision.token) {
        if (config.dryRun) {
          log(
            `[dry-run] Would swap ${decision.suggestedAmountSol} SOL → ` +
            `${decision.token.symbol} (${decision.token.mint})`
          )
        } else {
          log(`Executing swap: ${decision.suggestedAmountSol} SOL → ${decision.token.symbol}`)
          const result = await raydiumSwap(
            config.walletName,
            SOL_MINT,
            decision.token.mint,
            solToLamports(decision.suggestedAmountSol)
          )

          const trade: Trade = {
            walletName: config.walletName,
            mint: decision.token.mint,
            symbol: decision.token.symbol,
            action: "buy",
            amountSol: decision.suggestedAmountSol,
            signature: result.signature,
            timestamp: new Date().toISOString(),
            score: decision.token.score,
          }

          nextState.tradeHistory = [...state.tradeHistory, trade]
          log(`✅ Executed! ${result.explorerUrl}`)
          log(`   ${result.inputAmountSol} SOL → ${result.outputAmount} ${decision.token.symbol}`)
        }
      }
    }

    // 3. Log buffer health every cycle
    const bufStats = getBufferStats()
    log(`Buffer: ${bufStats.totalEvents} events | ${bufStats.trackedTokens} tokens tracked`)

  } catch (err) {
    log(`Error: ${err instanceof Error ? err.message : String(err)}`)
  }

  return nextState
}

// ── Agent loop — kept for backward-compat CLI (`solana-wallet agent <wallet>`) ─

export const runAgentLoop = async (config: AgentConfig): Promise<void> => {
  let state: AgentState = {
    openPositions: [],
    tradeHistory: [],
    lastRunAt: null,
  }

  log(`Agent starting — wallet: ${config.walletName}`)
  log(`Strategy: ${config.strategy} | Interval: ${config.intervalSeconds}s | Dry run: ${config.dryRun}`)

  if (config.dryRun) {
    log("[dry-run] No real trades will be executed")
  }

  const onGraduation = ([REDACTED_SECRET]) => {
    log(`[graduation] 🎓 ${token.symbol} (${token.mint.slice(0, 8)}...) — score ${token.score}/100`)
    log(`  ${token.reason}`)
  }

  startPumpListener(onGraduation)

  log("[pump.fun] Waiting for WebSocket connection...")
  await sleep(5_000)

  const stats = getBufferStats()
  log(`[pump.fun] Connected: ${stats.connected} | Buffer: ${stats.totalEvents} events | Tokens tracked: ${stats.trackedTokens}`)

  while (true) {
    state = await runPumpfunTick(config, state)
    log(`Sleeping ${config.intervalSeconds}s until next cycle...\n`)
    await sleep(config.intervalSeconds * 1_000)
  }
}


path: src/balance.ts
content: import { Connection, PublicKey, LAMPORTS_PER_SOL } from "@solana/web3.js"
import { SOLANA_RPC_URL } from "./environment.ts"
import { loadKeypair, getPublicKey } from "./wallet.ts"
import type { BalanceResult, TokenBalance } from "./types.ts"

const getConnection = (rpcUrl = SOLANA_RPC_URL) => new Connection(rpcUrl, "confirmed")

export const getSolBalance = async (
  publicKey: string,
  rpcUrl?: string
): Promise<{ lamports: number; sol: number }> => {
  const connection = getConnection(rpcUrl)
  const lamports = await connection.getBalance(new PublicKey(publicKey))
  return { lamports, sol: lamports / LAMPORTS_PER_SOL }
}

export const getTokenBalances = async (
  publicKey: string,
  rpcUrl?: string
): Promise<TokenBalance[]> => {
  const connection = getConnection(rpcUrl)

  const accounts = await connection.getParsedTokenAccountsByOwner(
    new PublicKey(publicKey),
    { programId: new PublicKey([REDACTED_SECRET]) }
  )

  return accounts.value
    .map(acc => {
      const info = acc.account.data.parsed.info
      const ta = info.tokenAmount
      return {
        mint: info.mint as string,
        symbol: "UNKNOWN", // Jupiter token list lookup can enrich this later
        decimals: ta.decimals as number,
        amount: BigInt(ta.amount as string),
        uiAmount: (ta.uiAmount as number) ?? 0,
      } satisfies TokenBalance
    })
    .filter(t => t.uiAmount > 0)
}

export const getPortfolioSummary = async (
  walletName: string,
  rpcUrl?: string
): Promise<BalanceResult> => {
  const publicKey = await getPublicKey(walletName)
  const { lamports, sol } = await getSolBalance(publicKey, rpcUrl)
  const tokens = await getTokenBalances(publicKey, rpcUrl)

  return { walletName, publicKey, solBalance: sol, lamports, tokens }
}


path: src/crypto.ts
content: // AES-GCM + PBKDF2 encryption — adapted from your working implementation
// Uses Web Crypto API (available globally in Node 22)
import { webcrypto as crypto } from "crypto"
import { MASTER_ENCRYPTED, MASTER_ENCRYPTION_PASSWORD_CRYPTO, MASTER_SALT } from "./environment.ts"

const ALGORITHM = "AES-GCM"
const KEY_LENGTH = 256
const IV_LENGTH = 12 // 96 bits for GCM

// Derive a CryptoKey from a password using PBKDF2
export const deriveKeyFromPassword = async (
  [REDACTED_SECRET],
  salt?: Uint8Array
): Promise<{ key: CryptoKey; salt: Uint8Array }> => {
  const encoder = new TextEncoder()

  const keyMaterial = await crypto.subtle.importKey(
    "raw",
    encoder.encode(password),
    { name: "PBKDF2" },
    false,
    ["deriveBits", "deriveKey"]
  )

  const saltArray = salt ?? crypto.getRandomValues(new Uint8Array(16))

  const key = await crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt: saltArray,
      iterations: 100_000,
      hash: "SHA-256",
    },
    keyMaterial,
    { name: ALGORITHM, length: KEY_LENGTH },
    false,
    ["encrypt", "decrypt"]
  )

  return { key, salt: saltArray }
}

// Encrypt a plaintext string → base64 (IV prepended)
export const encrypt = async (plaintext: string, key: CryptoKey): Promise<string> => {
  const encoder = new TextEncoder()
  const data = encoder.encode(plaintext)
  const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))

  const encrypted = await crypto.subtle.encrypt({ name: ALGORITHM, iv }, key, data)

  const combined = new Uint8Array(iv.length + encrypted.byteLength)
  combined.set(iv)
  combined.set(new Uint8Array(encrypted), iv.length)

  return btoa(String.fromCharCode(...combined))
}

// Decrypt a base64 string (IV prepended) → plaintext
export const decrypt = async (encryptedBase64: string, key: CryptoKey): Promise<string> => {
  const combined = new Uint8Array(
    atob(encryptedBase64)
      .split("")
      .map(c => c.charCodeAt(0))
  )

  const iv = combined.slice(0, IV_LENGTH)
  const encrypted = combined.slice(IV_LENGTH).buffer

  const decrypted = await crypto.subtle.decrypt({ name: ALGORITHM, iv }, key, encrypted)
  return new TextDecoder().decode(decrypted)
}

// Encrypt with a password — returns encrypted payload + salt (both base64)
export const encryptWithPassword = async (
  plaintext: string,
  [REDACTED_SECRET]
): Promise<{ encrypted: string; salt: string }> => {
  const { key, salt } = await deriveKeyFromPassword(password)
  const encrypted = await encrypt(plaintext, key)
  const saltBase64 = btoa(String.fromCharCode(...salt))
  return { encrypted, salt: saltBase64 }
}

// Decrypt with a password + salt (both base64)
export const decryptWithPassword = async (
  encrypted: string,
  [REDACTED_SECRET],
  saltBase64: string
): Promise<string> => {
  const salt = new Uint8Array(
    atob(saltBase64)
      .split("")
      .map(c => c.charCodeAt(0))
  )
  const { key } = await deriveKeyFromPassword(password, salt)
  return decrypt(encrypted, key)
}

// Decrypt the master key from env vars (two-layer: password → master key)
export const getMasterKey = async (): Promise<string> =>
  decryptWithPassword(
    MASTER_ENCRYPTED(),
    MASTER_ENCRYPTION_PASSWORD_CRYPTO(),
    MASTER_SALT()
  )


path: src/db.ts
content: import { readFile, writeFile } from "fs/promises"
import { existsSync } from "fs"
import { WALLET_STORE_PATH } from "./environment.ts"
import type { WalletStore, EncryptedWalletRecord } from "./types.ts"

const DEFAULT_STORE: WalletStore = { version: 1, wallets: {} }

export const readStore = async (): Promise<WalletStore> => {
  if (!existsSync(WALLET_STORE_PATH)) return structuredClone(DEFAULT_STORE)
  const raw = await readFile(WALLET_STORE_PATH, "utf-8")
  return JSON.parse(raw) as WalletStore
}

export const writeStore = async (store: WalletStore): Promise<void> => {
  await writeFile(WALLET_STORE_PATH, JSON.stringify(store, null, 2), "utf-8")
}

export const getWallet = async (name: string): Promise<EncryptedWalletRecord | null> => {
  const store = await readStore()
  return store.wallets[name] ?? null
}

export const saveWallet = async (record: EncryptedWalletRecord): Promise<void> => {
  const store = await readStore()
  store.wallets[record.name] = record
  await writeStore(store)
}

export const listWallets = async (): Promise<EncryptedWalletRecord[]> => {
  const store = await readStore()
  return Object.values(store.wallets)
}

export const deleteWallet = async (name: string): Promise<void> => {
  const store = await readStore()
  delete store.wallets[name]
  await writeStore(store)
}


path: src/environment.ts
content: // Typed env loader — validated at access time, not import time
// so commands that don't touch keys (e.g. find-pairs) don't fail on missing vars
import os from "os"
import path from "path"

const require = (key: string): string => {
  const val = process.env[key]
  if (!val) throw new Error(`Missing required env var: ${key}\nRun: cp .env.example .env && pnpm setup`)
  return val
}

const optional = (key: string, fallback: string): string =>
  process.env[key] ?? fallback

// Master key — required for any wallet signing operation
export const MASTER_ENCRYPTION_PASSWORD_CRYPTO = () => require("MASTER_ENCRYPTION_PASSWORD_CRYPTO")
export const MASTER_ENCRYPTED = () => require("MASTER_ENCRYPTED")
export const MASTER_SALT = () => require("MASTER_SALT")

// Network
export const SOLANA_RPC_URL = optional(
  "SOLANA_RPC_URL",
  "https://api.devnet.solana.com"
)

// Data directory — single source of truth for all IPC / state files
export const RAPHAEL_DATA_DIR =
  process.env["RAPHAEL_DATA_DIR"] ?? path.join(os.homedir(), ".raphael")

// Wallet store path — default inside RAPHAEL_DATA_DIR
export const WALLET_STORE_PATH = optional(
  "WALLET_STORE_PATH",
  path.join(RAPHAEL_DATA_DIR, "wallets.json"),
)

// pump.fun WebSocket — public, no key needed
export const PUMPPORTAL_WS = optional(
  "PUMPPORTAL_WS",
  "wss://pumpportal.fun/api/data"
)

// Secondary: DexScreener (free REST, no key)
export const DEXSCREENER_API = "https://api.dexscreener.com/latest/dex"

// X / Twitter API credentials
export const X_API_KEY             = optional("X_API_KEY",             "")
export const X_API_SECRET          = optional("X_API_SECRET",          "")
export const X_ACCESS_TOKEN        = optional("X_ACCESS_TOKEN",        "")
export const X_ACCESS_TOKEN_SECRET = optional("X_ACCESS_TOKEN_SECRET", "")
export const X_BEARER_TOKEN        = optional("X_BEARER_TOKEN",        "")

// Explorer URLs
export const explorerTx = (sig: string, rpcUrl = SOLANA_RPC_URL): string =>
  rpcUrl.includes("devnet")
    ? `https://explorer.solana.com/tx/${sig}?cluster=devnet`
    : `https://explorer.solana.com/tx/${sig}`


path: src/evmBalance.ts
content: // EVM balance checking for native and ERC-20 tokens on Polygon
import { ethers } from "ethers"
import { loadEvmWallet } from "./evmWallet.ts"

const POLYGON_RPC =
  process.env["POLYGON_RPC_URL"] ??
  "https://polygon-rpc.com"

const USDC_ADDRESS = [REDACTED_SECRET] // Native USDC on Polygon

const ERC20_ABI = [
  "function balanceOf(address owner) public view returns (uint256)",
  "function decimals() public view returns (uint8)",
  "function symbol() public view returns (string)",
]

export type EvmBalance = {
  walletName: string
  address: string
  nativeBalance: {
    wei: string
    matic: string
  }
  tokenBalances: Array<{
    [REDACTED_SECRET]
    address: string
    raw: string
    decimals: number
    formatted: string
    symbol: string
  }>
}

/**
 * Get native (MATIC) balance for an EVM wallet
 */
export const getNativeBalance = async (walletName: string): Promise<{ wei: string; matic: string }> => {
  const wallet = await loadEvmWallet(walletName)
  const provider = new ethers.JsonRpcProvider(POLYGON_RPC)
  const wei = await provider.getBalance(wallet.address)
  return {
    wei: wei.toString(),
    matic: ethers.formatEther(wei),
  }
}

/**
 * Get ERC-20 token balance for an EVM wallet
 */
export const getTokenBalance = async (
  walletName: string,
  tokenAddress: string,
): Promise<{ raw: string; formatted: string; decimals: number; symbol: string }> => {
  const wallet = await loadEvmWallet(walletName)
  const provider = new ethers.JsonRpcProvider(POLYGON_RPC)
  const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider)

  const [balance, decimals, symbol] = await Promise.all([
    contract.balanceOf(wallet.address),
    contract.decimals(),
    contract.symbol(),
  ])

  return {
    raw: balance.toString(),
    formatted: ethers.formatUnits(balance, decimals),
    decimals,
    symbol,
  }
}

/**
 * Get USDC balance
 */
export const getUsdcBalance = async (walletName: string): Promise<{ formatted: string; decimals: number }> => {
  const wallet = await loadEvmWallet(walletName)
  const provider = new ethers.JsonRpcProvider(POLYGON_RPC)

  const contract = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider)
  const balance = await contract.balanceOf(wallet.address)
  const decimals = await contract.decimals()
  return {
    formatted: ethers.formatUnits(balance, decimals),
    decimals,
  }
}

/**
 * Get full balance summary for a wallet (native + USDC)
 */
export const getEvmBalance = async (walletName: string): Promise<EvmBalance> => {
  const wallet = await loadEvmWallet(walletName)
  const native = await getNativeBalance(walletName)
  const usdc = await getTokenBalance(walletName, USDC_ADDRESS)

  return {
    walletName,
    address: wallet.address,
    nativeBalance: native,
    tokenBalances: [
      {
        token: "USDC",
        address: USDC_ADDRESS,
        raw: usdc.raw,
        decimals: usdc.decimals,
        formatted: usdc.formatted,
        symbol: usdc.symbol || "USDC",
      },
    ],
  }
}


path: src/evmTransfer.ts
content: // EVM token transfers on Polygon — MATIC (native) and ERC-20 tokens
import { ethers } from "ethers"
import { loadEvmWallet } from "./evmWallet.ts"

const POLYGON_RPC = process.env["POLYGON_RPC_URL"] ?? "https://polygon-rpc.com"

const ERC20_ABI = [
  "function transfer(address to, uint256 amount) returns (bool)",
  "function decimals() public view returns (uint8)",
  "function symbol() public view returns (string)",
]

export type EvmTransferResult = {
  from: string
  to: string
  amount: string
  [REDACTED_SECRET]
  txHash: string
  explorerUrl: string
}

export const transferMatic = async (
  walletName: string,
  toAddress: string,
  amountMatic: number,
): Promise<EvmTransferResult> => {
  const wallet = await loadEvmWallet(walletName)
  const provider = new ethers.JsonRpcProvider(POLYGON_RPC)
  const signer = wallet.connect(provider)

  const value = ethers.parseEther(amountMatic.toString())
  const tx = await signer.sendTransaction({ to: toAddress, value })
  await tx.wait()

  return {
    from: wallet.address,
    to: toAddress,
    amount: amountMatic.toString(),
    token: "MATIC",
    txHash: tx.hash,
    explorerUrl: `https://polygonscan.com/tx/${tx.hash}`,
  }
}

export const transferErc20 = async (
  walletName: string,
  toAddress: string,
  tokenAddress: string,
  amount: number,
): Promise<EvmTransferResult> => {
  const wallet = await loadEvmWallet(walletName)
  const provider = new ethers.JsonRpcProvider(POLYGON_RPC)
  const signer = wallet.connect(provider)

  const contract = new ethers.Contract(tokenAddress, ERC20_ABI, signer)
  const [decimals, symbol]: [number, string] = await Promise.all([
    contract.decimals(),
    contract.symbol(),
  ])
  const rawAmount = ethers.parseUnits(amount.toString(), decimals)

  const tx = await contract.transfer(toAddress, rawAmount)
  await tx.wait()

  return {
    from: wallet.address,
    to: toAddress,
    amount: amount.toString(),
    [REDACTED_SECRET],
    txHash: tx.hash,
    explorerUrl: `https://polygonscan.com/tx/${tx.hash}`,
  }
}


path: src/evmWallet.ts
content: // EVM wallet management for Polygon/Polymarket
// Uses the same AES-GCM + PBKDF2 encryption as src/wallet.ts.
// Stored separately from Solana wallets — different key curve (secp256k1 vs ed25519).

import { ethers } from "ethers"
import { readFile, writeFile } from "fs/promises"
import { existsSync } from "fs"
import path from "path"
import os from "os"
import { encryptWithPassword, decryptWithPassword, getMasterKey } from "./crypto.ts"

const EVM_WALLET_STORE_PATH =
  process.env["EVM_WALLET_STORE_PATH"] ??
  path.join(process.env["RAPHAEL_DATA_DIR"] ?? path.join(os.homedir(), ".raphael"), "evm-wallets.json")

type EvmWalletRecord = {
  name: string
  address: string               // checksummed Ethereum address
  encryptedPrivateKey: string   // AES-GCM, base64
  salt: string                  // PBKDF2 salt, base64
  createdAt: string
}

type EvmWalletStore = {
  version: number
  wallets: Record<string, EvmWalletRecord>
}

// ── Store helpers ─────────────────────────────────────────────────────────────

const readEvmStore = async (): Promise<EvmWalletStore> => {
  if (!existsSync(EVM_WALLET_STORE_PATH)) return { version: 1, wallets: {} }
  const raw = await readFile(EVM_WALLET_STORE_PATH, "utf-8")
  return JSON.parse(raw) as EvmWalletStore
}

const writeEvmStore = async (store: EvmWalletStore): Promise<void> => {
  await writeFile(EVM_WALLET_STORE_PATH, JSON.stringify(store, null, 2), "utf-8")
}

// ── Public API ────────────────────────────────────────────────────────────────

export type EvmWalletInfo = {
  name: string
  address: string
  createdAt: string
}

export const createEvmWallet = async (name: string): Promise<EvmWalletInfo> => {
  const store = await readEvmStore()
  if (store.wallets[name]) throw new Error(`EVM wallet "${name}" already exists`)

  const wallet = ethers.Wallet.createRandom()
  const masterKey = await getMasterKey()
  const { encrypted, salt } = await encryptWithPassword(wallet.privateKey, masterKey)

  store.wallets[name] = {
    name,
    address: wallet.address,
    encryptedPrivateKey: encrypted,
    salt,
    createdAt: new Date().toISOString(),
  }

  await writeEvmStore(store)
  return { name, address: wallet.address, createdAt: store.wallets[name].createdAt }
}

export const loadEvmWallet = async (name: string): Promise<ethers.Wallet> => {
  const store = await readEvmStore()
  const record = store.wallets[name]
  if (!record) throw new Error(`EVM wallet "${name}" not found. Run: solana-wallet evm-wallet create ${name}`)

  const masterKey = await getMasterKey()
  const privateKey = await decryptWithPassword(record.encryptedPrivateKey, masterKey, record.salt)
  return new ethers.Wallet(privateKey)
}

export const listEvmWallets = async (): Promise<EvmWalletInfo[]> => {
  const store = await readEvmStore()
  return Object.values(store.wallets).map(r => ({ name: r.name, address: r.address, createdAt: r.createdAt }))
}

export const getEvmAddress = async (name: string): Promise<string> => {
  const store = await readEvmStore()
  const record = store.wallets[name]
  if (!record) throw new Error(`EVM wallet "${name}" not found`)
  return record.address
}


path: src/plugin.ts
content: import { strategyManager } from "./strategyManager.ts"
import { createEvmWallet, listEvmWallets, getEvmAddress } from "./evmWallet.ts"
import { getUsdcBalance } from "./polymarketClob.ts"
import { createXClients, postTweet, replyToTweet, searchTweets, getMentions, likeTweet, retweetTweet, resolveUser } from "./xClient.ts"
import {
  X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_TOKEN_SECRET, X_BEARER_TOKEN,
} from "./environment.ts"
import type { XConfig, XStrategyConfig } from "./types.ts"

type OpenClawTool = {
  name: string
  description: string
  parameters: object
  execute: (
    id: string,
    params: Record<string, unknown>,
  ) => Promise<{ content: Array<{ type: "text"; text: string }> }>
}

type OpenClawAPI = {
  registerTool(tool: OpenClawTool, opts?: { optional?: boolean }): void
}

export default function register(api: OpenClawAPI): void {
  // ── create_evm_wallet ──────────────────────────────────────────────────────
  api.registerTool(
    {
      name: "create_evm_wallet",
      description:
        "Create a new EVM (Polygon/secp256k1) wallet for Polymarket trading. Returns the wallet name and Polygon address. The user must bridge USDC to this address before live trading.",
      parameters: {
        type: "object",
        properties: {
          name: { type: "string", description: "Wallet name, e.g. \"polymarket1\"" },
        },
        required: ["name"],
      },
      execute: async (_id, params) => {
        try {
          const { name } = params as { name: string }
          const info = await createEvmWallet(name)
          return {
            content: [
              {
                type: "text" as const,
                text: [
                  `EVM wallet "${info.name}" created.`,
                  `  Polygon address: ${info.address}`,
                  `  Created at:      ${info.createdAt}`,
                  ``,
                  `Send USDC (Polygon PoS) to: ${info.address}`,
                  `Then call check_usdc_balance to verify arrival before starting the scanner.`,
                ].join("\n"),
              },
            ],
          }
        } catch (err) {
          return {
            content: [{ type: "text" as const, text: `Error creating EVM wallet: ${err instanceof Error ? err.message : String(err)}` }],
          }
        }
      },
    },
    { optional: true },
  )

  // ── list_evm_wallets ───────────────────────────────────────────────────────
  api.registerTool(
    {
      name: "list_evm_wallets",
      description: "List all EVM (Polygon) wallets. Shows names and Polygon addresses.",
      parameters: { type: "object", properties: {} },
      execute: async () => {
        try {
          const wallets = await listEvmWallets()
          if (wallets.length === 0) {
            return { content: [{ type: "text" as const, text: "No EVM wallets found. Use create_evm_wallet to create one." }] }
          }
          const lines = wallets.map(w => `  ${w.name.padEnd(16)}: ${w.address}  (created ${w.createdAt})`)
          return { content: [{ type: "text" as const, text: `EVM wallets:\n${lines.join("\n")}` }] }
        } catch (err) {
          return {
            content: [{ type: "text" as const, text: `Error listing EVM wallets: ${err instanceof Error ? err.message : String(err)}` }],
          }
        }
      },
    },
    { optional: true },
  )

  // ── check_usdc_balance ─────────────────────────────────────────────────────
  api.registerTool(
    {
      name: "check_usdc_balance",
      description:
        "Check the USDC.e balance on Polygon for a named EVM wallet. Use this to confirm funds have arrived before starting the live scanner.",
      parameters: {
        type: "object",
        properties: {
          wallet_name: { type: "string", description: "EVM wallet name" },
        },
        required: ["wallet_name"],
      },
      execute: async (_id, params) => {
        try {
          const { wallet_name } = params as { wallet_name: string }
          const address = await getEvmAddress(wallet_name)
          const balance = await getUsdcBalance(address)
          return {
            content: [
              {
                type: "text" as const,
                text: [
                  `USDC balance for "${wallet_name}":`,
                  `  Address: ${address}`,
                  `  Balance: $${balance.toFixed(2)} USDC`,
                  balance < 5
                    ? `  ⚠ Balance is below the $5 minimum trade amount.`
                    : `  ✅ Ready to trade.`,
                ].join("\n"),
              },
            ],
          }
        } catch (err) {
          return {
            content: [{ type: "text" as const, text: `Error checking USDC balance: ${err instanceof Error ? err.message : String(err)}` }],
          }
        }
      },
    },
    { optional: true },
  )

  // ── start_weather_arb ──────────────────────────────────────────────────────
  api.registerTool(
    {
      name: "start_weather_arb",
      description:
        "Start the Polymarket weather arbitrage scanner. Polls Open-Meteo forecasts and buys underpriced YES brackets on Polymarket weather markets using an EVM (Polygon) wallet.",
      parameters: {
        type: "object",
        properties: {
          wallet_name: { type: "string", description: "EVM wallet name (Polygon/secp256k1)" },
          cities: {
            type: "array",
            items: { type: "string" },
            description: "City keys to scan, e.g. [\"nyc\",\"london\",\"seoul\"]",
          },
          trade_amount_usdc: {
            type: "number",
            description: "USDC to spend per trade (min $5, e.g. 5)",
          },
          max_position_usdc: {
            type: "number",
            description: "Hard cap USDC per bracket (default 10)",
          },
          min_edge: {
            type: "number",
            description: "Minimum edge (fairValue − askPrice) to trigger trade (default 0.20)",
          },
          min_fair_value: {
            type: "number",
            description: "Minimum fair probability to consider trading (default 0.40)",
          },
          interval_seconds: {
            type: "number",
            description: "Poll interval in seconds (default 120)",
          },
          dry_run: { type: "boolean", default: true },
        },
        required: ["wallet_name", "trade_amount_usdc"],
      },
      execute: async (_id, params) => {
        try {
          const p = params as {
            wallet_name: string
            cities?: string[]
            trade_amount_usdc: number
            max_position_usdc?: number
            min_edge?: number
            min_fair_value?: number
            interval_seconds?: number
            dry_run?: boolean
          }

          if (strategyManager.getStatus().weather_arb.running) {
            return {
              content: [
                {
                  type: "text" as const,
                  text: "Weather arb scanner is already running. Use stop_weather_arb first.",
                },
              ],
            }
          }

          const config = {
            walletName:       p.wallet_name,
            cities:           p.cities ?? ["nyc","london","seoul","chicago","dallas","miami","paris","toronto","seattle"],
            tradeAmountUsdc:  p.trade_amount_usdc,
            maxPositionUsdc:  p.max_position_usdc  ?? 10,
            minEdge:          p.min_edge            ?? 0.20,
            minFairValue:     p.min_fair_value      ?? 0.40,
            intervalSeconds:  p.interval_seconds    ?? 120,
            dryRun:           p.dry_run             ?? true,
          }

          strategyManager.startWeatherArb(config)

          return {
            content: [
              {
                type: "text" as const,
                text: [
                  "Polymarket weather arb scanner started.",
                  `  Wallet:       ${config.walletName}`,
                  `  Cities:       ${config.cities.join(", ")}`,
                  `  Amount:       $${config.tradeAmountUsdc} USDC`,
                  `  Max pos:      $${config.maxPositionUsdc} USDC`,
                  `  Min edge:     ${Math.round(config.minEdge * 100)}%`,
                  `  Min fair val: ${Math.round(config.minFairValue * 100)}%`,
                  `  Interval:     ${config.intervalSeconds}s`,
                  `  Dry run:      ${config.dryRun}`,
                ].join("\n"),
              },
            ],
          }
        } catch (err) {
          return {
            content: [
              {
                type: "text" as const,
                text: `Error starting weather arb: ${err instanceof Error ? err.message : String(err)}`,
              },
            ],
          }
        }
      },
    },
    { optional: true },
  )

  // ── stop_weather_arb ───────────────────────────────────────────────────────
  api.registerTool(
    {
      name: "stop_weather_arb",
      description: "Stop the Polymarket weather arbitrage scanner.",
      parameters: { type: "object", properties: {} },
      execute: async () => {
        try {
          strategyManager.stopWeatherArb()
          return { content: [{ type: "text" as const, text: "Weather arb scanner stopped." }] }
        } catch (err) {
          return {
            content: [
              {
                type: "text" as const,
                text: `Error stopping weather arb: ${err instanceof Error ? err.message : String(err)}`,
              },
            ],
          }
        }
      },
    },
    { optional: true },
  )

  // ── get_strategy_status ────────────────────────────────────────────────────
  api.registerTool(
    {
      name: "get_strategy_status",
      description:
        "Returns formatted status of both the pumpfun and Polymarket weather_arb scanners including latest per-city readings.",
      parameters: { type: "object", properties: {} },
      execute: async () => {
        const s = strategyManager.getStatus()

        const sourceLine =
          s._source
            ? `  Source:           ${s._source}${s._stale ? " ⚠ stale" : ""}`
            : null

        const cityLines = s.weather_arb.lastReadings.map((r) => {
          const edgeStr   = r.bestEdge   != null ? ` edge=${Math.round(r.bestEdge * 100)}%` : ""
          const bracketStr = r.targetBracket ? ` bracket="${r.targetBracket}"` : ""
          const skipStr   = r.skippedReason ? ` (${r.skippedReason})` : ""
          return `    ${r.city.padEnd(8)}: ${r.forecastHighF}°F${bracketStr}${edgeStr}${skipStr}`
        })

        const lines = [
          `── Pumpfun Scanner ─────────────────────`,
          `  Running:          ${s.pumpfun.running}`,
          `  Graduations seen: ${s.pumpfun.lastGraduations}`,
          `  Last check:       ${s.pumpfun.lastCheckAt ?? "never"}`,
          ``,
          `── Weather Arb Scanner ──────────────────`,
          ...(sourceLine ? [sourceLine] : []),
          `  Running:          ${s.weather_arb.running}`,
          `  Cities:           ${s.weather_arb.cities.join(", ") || "not configured"}`,
          `  Last check:       ${s.weather_arb.lastCheckAt ?? "never"}`,
          ...cityLines,
          ``,
          `── X Strategy ────────────────────────────`,
          `  Running:          ${s.x_strategy.running}`,
          `  Tweets this hour: ${s.x_strategy.tweetsThisHour}`,
          `  Last check:       ${s.x_strategy.lastCheckAt ?? "never"}`,
          `  Last tweet ID:    ${s.x_strategy.lastTweetId ?? "none"}`,
        ]
        return { content: [{ type: "text" as const, text: lines.join("\n") }] }
      },
    },
    { optional: true },
  )

  // ── Helper: build XConfig from env ────────────────────────────────────────
  const getXConfig = (): XConfig => ({
    [REDACTED_SECRET],
    apiSecret:          X_API_SECRET,
    accessToken:        X_ACCESS_TOKEN,
    accessTokenSecret:  X_ACCESS_TOKEN_SECRET,
    bearerToken:        X_BEARER_TOKEN,
  })

  // ── x_post_tweet ──────────────────────────────────────────────────────────
  api.registerTool(
    {
      name: "x_post_tweet",
      description: "Post a tweet from the configured X/Twitter account.",
      parameters: {
        type: "object",
        properties: {
          text: { type: "string", description: "Tweet text (max 280 chars)" },
        },
        required: ["text"],
      },
      execute: async (_id, params) => {
        try {
          const { text } = params as { text: string }
          const { rw } = createXClients(getXConfig())
          const tweet = await postTweet(rw, text.slice(0, 280))
          return { content: [{ type: "text" as const, text: `Tweet posted: https://x.com/i/web/status/${tweet.id}` }] }
        } catch (err) {
          return { content: [{ type: "text" as const, text: `Error posting tweet: ${err instanceof Error ? err.message : String(err)}` }] }
        }
      },
    },
    { optional: true },
  )

  // ── x_reply ───────────────────────────────────────────────────────────────
  api.registerTool(
    {
      name: "x_reply",
      description: "Reply to a specific tweet by ID.",
      parameters: {
        type: "object",
        properties: {
          tweet_id: { type: "string", description: "ID of the tweet to reply to" },
          text: { type: "string", description: "Reply text (max 280 chars)" },
        },
        required: ["tweet_id", "text"],
      },
      execute: async (_id, params) => {
        try {
          const { tweet_id, text } = params as { tweet_id: string; text: string }
          const { rw } = createXClients(getXConfig())
          const tweet = await replyToTweet(rw, tweet_id, text.slice(0, 280))
          return { content: [{ type: "text" as const, text: `Reply posted: https://x.com/i/web/status/${tweet.id}` }] }
        } catch (err) {
          return { content: [{ type: "text" as const, text: `Error replying: ${err instanceof Error ? err.message : String(err)}` }] }
        }
      },
    },
    { optional: true },
  )

  // ── x_search ──────────────────────────────────────────────────────────────
  api.registerTool(
    {
      name: "x_search",
      description: "Search recent tweets (last 7 days). Requires Basic+ tier on X API.",
      parameters: {
        type: "object",
        properties: {
          query:       { type: "string", description: "Search query, e.g. \"pump.fun graduation -is:retweet\"" },
          max_results: { type: "number", description: "Max tweets to return (default 10, max 100)" },
        },
        required: ["query"],
      },
      execute: async (_id, params) => {
        try {
          const { query, max_results } = params as { query: string; max_results?: number }
          const { ro } = createXClients(getXConfig())
          const tweets = await searchTweets(ro, query, max_results ?? 10)
          if (tweets.length === 0) return { content: [{ type: "text" as const, text: "No tweets found." }] }
          const lines = tweets.map(t => `[${t.id}] @${t.authorId ?? "?"}: ${t.text.slice(0, 120)}`)
          return { content: [{ type: "text" as const, text: lines.join("\n") }] }
        } catch (err) {
          return { content: [{ type: "text" as const, text: `Error searching: ${err instanceof Error ? err.message : String(err)}` }] }
        }
      },
    },
    { optional: true },
  )

  // ── x_get_mentions ────────────────────────────────────────────────────────
  api.registerTool(
    {
      name: "x_get_mentions",
      description: "Fetch recent mentions of the authenticated bot account.",
      parameters: {
        type: "object",
        properties: {
          since_id: { type: "string", description: "Only return tweets newer than this tweet ID" },
        },
      },
      execute: async (_id, params) => {
        try {
          const { since_id } = params as { since_id?: string }
          const clients = createXClients(getXConfig())
          const userId = await clients.userId()
          const mentions = await getMentions(clients.rw, userId, since_id)
          if (mentions.length === 0) return { content: [{ type: "text" as const, text: "No new mentions." }] }
          const lines = mentions.map(t => `[${t.id}] @${t.authorId ?? "?"}: ${t.text.slice(0, 120)}`)
          return { content: [{ type: "text" as const, text: lines.join("\n") }] }
        } catch (err) {
          return { content: [{ type: "text" as const, text: `Error fetching mentions: ${err instanceof Error ? err.message : String(err)}` }] }
        }
      },
    },
    { optional: true },
  )

  // ── x_resolve_user ────────────────────────────────────────────────────────
  api.registerTool(
    {
      name: "x_resolve_user",
      description: "Look up a Twitter/X user by @handle and return their user ID.",
      parameters: {
        type: "object",
        properties: {
          handle: { type: "string", description: "Twitter handle with or without @, e.g. \"elonmusk\"" },
        },
        required: ["handle"],
      },
      execute: async (_id, params) => {
        try {
          const { handle } = params as { handle: string }
          const { ro } = createXClients(getXConfig())
          const user = await resolveUser(ro, handle)
          return { content: [{ type: "text" as const, text: `@${user.username} (${user.name})\n  User ID: ${user.id}` }] }
        } catch (err) {
          return { content: [{ type: "text" as const, text: `Error resolving user: ${err instanceof Error ? err.message : String(err)}` }] }
        }
      },
    },
    { optional: true },
  )

  // ── start_x_strategy ──────────────────────────────────────────────────────
  api.registerTool(
    {
      name: "start_x_strategy",
      description:
        "Start the X/Twitter strategy: polls mentions, monitors keywords, and optionally posts trade updates automatically.",
      parameters: {
        type: "object",
        properties: {
          handle:                 { type: "string",  description: "Bot's X handle (without @)" },
          monitor_keywords:       { type: "array", items: { type: "string" }, description: "Keywords to watch, e.g. [\"pump.fun\",\"graduation\"]" },
          auto_reply_to_mentions: { type: "boolean", description: "Auto-reply to mentions (default false)" },
          post_trade_updates:     { type: "boolean", description: "Tweet when a trade fires (default true)" },
          max_tweets_per_hour:    { type: "number",  description: "Hard cap on outbound tweets per hour (default 2)" },
          interval_seconds:       { type: "number",  description: "Poll interval in seconds (default 60)" },
          dry_run:                { type: "boolean", description: "Log tweets without sending (default true)" },
        },
        required: ["handle"],
      },
      execute: async (_id, params) => {
        try {
          const p = params as {
            handle: string
            monitor_keywords?: string[]
            auto_reply_to_mentions?: boolean
            post_trade_updates?: boolean
            max_tweets_per_hour?: number
            interval_seconds?: number
            dry_run?: boolean
          }

          if (strategyManager.getStatus().x_strategy.running) {
            return { content: [{ type: "text" as const, text: "X strategy is already running. Use stop_x_strategy first." }] }
          }

          const config: XStrategyConfig = {
            handle:                 p.handle,
            monitorKeywords:        p.monitor_keywords       ?? [],
            autoReplyToMentions:    p.auto_reply_to_mentions ?? false,
            postTradeUpdates:       p.post_trade_updates     ?? true,
            maxTweetsPerHour:       p.max_tweets_per_hour    ?? 2,
            intervalSeconds:        p.interval_seconds       ?? 60,
            dryRun:                 p.dry_run                ?? true,
          }

          strategyManager.startXStrategy(getXConfig(), config)

          return {
            content: [{
              type: "text" as const,
              text: [
                "X strategy started.",
                `  Handle:        @${config.handle}`,
                `  Keywords:      ${config.monitorKeywords.join(", ") || "none"}`,
                `  Auto-reply:    ${config.autoReplyToMentions}`,
                `  Trade updates: ${config.postTradeUpdates}`,
                `  Max tweets/hr: ${config.maxTweetsPerHour}`,
                `  Interval:      ${config.intervalSeconds}s`,
                `  Dry run:       ${config.dryRun}`,
              ].join("\n"),
            }],
          }
        } catch (err) {
          return { content: [{ type: "text" as const, text: `Error starting X strategy: ${err instanceof Error ? err.message : String(err)}` }] }
        }
      },
    },
    { optional: true },
  )

  // ── stop_x_strategy ───────────────────────────────────────────────────────
  api.registerTool(
    {
      name: "stop_x_strategy",
      description: "Stop the X/Twitter strategy.",
      parameters: { type: "object", properties: {} },
      execute: async () => {
        try {
          strategyManager.stopXStrategy()
          return { content: [{ type: "text" as const, text: "X strategy stopped." }] }
        } catch (err) {
          return { content: [{ type: "text" as const, text: `Error stopping X strategy: ${err instanceof Error ? err.message : String(err)}` }] }
        }
      },
    },
    { optional: true },
  )
}


path: src/polymarketClob.ts
content: // Polymarket CLOB API — authenticated order management
// Auth model:
//   L1: EIP-712 ClobAuth signature → derive API credentials (key, secret, passphrase)
//   L2: HMAC-SHA256 of (timestamp + method + path + body) using secret → request headers
// Order signing: EIP-712 Order struct signed with the EVM wallet private key.

import { ethers } from "ethers"
import { createHmac } from "crypto"
import type { ethers as EthersType } from "ethers"

const CLOB_API = "https://clob.polymarket.com"
const POLYGON_RPC = process.env["POLYGON_RPC_URL"] ?? "https://polygon-rpc.com"
const USDC_POLYGON = [REDACTED_SECRET]

// Neg Risk CTF Exchange — used for all Polymarket weather markets (negRisk: true)
const NEG_RISK_CTF_EXCHANGE = [REDACTED_SECRET]

// ── Types ─────────────────────────────────────────────────────────────────────

export type ClobCreds = {
  [REDACTED_SECRET]
  apiSecret: string
  passphrase: string
}

export type PlacedOrder = {
  orderId: string
  status: string
  tokenId: string
  price: number
  sizeUsdc: number
}

// ── EIP-712 domains + types ───────────────────────────────────────────────────

const AUTH_DOMAIN = {
  name: "ClobAuthDomain",
  version: "1",
  chainId: 137n,
} as const

const AUTH_TYPES = {
  ClobAuth: [
    { name: "address",   type: "address" },
    { name: "timestamp", type: "string"  },
    { name: "nonce",     type: "int256"  },
    { name: "message",   type: "string"  },
  ],
}

const ORDER_DOMAIN = {
  name: "Polymarket CTF Exchange",
  version: "1",
  chainId: 137n,
  verifyingContract: NEG_RISK_CTF_EXCHANGE,
} as const

const ORDER_TYPES = {
  Order: [
    { name: "salt",          type: "uint256" },
    { name: "maker",         type: "address" },
    { name: "signer",        type: "address" },
    { name: "taker",         type: "address" },
    { name: "tokenId",       type: "uint256" },
    { name: "makerAmount",   type: "uint256" },
    { name: "takerAmount",   type: "uint256" },
    { name: "expiration",    type: "uint256" },
    { name: "nonce",         type: "uint256" },
    { name: "feeRateBps",    type: "uint256" },
    { name: "side",          type: "uint8"   },
    { name: "signatureType", type: "uint8"   },
  ],
}

// ── L1 Auth — derive API credentials ─────────────────────────────────────────

// Credentials are valid until invalidated; cache per wallet address for the process lifetime.
const credsCache = new Map<string, ClobCreds>()

export const getApiCreds = async (wallet: EthersType.Wallet): Promise<ClobCreds> => {
  const cached = credsCache.get(wallet.address)
  if (cached) return cached

  const timestamp = String(Math.floor(Date.now() / 1000))

  const signature = await wallet.signTypedData(AUTH_DOMAIN, AUTH_TYPES, {
    address:   wallet.address,
    timestamp,
    nonce:     0,
    message:   "This message attests that I control the given wallet",
  })

  const res = await fetch(`${CLOB_API}/auth/api-key`, {
    method: "GET",
    headers: {
      "POLY_ADDRESS":   wallet.address,
      "POLY_SIGNATURE": signature,
      "POLY_TIMESTAMP": timestamp,
      "POLY_NONCE":     "0",
    },
  })

  if (!res.ok) throw new Error(`CLOB auth failed (${res.status}): ${await res.text()}`)
  const data = await res.json() as { apiKey?: string; secret?: string; passphrase?: string }
  if (!data.apiKey || !data.secret || !data.passphrase) {
    throw new Error(`CLOB auth: unexpected response shape: ${JSON.stringify(data)}`)
  }

  const creds: ClobCreds = { [REDACTED_SECRET], apiSecret: data.secret, passphrase: data.passphrase }
  credsCache.set(wallet.address, creds)
  return creds
}

// ── L2 HMAC auth headers ──────────────────────────────────────────────────────

const l2Headers = (
  creds: ClobCreds,
  walletAddress: string,
  method: string,
  path: string,
  body = "",
): Record<string, string> => {
  const timestamp = String(Date.now() / 1000)
  const msg = timestamp + method.toUpperCase() + path + body
  const sig = createHmac("sha256", creds.apiSecret).update(msg).digest("base64")
  return {
    "Content-Type":    "application/json",
    "POLY_ADDRESS":    walletAddress,
    "POLY_SIGNATURE":  sig,
    "POLY_TIMESTAMP":  timestamp,
    "POLY_NONCE":      "0",
    "POLY_API-KEY":    creds.apiKey,
    "POLY_PASSPHRASE": creds.passphrase,
  }
}

// ── USDC balance check (Polygon RPC) ──────────────────────────────────────────

export const getUsdcBalance = async (address: string): Promise<number> => {
  const provider = new ethers.JsonRpcProvider(POLYGON_RPC)
  const usdc = new ethers.Contract(
    USDC_POLYGON,
    ["function balanceOf(address) view returns (uint256)"],
    provider,
  )
  const raw: bigint = await usdc.balanceOf(address) as bigint
  return Number(raw) / 1e6
}

// ── Place order ────────────────────────────────────────────────────────────────
// BUY `sizeUsdc` worth of YES shares at `limitPrice` (0–1).
// maker pays USDC, taker delivers YES tokens.

export const placeOrder = async (
  wallet: EthersType.Wallet,
  tokenId: string,
  limitPrice: number,    // e.g. 0.30
  sizeUsdc: number,      // USDC to spend, e.g. 5.0
): Promise<PlacedOrder> => {
  // Amount math (6 decimals for both USDC and CTF tokens)
  const tokensToReceive = sizeUsdc / limitPrice
  const makerAmount = BigInt(Math.floor(sizeUsdc      * 1e6))
  const takerAmount = BigInt(Math.floor(tokensToReceive * 1e6))

  const salt = BigInt(Math.floor(Math.random() * 1e15))
  const zero = "0x0000000000000000000000000000000000000000"

  const orderStruct = {
    salt,
    maker:         wallet.address,
    signer:        wallet.address,
    taker:         zero,
    tokenId:       BigInt(tokenId),
    makerAmount,
    takerAmount,
    expiration:    BigInt(0),   // GTC
    nonce:         BigInt(0),
    feeRateBps:    BigInt(0),
    side:          0,           // BUY
    signatureType: 0,           // EOA
  }

  const signature = await wallet.signTypedData(ORDER_DOMAIN, ORDER_TYPES, orderStruct)

  const body = JSON.stringify({
    order: {
      salt:          salt.toString(),
      maker:         wallet.address,
      signer:        wallet.address,
      taker:         zero,
      tokenId:       tokenId,
      makerAmount:   makerAmount.toString(),
      takerAmount:   takerAmount.toString(),
      expiration:    "0",
      nonce:         "0",
      feeRateBps:    "0",
      side:          0,
      signatureType: 0,
      signature,
    },
    owner:     wallet.address,
    orderType: "GTC",
  })

  const creds = await getApiCreds(wallet)
  const headers = l2Headers(creds, wallet.address, "POST", "/order", body)

  const res = await fetch(`${CLOB_API}/order`, { method: "POST", headers, body })
  if (!res.ok) throw new Error(`CLOB order failed (${res.status}): ${await res.text()}`)

  const data = await res.json() as { orderID?: string; status?: string; errorMsg?: string }
  if (data.errorMsg) throw new Error(`CLOB order rejected: ${data.errorMsg}`)

  return {
    orderId:  data.orderID ?? "unknown",
    status:   data.status  ?? "unknown",
    tokenId,
    price:    limitPrice,
    sizeUsdc,
  }
}

// ── List open orders ───────────────────────────────────────────────────────────

export const getOpenOrders = async (wallet: EthersType.Wallet): Promise<Array<{ id: string; asset_id: string }>> => {
  const creds = await getApiCreds(wallet)
  const path = "/orders?market_status=open"
  const headers = l2Headers(creds, wallet.address, "GET", path)

  const res = await fetch(`${CLOB_API}${path}`, { headers })
  if (!res.ok) throw new Error(`CLOB getOpenOrders failed (${res.status}): ${await res.text()}`)

  const data = await res.json() as Array<{ id: string; asset_id: string }>
  return Array.isArray(data) ? data : []
}

// ── Cancel order ───────────────────────────────────────────────────────────────

export const cancelOrder = async (wallet: EthersType.Wallet, orderId: string): Promise<void> => {
  const body = JSON.stringify({ orderID: orderId })
  const creds = await getApiCreds(wallet)
  const headers = l2Headers(creds, wallet.address, "DELETE", "/order", body)

  const res = await fetch(`${CLOB_API}/order`, { method: "DELETE", headers, body })
  if (!res.ok) throw new Error(`CLOB cancelOrder failed (${res.status}): ${await res.text()}`)
}


path: src/polymarketOracle.ts
content: // Polymarket weather arb — read-only oracle layer
// No auth required for any function in this file.

export type CityKey = "nyc" | "london" | "seoul" | "chicago" | "dallas" | "miami" | "paris" | "toronto" | "seattle"

export type CityConfig = {
  lat: number
  lon: number
  slugName: string
  label: string
  tz: string
}

export const CITIES: Record<CityKey, CityConfig> = {
  nyc:     { lat: 40.7128, lon: -74.0060, slugName: "nyc",     label: "New York City", tz: "America/New_York"    },
  london:  { lat: 51.5074, lon: -0.1278,  slugName: "london",  label: "London",        tz: "Europe/London"       },
  seoul:   { lat: 37.5665, lon: 126.9780, slugName: "seoul",   label: "Seoul",         tz: "Asia/Seoul"          },
  chicago: { lat: 41.8781, lon: -87.6298, slugName: "chicago", label: "Chicago",       tz: "America/Chicago"     },
  dallas:  { lat: 32.7767, lon: -96.7970, slugName: "dallas",  label: "Dallas",        tz: "America/Chicago"     },
  miami:   { lat: 25.7617, lon: -80.1918, slugName: "miami",   label: "Miami",         tz: "America/New_York"    },
  paris:   { lat: 48.8566, lon:  2.3522,  slugName: "paris",   label: "Paris",         tz: "Europe/Paris"        },
  toronto: { lat: 43.6532, lon: -79.3832, slugName: "toronto", label: "Toronto",       tz: "America/Toronto"     },
  seattle: { lat: 47.6062, lon: -122.3321,slugName: "seattle", label: "Seattle",       tz: "America/Los_Angeles" },
}

// ── Types ─────────────────────────────────────────────────────────────────────

export type WeatherForecast = {
  forecastHighF: number
  fetchedAt: number
}

export type PolymarketBracket = {
  label: string          // "40-41°F" | "33°F or below" | "48°F or higher"
  lo: number             // lower bound inclusive; -Infinity for lower terminal
  hi: number             // upper bound inclusive; +Infinity for upper terminal
  yesTokenId: string
  conditionId: string
  isTerminal: boolean
  acceptingOrders: boolean
  closeTimeUtc: Date
}

export type PricedBracket = PolymarketBracket & {
  askPrice: number       // price you pay to buy YES (0–1)
  fairValue: number      // our calculated probability (0–1)
  edge: number           // fairValue − askPrice
}

// ── Helpers ───────────────────────────────────────────────────────────────────

// Date → "february-25-2026"
export const formatDateSlug = (d: Date): string => {
  const months = ["january","february","march","april","may","june","july","august","september","october","november","december"]
  return `${months[d.getMonth()]}-${d.getDate()}-${d.getFullYear()}`
}

// Abramowitz & Stegun erf approximation (max error ~1.5e-7)
const erf = (x: number): number => {
  const sign = x < 0 ? -1 : 1
  x = Math.abs(x)
  const t = 1 / (1 + 0.3275911 * x)
  const poly = t * (0.254829592 + t * (-0.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429))))
  return sign * (1 - poly * Math.exp(-x * x))
}

// P(X ≤ x) where X ~ N(mu, sigma²)
const normalCDF = (x: number, mu: number, sigma: number): number => {
  if (!isFinite(x)) return x > 0 ? 1 : 0
  return 0.5 * (1 + erf((x - mu) / (sigma * Math.SQRT2)))
}

// P(reported high falls in bracket [lo, hi]) using half-integer boundaries
// e.g. "40-41°F" bracket: P(39.5 ≤ X < 41.5) = CDF(41.5) − CDF(39.5)
const bracketFairValue = (lo: number, hi: number, mu: number, sigma: number): number => {
  const loCDF = lo === -Infinity ? 0 : normalCDF(lo - 0.5, mu, sigma)
  const hiCDF = hi === Infinity  ? 1 : normalCDF(hi + 0.5, mu, sigma)
  return Math.max(0, Math.min(1, hiCDF - loCDF))
}

// sigma grows with time-to-resolution: same-day ≈ 2°F, next-day ≈ 4°F
export const forecastSigmaF = (hoursUntilClose: number): number => {
  if (hoursUntilClose <= 6)  return 2
  if (hoursUntilClose >= 30) return 4
  return 2 + ((hoursUntilClose - 6) / 24) * 2
}

// ── Open-Meteo ────────────────────────────────────────────────────────────────

export const fetchOpenMeteoForecast = async (city: CityKey): Promise<WeatherForecast> => {
  const c = CITIES[city]
  const url = `https://api.open-meteo.com/v1/forecast` +
    `?latitude=${c.lat}&longitude=${c.lon}` +
    `&daily=temperature_2m_max` +
    `&temperature_unit=fahrenheit` +
    `&timezone=${encodeURIComponent(c.tz)}` +
    `&forecast_days=2`

  const res = await fetch(url)
  if (!res.ok) throw new Error(`Open-Meteo failed for ${city} (${res.status}): ${await res.text()}`)

  const data = await res.json() as { daily?: { temperature_2m_max?: number[] } }
  const forecastHighF = data.daily?.temperature_2m_max?.[0]
  if (forecastHighF == null) throw new Error(`Open-Meteo: no temperature data for ${city}`)

  return { forecastHighF, fetchedAt: Date.now() }
}

// ── Polymarket Gamma API ───────────────────────────────────────────────────────

const parseBracketRange = (label: string): [number, number] => {
  const range = label.match(/^(\d+)-(\d+)°F$/)
  if (range) return [parseInt(range[1]), parseInt(range[2])]

  const below = label.match(/^(\d+)°F or below$/)
  if (below) return [-Infinity, parseInt(below[1])]

  const above = label.match(/^(\d+)°F or higher$/)
  if (above) return [parseInt(above[1]), Infinity]

  // Celsius variants (London, Paris, Seoul, Toronto)
  const rangeC = label.match(/^(-?\d+)-(-?\d+)°C$/)
  if (rangeC) {
    const loC = parseInt(rangeC[1]), hiC = parseInt(rangeC[2])
    return [loC * 9/5 + 32, hiC * 9/5 + 32]
  }

  throw new Error(`Cannot parse bracket: "${label}"`)
}

export const fetchPolymarketWeatherBrackets = async (
  city: CityKey,
  date: Date,
): Promise<PolymarketBracket[]> => {
  const c = CITIES[city]
  const slug = `highest-temperature-in-${c.slugName}-on-${formatDateSlug(date)}`
  const url = `https://gamma-api.polymarket.com/events?slug=${slug}`

  const res = await fetch(url, { headers: { Accept: "application/json" } })
  if (!res.ok) throw new Error(`Gamma API failed (${res.status}): ${await res.text()}`)

  const events = await res.json() as Array<{
    markets?: Array<{
      conditionId: string
      groupItemTitle?: string
      clobTokenIds?: string[]
      acceptingOrders?: boolean
      endDate?: string
    }>
  }>

  if (!events.length || !events[0].markets?.length) return []

  const brackets: PolymarketBracket[] = []
  for (const m of events[0].markets) {
    const label = m.groupItemTitle ?? ""
    if (!label || !m.clobTokenIds?.length) continue

    try {
      const [lo, hi] = parseBracketRange(label)
      brackets.push({
        label,
        lo,
        hi,
        yesTokenId: m.clobTokenIds[0],
        conditionId: m.conditionId,
        isTerminal: lo === -Infinity || hi === Infinity,
        acceptingOrders: m.acceptingOrders ?? false,
        closeTimeUtc: m.endDate ? new Date(m.endDate) : new Date(),
      })
    } catch {
      // skip brackets with unrecognised formats
    }
  }

  return brackets.sort((a, b) => (isFinite(a.lo) ? a.lo : -999) - (isFinite(b.lo) ? b.lo : -999))
}

// ── CLOB Pricing ─────────────────────────────────────────────────────────────

const fetchAskPrice = async (tokenId: string): Promise<number | null> => {
  try {
    const res = await fetch(
      `https://clob.polymarket.com/price?token_id=${tokenId}&side=sell`,
      { headers: { Accept: "application/json" } },
    )
    if (!res.ok) return null
    const data = await res.json() as { price?: string }
    const p = parseFloat(data.price ?? "")
    return isNaN(p) ? null : p
  } catch {
    return null
  }
}

export const priceBrackets = async (
  brackets: PolymarketBracket[],
  forecastHighF: number,
  sigmaF: number,
): Promise<PricedBracket[]> => {
  const askPrices = await Promise.all(brackets.map(b => fetchAskPrice(b.yesTokenId)))

  return brackets
    .map((b, i) => {
      const askPrice = askPrices[i]
      if (askPrice === null) return null
      const fairValue = bracketFairValue(b.lo, b.hi, forecastHighF, sigmaF)
      return { ...b, askPrice, fairValue, edge: fairValue - askPrice } as PricedBracket
    })
    .filter((b): b is PricedBracket => b !== null)
    .sort((a, b) => b.edge - a.edge)
}

// ── Top-level: scan all brackets for a city ───────────────────────────────────

export type CityOpportunity = {
  city: CityKey
  forecastHighF: number
  sigmaF: number
  targetBracket: PricedBracket | null   // best bracket if edge qualifies
  allBrackets: PricedBracket[]
  skippedReason: string | null
  scannedAt: number
}

export const scanCity = async (
  city: CityKey,
  date: Date,
  minEdge: number,
  minFairValue: number,
): Promise<CityOpportunity> => {
  const base: Omit<CityOpportunity, "targetBracket" | "allBrackets" | "skippedReason"> & Partial<CityOpportunity> = {
    city,
    forecastHighF: 0,
    sigmaF: 0,
    scannedAt: Date.now(),
  }

  // 1. Forecast
  const forecast = await fetchOpenMeteoForecast(city)
  base.forecastHighF = forecast.forecastHighF

  // 2. Market brackets
  const rawBrackets = await fetchPolymarketWeatherBrackets(city, date)
  if (!rawBrackets.length) {
    return { ...base, targetBracket: null, allBrackets: [], sigmaF: 0,
      skippedReason: "no_market" } as CityOpportunity
  }

  // 3. Time-to-close guard (30-min buffer)
  const closeTimes = rawBrackets.map(b => b.closeTimeUtc.getTime())
  const closeTimeMs = Math.min(...closeTimes)
  const msUntilClose = closeTimeMs - Date.now()
  if (msUntilClose < 30 * 60 * 1000) {
    return { ...base, targetBracket: null, allBrackets: [], sigmaF: 0,
      skippedReason: "market_closing_soon" } as CityOpportunity
  }

  const hoursUntilClose = msUntilClose / 3_600_000
  const sigmaF = forecastSigmaF(hoursUntilClose)
  base.sigmaF = sigmaF

  // 4. Price non-terminal brackets only
  const tradeable = rawBrackets.filter(b => !b.isTerminal && b.acceptingOrders)
  const priced = await priceBrackets(tradeable, forecast.forecastHighF, sigmaF)

  // 5. Find best opportunity
  const best = priced.find(b => b.edge >= minEdge && b.fairValue >= minFairValue) ?? null

  return {
    ...base,
    sigmaF,
    targetBracket: best,
    allBrackets: priced,
    skippedReason: best ? null : "no_edge",
  } as CityOpportunity
}


path: src/polymarketWeatherArb.ts
content: // Polymarket weather arbitrage strategy
// Edge: NOAA/Open-Meteo forecasts are more accurate than crowd pricing at trade time.
// We buy underpriced YES brackets and hold until daily resolution at ~$1.

import { scanCity, CITIES, formatDateSlug } from "./polymarketOracle.ts"
import { loadEvmWallet } from "./evmWallet.ts"
import { placeOrder, getOpenOrders, getUsdcBalance } from "./polymarketClob.ts"
import type { CityKey } from "./polymarketOracle.ts"
import type { PolymarketWeatherConfig, PolymarketWeatherReading } from "./types.ts"

const log = (msg: string) => console.log(`[polymarket_arb] ${msg}`)

// ── Tick ──────────────────────────────────────────────────────────────────────

export const runPolymarketWeatherArbTick = async (
  config: PolymarketWeatherConfig,
  onReading: (readings: PolymarketWeatherReading[]) => void,
): Promise<void> => {
  const today = new Date()
  const readings: PolymarketWeatherReading[] = []

  try {
    const wallet = config.dryRun ? null : await loadEvmWallet(config.walletName)

    // Open positions — skip any city already positioned
    const openOrders = wallet ? await getOpenOrders(wallet).catch(() => []) : []
    const positionedTokenIds = new Set(openOrders.map(o => o.asset_id))

    // USDC balance — checked once per tick
    const usdcBalance = wallet ? await getUsdcBalance(wallet.address).catch(() => 0) : 0
    if (wallet) log(`USDC balance: $${usdcBalance.toFixed(2)}`)

    for (const city of config.cities as CityKey[]) {
      const cityLabel = CITIES[city]?.label ?? city
      const reading: PolymarketWeatherReading = {
        city,
        forecastHighF: 0,
        sigmaF: 0,
        targetBracket: null,
        bestEdge: null,
        orderId: null,
        skippedReason: null,
        scannedAt: Date.now(),
      }

      try {
        const result = await scanCity(city, today, config.minEdge, config.minFairValue)
        reading.forecastHighF = result.forecastHighF
        reading.sigmaF        = result.sigmaF
        reading.targetBracket = result.targetBracket?.label ?? null
        reading.bestEdge      = result.targetBracket?.edge ?? null

        if (result.skippedReason) {
          reading.skippedReason = result.skippedReason
          log(`${cityLabel}: skipped (${result.skippedReason})`)
          readings.push(reading)
          continue
        }

        const best = result.targetBracket!
        log(
          `${cityLabel}: forecast ${result.forecastHighF}°F | ` +
          `bracket "${best.label}" | fair ${(best.fairValue * 100).toFixed(1)}% | ` +
          `ask ${(best.askPrice * 100).toFixed(1)}% | edge ${(best.edge * 100).toFixed(1)}%`
        )

        // Already positioned in this bracket? Skip.
        if (positionedTokenIds.has(best.yesTokenId)) {
          reading.skippedReason = "already_positioned"
          log(`${cityLabel}: already have an open position — skipping`)
          readings.push(reading)
          continue
        }

        const tradeSize = Math.min(config.tradeAmountUsdc, config.maxPositionUsdc)

        if (config.dryRun) {
          log(
            `[dry-run] Would buy ${tradeSize} USDC of "${best.label}" YES @ ${best.askPrice.toFixed(3)} ` +
            `(${formatDateSlug(today)})`
          )
        } else {
          // Hard guards before execution
          if (usdcBalance < tradeSize) {
            reading.skippedReason = "insufficient_usdc"
            log(`${cityLabel}: insufficient USDC ($${usdcBalance.toFixed(2)} < $${tradeSize}) — skipping`)
            readings.push(reading)
            continue
          }

          const order = await placeOrder(wallet!, best.yesTokenId, best.askPrice, tradeSize)
          reading.orderId = order.orderId
          log(`✅ ${cityLabel}: order placed — ${order.orderId} | ${tradeSize} USDC @ ${best.askPrice.toFixed(3)}`)
        }
      } catch (err) {
        reading.skippedReason = "error"
        log(`${cityLabel}: error — ${err instanceof Error ? err.message : String(err)}`)
      }

      readings.push(reading)
    }
  } catch (err) {
    log(`tick error: ${err instanceof Error ? err.message : String(err)}`)
  }

  onReading(readings)
}


path: src/screener.ts
content: import WebSocket from "ws"
import { PUMPPORTAL_WS, DEXSCREENER_API } from "./environment.ts"
import type { PumpEvent, RawPumpEvent, ScoredToken, DexScreenerConfirmation } from "./types.ts"

// ── In-memory state ───────────────────────────────────────────────────────────

const MAX_BUFFER = 2_000
const eventBuffer: PumpEvent[] = []

// Per-token trade stats accumulated from live trade events
const tokenStats: Map<string, { buys: number; sells: number; solVolume: number; marketCapSol: number }> = new Map()

// Graduated tokens (mint → scored token)
const graduatedTokens: Map<string, ScoredToken> = new Map()

let _connected = false
let _ws: WebSocket | null = null

const pushEvent = (event: PumpEvent): void => {
  eventBuffer.push(event)
  if (eventBuffer.length > MAX_BUFFER) eventBuffer.shift()

  if (event.txType === "buy" || event.txType === "sell") {
    const stats = tokenStats.get(event.mint) ?? { buys: 0, sells: 0, solVolume: 0, marketCapSol: 0 }
    if (event.txType === "buy") stats.buys++
    else stats.sells++
    stats.solVolume += event.solAmount
    if ("marketCapSol" in event) stats.marketCapSol = Number((event as RawPumpEvent).marketCapSol ?? 0)
    tokenStats.set(event.mint, stats)
  }

  // Track initial bonding curve data from create events
  if (event.txType === "create") {
    const raw = event as unknown as RawPumpEvent
    const stats = tokenStats.get(event.mint) ?? { buys: 0, sells: 0, solVolume: Number(raw.solAmount ?? 0), marketCapSol: Number(raw.marketCapSol ?? 0) }
    // initialBuy counts as a buy
    if (Number(raw.initialBuy ?? 0) > 0) stats.buys++
    tokenStats.set(event.mint, stats)
  }
}

// ── Event normalization (field names confirmed from live pumpportal.fun data) ─

const normalizeEvent = (raw: RawPumpEvent): PumpEvent | null => {
  const { txType, mint } = raw
  if (!mint || typeof mint !== "string") return null
  const now = Date.now()

  if (txType === "create") {
    return {
      txType: "create",
      mint,
      name: String(raw.name ?? ""),
      symbol: String(raw.symbol ?? ""),
      traderPublicKey: String(raw.traderPublicKey ?? ""),
      initialBuy: Number(raw.initialBuy ?? 0),
      bondingCurveKey: raw.bondingCurveKey as string | undefined,
      receivedAt: now,
    }
  }

  if (txType === "buy" || txType === "sell") {
    return {
      txType,
      mint,
      solAmount: Number(raw.solAmount ?? 0),
      tokenAmount: Number(raw.tokenAmount ?? 0),
      traderPublicKey: String(raw.traderPublicKey ?? ""),
      newTokenBalance: raw.newTokenBalance as number | undefined,
      bondingCurveKey: raw.bondingCurveKey as string | undefined,
      receivedAt: now,
    }
  }

  // Graduation: txType "migrate" when bonding curve completes → moves to Raydium
  if (txType === "migrate" || (raw.raydiumPool && typeof raw.raydiumPool === "string")) {
    return {
      txType: "migrate",
      mint,
      name: String(raw.name ?? ""),
      symbol: String(raw.symbol ?? ""),
      raydiumPool: raw.raydiumPool as string | undefined,
      bondingCurveKey: raw.bondingCurveKey as string | undefined,
      receivedAt: now,
    }
  }

  return null
}

const isGraduation = (event: PumpEvent): boolean =>
  event.txType === "migrate" || event.txType === "graduation"

// ── WebSocket listener ────────────────────────────────────────────────────────

export const startPumpListener = (
  onGraduation?: ([REDACTED_SECRET]) => void,
  verbose = false
): WebSocket => {
  const ws = new WebSocket(PUMPPORTAL_WS)
  _ws = ws

  ws.on("open", () => {
    _connected = true
    console.log("[pump.fun] Connected to pumpportal.fun WebSocket")

    // Subscribe to new token creations
    ws.send(JSON.stringify({ method: "subscribeNewToken" }))
  })

  ws.on("message", (raw) => {
    try {
      const data = JSON.parse(raw.toString()) as RawPumpEvent
      if (verbose) console.log("[pump.fun] raw:", JSON.stringify(data))

      // When a new token is created, subscribe to its trades immediately
      // This is the correct pattern — pump.fun doesn't support wildcard trade subs
      if (data.txType === "create" && data.mint && _ws?.readyState === WebSocket.OPEN) {
        _ws.send(JSON.stringify({
          method: "subscribeTokenTrade",
          keys: [data.mint],
        }))
      }

      const event = normalizeEvent(data)
      if (!event) return

      pushEvent(event)

      if (isGraduation(event)) {
        const scored = scoreGraduatedToken(data)
        graduatedTokens.set(event.mint, scored)
        console.log(`[pump.fun] 🎓 GRADUATED: ${scored.symbol} — score ${scored.score}/100`)
        onGraduation?.(scored)
      }
    } catch {
      // ignore malformed messages
    }
  })

  ws.on("error", (err) => console.error("[pump.fun] WS error:", err.message))

  ws.on("close", () => {
    _connected = false
    _ws = null
    console.log("[pump.fun] Connection closed — reconnecting in 3s...")
    setTimeout(() => startPumpListener(onGraduation, verbose), 3_000)
  })

  return ws
}

export const isPumpListenerConnected = (): boolean => _connected

// ── Scoring ──────────────────────────────────────────────────────────────────

export const scoreGraduatedToken = (raw: RawPumpEvent): ScoredToken => {
  const mint = String(raw.mint ?? "")
  const stats = tokenStats.get(mint) ?? { buys: 0, sells: 0, solVolume: 0, marketCapSol: 0 }
  const totalTrades = stats.buys + stats.sells
  const buyPressure = totalTrades > 0 ? stats.buys / totalTrades : 0.5

  let score = 50 // base: graduation itself is a proven-demand signal

  // Buy pressure (−15 to +25)
  score += buyPressure > 0.7 ? 25 : buyPressure > 0.55 ? 12 : buyPressure < 0.4 ? -15 : 0

  // SOL volume on bonding curve (0 to +15)
  score += stats.solVolume > 100 ? 15 : stats.solVolume > 50 ? 8 : stats.solVolume > 20 ? 3 : 0

  // Trade count = community interest (0 to +10)
  score += totalTrades > 300 ? 10 : totalTrades > 100 ? 5 : totalTrades > 30 ? 2 : 0

  score = Math.max(0, Math.min(100, score))

  const reason = [
    `Graduated → Raydium`,
    `${stats.buys}/${totalTrades} buys (${Math.round(buyPressure * 100)}% buy pressure)`,
    `${stats.solVolume.toFixed(1)} SOL bonding curve volume`,
  ].join(" · ")

  return {
    mint,
    symbol: String(raw.symbol ?? "UNKNOWN"),
    name: String(raw.name ?? "UNKNOWN"),
    score,
    reason,
    raydiumPool: raw.raydiumPool as string | undefined,
    graduatedAt: Date.now(),
    source: "graduation",
  }
}

// ── DexScreener confirmation ──────────────────────────────────────────────────

export const confirmOnDexScreener = async (mint: string): Promise<DexScreenerConfirmation> => {
  try {
    const res = await fetch(`${DEXSCREENER_API}/tokens/${mint}`)
    if (!res.ok) return null

    const data = await res.json() as {
      pairs?: Array<{
        liquidity?: { usd?: number }
        volume?: { h1?: number }
        priceChange?: { h1?: number }
      }>
    }

    const pair = data.pairs?.[0]
    if (!pair) return null

    return {
      liquidityUsd: pair.liquidity?.usd ?? 0,
      volume1h: pair.volume?.h1 ?? 0,
      priceChange1h: pair.priceChange?.h1 ?? 0,
    }
  } catch {
    return null
  }
}

// ── One-shot snapshot for CLI `find-pairs` ────────────────────────────────────

export const findHighPotentialPairs = async (minScore = 60): Promise<ScoredToken[]> => {
  // Return from graduated tokens map (populated by live WebSocket events)
  const cutoff = Date.now() - 30 * 60 * 1_000
  const recent = Array.from(graduatedTokens.values())
    .filter(t => (t.graduatedAt ?? 0) > cutoff)
    .filter(t => t.score >= minScore)
    .sort((a, b) => b.score - a.score)
    .slice(0, 10)

  return recent
}

export const getBufferStats = () => ({
  totalEvents: eventBuffer.length,
  trackedTokens: tokenStats.size,
  graduatedTokens: graduatedTokens.size,
  connected: _connected,
})


path: src/strategy.ts
content: import { findHighPotentialPairs, confirmOnDexScreener } from "./screener.ts"
import type { TradeDecision } from "./types.ts"

export const threeXStrategy = async (
  currentPortfolioSol: number,
  maxRiskPercent = 5
): Promise<TradeDecision> => {
  const topTokens = await findHighPotentialPairs(65)

  if (topTokens.length === 0) {
    return {
      action: "skip",
      token: null,
      reason:
        "No qualifying pump.fun graduations in buffer. " +
        "Start the agent loop to collect live signals: solana-wallet agent <wallet> --dry-run",
      suggestedAmountSol: 0,
      targetMultiple: 3.0,
      stopLossPercent: 30,
    }
  }

  // Optional DexScreener confirmation on the top candidate
  const best = topTokens[0]
  const dex = await confirmOnDexScreener(best.mint)

  // If DexScreener shows the token but liquidity is dangerously low, skip
  if (dex !== null && dex.liquidityUsd < 10_000) {
    return {
      action: "skip",
      token: best,
      reason: `Low Raydium liquidity ($${dex.liquidityUsd.toFixed(0)}) — rug risk too high`,
      suggestedAmountSol: 0,
      targetMultiple: 3.0,
      stopLossPercent: 30,
    }
  }

  // Risk sizing: % of portfolio, min 0.01 SOL, max 0.5 SOL
  const amountSol = Math.max(
    0.01,
    Math.min(currentPortfolioSol * (maxRiskPercent / 100), 0.5)
  )

  if (currentPortfolioSol < amountSol + 0.005) {
    return {
      action: "skip",
      token: best,
      reason: `Insufficient SOL (${currentPortfolioSol.toFixed(4)}) — need ${(amountSol + 0.005).toFixed(4)} SOL including fees`,
      suggestedAmountSol: 0,
      targetMultiple: 3.0,
      stopLossPercent: 30,
    }
  }

  const dexNote = dex
    ? ` | Raydium liq: $${dex.liquidityUsd.toFixed(0)}`
    : " | Raydium liq: pending"

  return {
    action: "buy",
    token: best,
    reason: `Score ${best.score}/100 — ${best.symbol} · ${best.reason}${dexNote}`,
    suggestedAmountSol: amountSol,
    targetMultiple: 3.0,
    stopLossPercent: 30,
  }
}


path: src/strategyManager.ts
content: import fs from "fs";
import path from "path";
import os from "os";
import type {
  StrategyStatus,
  PolymarketWeatherConfig,
  PolymarketWeatherReading,
  XConfig,
  XStrategyConfig,
} from "./types.ts";
import { runPolymarketWeatherArbTick } from "./polymarketWeatherArb.ts";
import { createXStrategy } from "./xStrategy.ts";

const RAPHAEL_DATA_DIR =
  process.env["RAPHAEL_DATA_DIR"] ?? path.join(os.homedir(), ".raphael");

try { fs.mkdirSync(RAPHAEL_DATA_DIR, { recursive: true }); } catch {}

export const STATUS_FILE = path.join(RAPHAEL_DATA_DIR, "scanner-status.json");
export const PID_FILE    = path.join(RAPHAEL_DATA_DIR, "weather-arb.pid");

// ── Helpers ───────────────────────────────────────────────────────────────────

const atomicWriteJSON = (filePath: string, data: unknown): void => {
  const tmp = filePath + ".tmp";
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
  fs.renameSync(tmp, filePath);
};

const isPidAlive = (pid: number): boolean => {
  try { process.kill(pid, 0); return true; } catch { return false; }
};

const isDaemonRunning = (): boolean => {
  try {
    if (!fs.existsSync(PID_FILE)) return false;
    const pid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10);
    return !isNaN(pid) && isPidAlive(pid);
  } catch { return false; }
};

// ── Strategy Manager ──────────────────────────────────────────────────────────

export const createStrategyManager = () => {
  let weatherArbIntervalId: NodeJS.Timeout | null = null;
  let weatherArbConfig: PolymarketWeatherConfig | null = null;
  let weatherArbLastReadings: PolymarketWeatherReading[] = [];
  let weatherArbLastCheckAt: string | null = null;
  let isOwnerProcess = false;

  const xStrategy = createXStrategy();

  const buildLiveStatus = (): StrategyStatus => {
    const xs = xStrategy.getState();
    return {
      pumpfun: { running: false, lastGraduations: 0, lastCheckAt: null },
      weather_arb: {
        running: weatherArbIntervalId !== null,
        lastCheckAt: weatherArbLastCheckAt,
        cities: weatherArbConfig?.cities ?? [],
        lastReadings: weatherArbLastReadings,
      },
      x_strategy: {
        running: xs.running,
        lastCheckAt: xs.lastCheckAt,
        tweetsThisHour: xs.tweetsThisHour,
        lastTweetId: xs.lastTweetId,
      },
    };
  };

  const saveStatusToDisk = () => {
    try { atomicWriteJSON(STATUS_FILE, buildLiveStatus()); } catch (e) {
      console.error(`[strategyManager] Failed to write status file: ${e}`);
    }
  };

  const writePidFile = () => {
    try { fs.writeFileSync(PID_FILE, String(process.pid)); } catch (e) {
      console.error(`[strategyManager] Failed to write PID file: ${e}`);
    }
  };

  const cleanupPidFile = () => { try { fs.unlinkSync(PID_FILE); } catch {} };

  const getStatus = (
    forceLive = false,
  ): StrategyStatus & { _stale?: boolean; _source?: string } => {
    if (forceLive || isOwnerProcess) return { ...buildLiveStatus(), _source: "live" };

    if (fs.existsSync(STATUS_FILE)) {
      try {
        const fileData = JSON.parse(fs.readFileSync(STATUS_FILE, "utf-8")) as StrategyStatus;
        const ageMs = Date.now() - fs.statSync(STATUS_FILE).mtimeMs;

        if (fileData.weather_arb.running && !isDaemonRunning()) {
          console.warn("[strategyManager] Status file says RUNNING but daemon PID is dead. Cleaning up.");
          try { fs.unlinkSync(STATUS_FILE); } catch {}
          try { fs.unlinkSync(PID_FILE); } catch {}
          return { ...buildLiveStatus(), _source: "dead_daemon_cleanup" };
        }

        return { ...fileData, _stale: ageMs > 10 * 60 * 1000, _source: "file" };
      } catch { /* corrupted file */ }
    }

    return { ...buildLiveStatus(), _source: "default" };
  };

  const startWeatherArb = (config: PolymarketWeatherConfig) => {
    isOwnerProcess = true;
    weatherArbConfig = config;

    const onReading = (readings: PolymarketWeatherReading[]) => {
      weatherArbLastReadings = readings;
      weatherArbLastCheckAt = new Date().toISOString();
      saveStatusToDisk();
    };

    writePidFile();
    saveStatusToDisk();
    runPolymarketWeatherArbTick(config, onReading).catch(console.error);

    weatherArbIntervalId = setInterval(
      () => runPolymarketWeatherArbTick(config, onReading).catch(console.error),
      (config.intervalSeconds || 120) * 1000,
    );

    const cleanup = () => {
      if (weatherArbIntervalId) { clearInterval(weatherArbIntervalId); weatherArbIntervalId = null; }
      saveStatusToDisk();
      cleanupPidFile();
    };

    process.on("SIGTERM", () => { cleanup(); process.exit(0); });
    process.on("SIGINT",  () => { cleanup(); process.exit(0); });
    process.on("exit", cleanup);
  };

  const stopWeatherArb = () => {
    if (weatherArbIntervalId) {
      clearInterval(weatherArbIntervalId);
      weatherArbIntervalId = null;
      saveStatusToDisk();
      cleanupPidFile();
    }
  };

  const startXStrategy = (xConfig: XConfig, strategyConfig: XStrategyConfig) => {
    isOwnerProcess = true;
    xStrategy.start(xConfig, strategyConfig);
    saveStatusToDisk();
  };

  const stopXStrategy = () => {
    xStrategy.stop();
    saveStatusToDisk();
  };

  const notifyXTrade = (xConfig: XConfig, strategyConfig: XStrategyConfig, summary: string) => {
    xStrategy.onTrade(xConfig, strategyConfig, summary);
  };

  return { startWeatherArb, stopWeatherArb, startXStrategy, stopXStrategy, notifyXTrade, getStatus, STATUS_FILE, PID_FILE };
};

export const strategyManager = createStrategyManager();


path: src/swap.ts
content: import { Connection, VersionedTransaction, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js"
import { getMint, getAssociatedTokenAddressSync } from "@solana/spl-token"
import { explorerTx } from "./environment.ts"
import { loadKeypair } from "./wallet.ts"
import type { SwapResult } from "./types.ts"

const RAYDIUM_SWAP_HOST = "https://transaction-v1.raydium.io"

export const SOL_MINT         = [REDACTED_SECRET]
export const USDC_MINT        = [REDACTED_SECRET]  // mainnet USDC
export const USDC_DEVNET_MINT = [REDACTED_SECRET]

export const solToLamports = (sol: number): number => Math.floor(sol * LAMPORTS_PER_SOL)

interface RaydiumSwapCompute {
  success: boolean
  data?: {
    inputMint: string
    inputAmount: string
    outputMint: string
    outputAmount: string
    slippageBps: number
    priceImpactPct: number
    routePlan: Array<{ poolId: string; inputMint: string; outputMint: string }>
  }
}

export const raydiumQuote = async (
  inputMint: string,
  outputMint: string,
  amountLamports: number,
  slippageBps = 300
): Promise<RaydiumSwapCompute> => {
  const url =
    `${RAYDIUM_SWAP_HOST}/compute/swap-base-in` +
    `?inputMint=${inputMint}` +
    `&outputMint=${outputMint}` +
    `&amount=${amountLamports}` +
    `&slippageBps=${slippageBps}` +
    `&txVersion=V0`

  const res = await fetch(url)
  if (!res.ok) throw new Error(`Raydium quote failed (${res.status}): ${await res.text()}`)
  return res.json() as Promise<RaydiumSwapCompute>
}

async function rpcPost<T>(rpcUrl: string, method: string, params: unknown[]): Promise<T> {
  const res = await fetch(rpcUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ jsonrpc: "2.0", id: Date.now(), method, params }),
  })
  const data = await res.json() as { result?: T; error?: { message: string; code?: number } }
  if (data.error) throw new Error(`RPC ${method} error: ${data.error.message}`)
  return data.result as T
}

async function simulateTx(rpcUrl: string, txBase64: string): Promise<void> {
  const result = await rpcPost<{ value: { err: unknown; logs?: string[] } }>(
    rpcUrl,
    "simulateTransaction",
    [txBase64, { encoding: "base64", commitment: "confirmed" }],
  )
  if (result.value.err) {
    const logs = result.value.logs?.slice(-5).join("\n") ?? ""
    throw new Error(`Simulation failed: ${JSON.stringify(result.value.err)}\n${logs}`)
  }
  console.log(`[RPC] Simulation OK`)
}

async function sendRawTx(rpcUrl: string, txBase64: string): Promise<string> {
  const sig = await rpcPost<string>(
    rpcUrl,
    "sendTransaction",
    [txBase64, { encoding: "base64", skipPreflight: true }],
  )
  if (!sig) throw new Error(`sendTransaction returned no signature`)
  return sig
}

// Solana transactions are regularly dropped — resend every 2s until confirmed or blockhash expires (~60s).
async function confirmTx(rpcUrl: string, signature: string, signedTxBase64: string, timeout = 60000): Promise<void> {
  const startTime = Date.now()
  let lastResend = 0
  let polls = 0

  while (Date.now() - startTime < timeout) {
    // Resend every 2s so the tx stays in validators' queues
    if (Date.now() - lastResend >= 2000) {
      sendRawTx(rpcUrl, signedTxBase64).catch(() => {}) // fire-and-forget
      lastResend = Date.now()
    }

    try {
      type StatusResult = Array<{ confirmationStatus?: string; err?: unknown } | null>
      const value = await rpcPost<{ value: StatusResult }>(
        rpcUrl,
        "getSignatureStatuses",
        [[signature]],
      )
      const entry = value.value[0]

      if (entry?.err) throw new Error(`Transaction failed on-chain: ${JSON.stringify(entry.err)}`)

      const status = entry?.confirmationStatus
      if (status === "confirmed" || status === "finalized") {
        console.log(`[RPC] ✅ Confirmed (${Math.round((Date.now() - startTime) / 1000)}s)`)
        return
      }

      polls++
      if (polls % 5 === 0) console.log(`[RPC] Waiting... ${Math.round((Date.now() - startTime) / 1000)}s (status: ${status ?? "unknown"})`)
    } catch (e) {
      if (e instanceof Error && e.message.startsWith("Transaction failed")) throw e
    }

    await new Promise(r => setTimeout(r, 1000))
  }

  throw new Error(`Transaction not confirmed within ${timeout / 1000}s — blockhash likely expired`)
}

export const raydiumSwap = async (
  walletName: string,
  inputMint: string,
  outputMint: string,
  amountLamports: number,
  slippageBps = 300,
  rpcUrl?: string,
  maxHops = Infinity,
): Promise<SwapResult> => {
  // Raydium routes sub-$1 swaps through illiquid intermediate tokens, producing ~40% worse rates.
  // 1_000_000 lamports ≈ 0.001 SOL; for token inputs 1_000_000 units = $1 USDC.
  // Enforce a floor of 1_000_000 base units (~$1) before even fetching a quote.
  if (amountLamports < 1_000_000) {
    throw new Error(
      `Trade amount ${amountLamports} is below the 1,000,000 base-unit minimum. ` +
      `Raydium routes micro-trades through illiquid intermediate pools — refusing to execute.`
    )
  }

  const rpc = rpcUrl || process.env.SOLANA_RPC_URL || "https://api.devnet.solana.com"
  const connection = new Connection(rpc, "confirmed")
  const keypair = await loadKeypair(walletName)

  console.log(`[RAYDIUM] Getting quote...`)
  const quoteResponse = await raydiumQuote(inputMint, outputMint, amountLamports, slippageBps)
  if (!quoteResponse.success) {
    throw new Error(`Raydium quote failed: ${JSON.stringify(quoteResponse)}`)
  }

  const route = quoteResponse.data!.routePlan
  const routeStr = route.map(h => `${h.inputMint.slice(0,4)}..→${h.outputMint.slice(0,4)}.. [${h.poolId.slice(0,8)}]`).join(" | ")
  console.log(`[RAYDIUM] Route (${route.length} hop${route.length !== 1 ? "s" : ""}): ${routeStr}`)
  console.log(`[RAYDIUM] Output: ${quoteResponse.data?.outputAmount}`)

  if (route.length > maxHops) {
    throw new Error(
      `Raydium routed through ${route.length} hops (max ${maxHops}). ` +
      `Refusing to execute — likely routing through illiquid intermediate tokens. ` +
      `Route: ${routeStr}`
    )
  }

  let computeUnitPrice = "1000000"
  try {
    const feeRes = await fetch(`${RAYDIUM_SWAP_HOST}/priority-fee`)
    if (feeRes.ok) {
      const feeData = await feeRes.json() as { data: { default: { h: number } } }
      computeUnitPrice = String(feeData.data.default.h)
    }
  } catch (e) {}

  // For token inputs (non-SOL), Raydium requires the ATA address explicitly.
  // Without it, their server-side account derivation fails with REQ_INPUT_ACCOUT_ERROR.
  const inputAccount = inputMint !== SOL_MINT
    ? getAssociatedTokenAddressSync(new PublicKey(inputMint), keypair.publicKey).toBase58()
    : undefined

  console.log(`[RAYDIUM] Building transaction...`)
  const swapRes = await fetch(`${RAYDIUM_SWAP_HOST}/transaction/swap-base-in`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      computeUnitPriceMicroLamports: computeUnitPrice,
      swapResponse: quoteResponse,
      txVersion: "V0",
      wallet: keypair.publicKey.toBase58(),
      wrapSol: inputMint === SOL_MINT,
      unwrapSol: outputMint === SOL_MINT,
      ...(inputAccount ? { inputAccount } : {}),
    }),
  })

  if (!swapRes.ok) throw new Error(`Raydium tx build failed (${swapRes.status}): ${await swapRes.text()}`)
  const swapData = await swapRes.json() as { success?: boolean; msg?: string; data?: Array<{ transaction: string }> }

  if (!swapData.success) throw new Error(`Raydium tx build failed: ${swapData.msg ?? "unknown error"}`)
  if (!swapData.data?.length) throw new Error(`Raydium tx build returned no transactions`)

  const txBuf = Buffer.from(swapData.data[0].transaction, "base64")
  const transaction = VersionedTransaction.deserialize(txBuf)
  transaction.sign([keypair])
  const signedBase64 = Buffer.from(transaction.serialize()).toString("base64")

  await simulateTx(rpc, signedBase64)

  const signature = await sendRawTx(rpc, signedBase64)
  console.log(`[RPC] TX: ${signature}`)

  await confirmTx(rpc, signature, signedBase64)

  const mintInfo = await getMint(connection, new PublicKey(outputMint))
  const outputAmount = parseInt(quoteResponse.data?.outputAmount || "0") / 10 ** mintInfo.decimals
  const inputAmountSol = amountLamports / LAMPORTS_PER_SOL

  return {
    signature,
    explorerUrl: explorerTx(signature, rpc),
    inputMint,
    outputMint,
    inputAmountSol,
    outputAmount,
  }
}


path: src/transfer.ts
content: import {
  Connection,
  PublicKey,
  SystemProgram,
  Transaction,
  sendAndConfirmTransaction,
  LAMPORTS_PER_SOL,
} from "@solana/web3.js"
import {
  getOrCreateAssociatedTokenAccount,
  createTransferInstruction,
  getMint,
} from "@solana/spl-token"
import { SOLANA_RPC_URL, explorerTx } from "./environment.ts"
import { loadKeypair } from "./wallet.ts"
import type { TransferResult } from "./types.ts"

const getConnection = (rpcUrl = SOLANA_RPC_URL) => new Connection(rpcUrl, "confirmed")

export const transferSOL = async (
  fromWalletName: string,
  toAddress: string,
  amountSol: number,
  rpcUrl?: string
): Promise<TransferResult> => {
  const rpc = rpcUrl ?? SOLANA_RPC_URL
  const connection = getConnection(rpc)
  const keypair = await loadKeypair(fromWalletName)
  const toPubkey = new PublicKey(toAddress)

  const tx = new Transaction().add(
    SystemProgram.transfer({
      fromPubkey: keypair.publicKey,
      toPubkey,
      lamports: Math.floor(amountSol * LAMPORTS_PER_SOL),
    })
  )

  const signature = await sendAndConfirmTransaction(connection, tx, [keypair])

  return {
    signature,
    explorerUrl: explorerTx(signature, rpc),
    from: keypair.publicKey.toBase58(),
    to: toAddress,
    amount: amountSol,
  }
}

export const transferSPL = async (
  fromWalletName: string,
  toAddress: string,
  mintAddress: string,
  amount: number,
  rpcUrl?: string
): Promise<TransferResult> => {
  const rpc = rpcUrl ?? SOLANA_RPC_URL
  const connection = getConnection(rpc)
  const keypair = await loadKeypair(fromWalletName)
  const mintPubkey = new PublicKey(mintAddress)
  const toPubkey = new PublicKey(toAddress)

  const mintInfo = await getMint(connection, mintPubkey)
  const adjustedAmount = BigInt(Math.floor(amount * 10 ** mintInfo.decimals))

  const fromATA = await getOrCreateAssociatedTokenAccount(
    connection, keypair, mintPubkey, keypair.publicKey
  )
  const toATA = await getOrCreateAssociatedTokenAccount(
    connection, keypair, mintPubkey, toPubkey
  )

  const tx = new Transaction().add(
    createTransferInstruction(fromATA.address, toATA.address, keypair.publicKey, adjustedAmount)
  )

  const signature = await sendAndConfirmTransaction(connection, tx, [keypair])

  return {
    signature,
    explorerUrl: explorerTx(signature, rpc),
    from: keypair.publicKey.toBase58(),
    to: toAddress,
    amount,
    mint: mintAddress,
  }
}


path: src/types.ts
content: export type Network = "devnet" | "mainnet-beta"

// ── Wallet Store ────────────────────────────────────────────────────────────

export type EncryptedWalletRecord = {
  name: string
  publicKey: string
  encryptedPrivateKey: string
  salt: string
  createdAt: string
  network: Network
  tags: string[]
}

export type WalletStore = {
  version: number
  wallets: Record<string, EncryptedWalletRecord>
}

export type WalletInfo = {
  name: string
  publicKey: string
  network: Network
  createdAt: string
  tags: string[]
}

// ── Balances ─────────────────────────────────────────────────────────────────

export type TokenBalance = {
  mint: string
  symbol: string
  decimals: number
  amount: bigint
  uiAmount: number
}

export type BalanceResult = {
  walletName: string
  publicKey: string
  solBalance: number
  lamports: number
  tokens: TokenBalance[]
}

// ── Transfers + Swaps ────────────────────────────────────────────────────────

export type TransferResult = {
  signature: string
  explorerUrl: string
  from: string
  to: string
  amount: number
  mint?: string
}

export type SwapResult = {
  signature: string
  explorerUrl: string
  inputMint: string
  outputMint: string
  inputAmountSol: number
  outputAmount: number
}

// ── pump.fun events ──────────────────────────────────────────────────────────

// Raw events from pumpportal.fun WebSocket (field names match their live API)
export type RawPumpEvent = Record<string, unknown> & {
  txType?: string
  mint?: string
}

export type PumpCreateEvent = {
  txType: "create"
  mint: string
  name: string
  symbol: string
  traderPublicKey: string
  initialBuy?: number
  bondingCurveKey?: string
}

export type PumpTradeEvent = {
  txType: "buy" | "sell"
  mint: string
  solAmount: number
  tokenAmount: number
  traderPublicKey: string
  newTokenBalance?: number
  bondingCurveKey?: string
}

// Graduation = token completed bonding curve, migrated to Raydium
// pump.fun may send this as txType "migrate" or a top-level "type" field
export type PumpGraduationEvent = {
  txType: "migrate" | "graduation"
  mint: string
  name: string
  symbol: string
  raydiumPool?: string
  bondingCurveKey?: string
}

export type PumpEvent = (PumpCreateEvent | PumpTradeEvent | PumpGraduationEvent) & {
  receivedAt: number   // added by us on receipt
}

// ── Screener ─────────────────────────────────────────────────────────────────

export type ScoredToken = {
  mint: string
  symbol: string
  name: string
  score: number
  reason: string
  raydiumPool?: string
  graduatedAt?: number
  source: "graduation" | "momentum"
}

export type DexScreenerConfirmation = {
  liquidityUsd: number
  volume1h: number
  priceChange1h: number
} | null

// ── Strategy ─────────────────────────────────────────────────────────────────

export type TradeDecision = {
  action: "buy" | "hold" | "skip"
  [REDACTED_SECRET] | null
  reason: string
  suggestedAmountSol: number
  targetMultiple: number
  stopLossPercent: number
}

// ── Agent ────────────────────────────────────────────────────────────────────

export type Position = {
  walletName: string
  mint: string
  symbol: string
  entryAmountSol: number
  tokensHeld: number
  openedAt: string
  targetMultiple: number
  stopLossPercent: number
  score: number
}

export type Trade = {
  walletName: string
  mint: string
  symbol: string
  action: "buy" | "sell"
  amountSol: number
  signature: string
  timestamp: string
  score: number
}

export type AgentConfig = {
  walletName: string
  intervalSeconds: number
  strategy: "3x" | "manual"
  maxRiskPercent: number
  dryRun: boolean
}

export type AgentState = {
  openPositions: Position[]
  tradeHistory: Trade[]
  lastRunAt: string | null
}

// ── Polymarket Weather Arb ────────────────────────────────────────────────────

export type PolymarketWeatherConfig = {
  walletName: string          // EVM wallet name (secp256k1, Polygon)
  cities: string[]            // e.g. ["nyc", "london", "seoul"]
  tradeAmountUsdc: number     // per trade (min $5, default $5)
  maxPositionUsdc: number     // hard cap per bracket (default $10)
  minEdge: number             // fairValue − askPrice threshold (default 0.20)
  minFairValue: number        // minimum fair probability to trade (default 0.40)
  intervalSeconds: number
  dryRun: boolean
}

export type PolymarketWeatherReading = {
  city: string
  forecastHighF: number
  sigmaF: number              // forecast uncertainty used (°F)
  targetBracket: string | null   // e.g. "40-41°F"
  bestEdge: number | null
  orderId: string | null
  skippedReason: string | null   // "no_market" | "market_closing_soon" | "no_edge" | "already_positioned" | "insufficient_usdc" | "error"
  scannedAt: number
}

// ── X / Twitter ──────────────────────────────────────────────────────────────

export type XConfig = {
  [REDACTED_SECRET]
  apiSecret: string
  accessToken: string
  accessTokenSecret: string
  bearerToken: string
}

export type Tweet = {
  id: string
  text: string
  authorId?: string
  createdAt: string
  conversationId?: string
}

export type XUser = {
  id: string
  name: string
  username: string
}

export type XStrategyConfig = {
  /** X handle of this bot account (without @) */
  handle: string
  /** Keywords to search and monitor (Basic+ tier only) */
  monitorKeywords: string[]
  /** Auto-reply to mentions with a canned response (dry_run ignores this) */
  autoReplyToMentions: boolean
  /** Automatically post a tweet when any strategy fires a trade */
  postTradeUpdates: boolean
  /** Hard cap on tweets sent per hour (default 2) */
  maxTweetsPerHour: number
  /** If true, no tweets are actually sent */
  dryRun: boolean
  /** Poll interval for mentions + keyword feed (seconds, default 60) */
  intervalSeconds: number
}

// ── Strategy Manager ─────────────────────────────────────────────────────────
export type StrategyStatus = {
  pumpfun: {
    running: boolean
    lastGraduations: number
    lastCheckAt: string | null
  }
  weather_arb: {
    running: boolean
    lastCheckAt: string | null
    cities: string[]
    lastReadings: PolymarketWeatherReading[]
  }
  x_strategy: {
    running: boolean
    lastCheckAt: string | null
    tweetsThisHour: number
    lastTweetId: string | null
  }
  _source?: "live" | "file" | "default" | "dead_daemon_cleanup"
  _stale?: boolean
}


path: src/wallet.ts
content: import { Keypair } from "@solana/web3.js"
import bs58 from "bs58"
const { encode: bs58Encode, decode: bs58Decode } = bs58
import { encryptWithPassword, decryptWithPassword, getMasterKey } from "./crypto.ts"
import { saveWallet, getWallet, listWallets as listFromStore } from "./db.ts"
import type { WalletInfo, EncryptedWalletRecord, Network } from "./types.ts"

export const createWallet = async (
  name: string,
  network: Network = "devnet",
  tags: string[] = []
): Promise<WalletInfo> => {
  const existing = await getWallet(name)
  if (existing) throw new Error(`Wallet "${name}" already exists`)

  const keypair = Keypair.generate()
  const privateKeyBs58 = bs58Encode(keypair.secretKey)

  const masterKey = await getMasterKey()
  const { encrypted, salt } = await encryptWithPassword(privateKeyBs58, masterKey)

  const record: EncryptedWalletRecord = {
    name,
    publicKey: keypair.publicKey.toBase58(),
    encryptedPrivateKey: encrypted,
    salt,
    createdAt: new Date().toISOString(),
    network,
    tags,
  }

  await saveWallet(record)

  return {
    name: record.name,
    publicKey: record.publicKey,
    network: record.network,
    createdAt: record.createdAt,
    tags: record.tags,
  }
}

// Load and decrypt a keypair into memory for signing
// Private key is never written back or logged
export const loadKeypair = async (name: string): Promise<Keypair> => {
  const record = await getWallet(name)
  if (!record) throw new Error(`Wallet "${name}" not found. Run: solana-wallet wallet list`)

  const masterKey = await getMasterKey()
  const privateKeyBs58 = await decryptWithPassword(record.encryptedPrivateKey, masterKey, record.salt)
  const secretKey = bs58Decode(privateKeyBs58)

  return Keypair.fromSecretKey(secretKey)
}

export const listWallets = async (): Promise<WalletInfo[]> => {
  const records = await listFromStore()
  return records.map(r => ({
    name: r.name,
    publicKey: r.publicKey,
    network: r.network,
    createdAt: r.createdAt,
    tags: r.tags,
  }))
}

export const getPublicKey = async (name: string): Promise<string> => {
  const record = await getWallet(name)
  if (!record) throw new Error(`Wallet "${name}" not found`)
  return record.publicKey
}


path: src/xClient.ts
content: import { TwitterApi } from "twitter-api-v2"
import type { TwitterApiReadWrite, TwitterApiReadOnly } from "twitter-api-v2"
import { TwitterApiRateLimitPlugin } from "@twitter-api-v2/plugin-rate-limit"
import type { XConfig, Tweet, XUser } from "./types.ts"

// ── Client factory ────────────────────────────────────────────────────────────

export type XClients = {
  /** OAuth 1.0a user context — required for writes (tweet, reply, like, retweet) */
  rw: TwitterApiReadWrite
  /** OAuth 2.0 app-only — efficient for searches and timeline reads */
  ro: TwitterApiReadOnly
  /** Resolved user ID for the authenticated account (lazy-populated) */
  userId: () => Promise<string>
}

let _userId: string | null = null

export const createXClients = (config: XConfig): XClients => {
  const rateLimitPlugin = new TwitterApiRateLimitPlugin()

  const userClient = new TwitterApi(
    {
      appKey:        config.apiKey,
      appSecret:     config.apiSecret,
      accessToken:   config.accessToken,
      accessSecret:  config.accessTokenSecret,
    },
    { plugins: [rateLimitPlugin] },
  )

  const appClient = new TwitterApi(config.bearerToken, { plugins: [rateLimitPlugin] })

  const rw = userClient.readWrite
  const ro = appClient.readOnly

  const userId = async (): Promise<string> => {
    if (_userId) return _userId
    const me = await rw.v2.me()
    _userId = me.data.id
    return _userId
  }

  return { rw, ro, userId }
}

// ── Write operations ──────────────────────────────────────────────────────────

export const postTweet = async (
  rw: TwitterApiReadWrite,
  text: string,
): Promise<Tweet> => {
  const res = await rw.v2.tweet(text)
  return { id: res.data.id, text: res.data.text ?? text, createdAt: new Date().toISOString() }
}

export const replyToTweet = async (
  rw: TwitterApiReadWrite,
  tweetId: string,
  text: string,
): Promise<Tweet> => {
  const res = await rw.v2.reply(text, tweetId)
  return { id: res.data.id, text: res.data.text ?? text, createdAt: new Date().toISOString() }
}

export const likeTweet = async (
  rw: TwitterApiReadWrite,
  userId: string,
  tweetId: string,
): Promise<void> => {
  await rw.v2.like(userId, tweetId)
}

export const retweetTweet = async (
  rw: TwitterApiReadWrite,
  userId: string,
  tweetId: string,
): Promise<void> => {
  await rw.v2.retweet(userId, tweetId)
}

export const deleteTweet = async (
  rw: TwitterApiReadWrite,
  tweetId: string,
): Promise<void> => {
  await rw.v2.deleteTweet(tweetId)
}

// ── Read operations ───────────────────────────────────────────────────────────

export const searchTweets = async (
  ro: TwitterApiReadOnly,
  query: string,
  maxResults = 10,
): Promise<Tweet[]> => {
  const res = await ro.v2.search(query, {
    max_results: Math.min(maxResults, 100) as 10,
    "tweet.fields": ["created_at", "author_id", "conversation_id", "in_reply_to_user_id"],
  })
  return (res.data.data ?? []).map(t => ({
    id: t.id,
    text: t.text,
    authorId: t.author_id,
    createdAt: t.created_at ?? "",
    conversationId: t.conversation_id,
  }))
}

export const getMentions = async (
  rw: TwitterApiReadWrite,
  userId: string,
  sinceId?: string,
): Promise<Tweet[]> => {
  const params: Record<string, unknown> = {
    max_results: 10,
    "tweet.fields": ["created_at", "author_id", "conversation_id"],
  }
  if (sinceId) params["since_id"] = sinceId

  const res = await rw.v2.userMentionTimeline(userId, params as any)
  return (res.data.data ?? []).map(t => ({
    id: t.id,
    text: t.text,
    authorId: t.author_id,
    createdAt: t.created_at ?? "",
    conversationId: t.conversation_id,
  }))
}

export const getUserTimeline = async (
  ro: TwitterApiReadOnly,
  userId: string,
  maxResults = 10,
): Promise<Tweet[]> => {
  const res = await ro.v2.userTimeline(userId, {
    max_results: Math.min(maxResults, 100) as 10,
    "tweet.fields": ["created_at", "author_id"],
    exclude: ["retweets", "replies"],
  })
  return (res.data.data ?? []).map(t => ({
    id: t.id,
    text: t.text,
    authorId: t.author_id,
    createdAt: t.created_at ?? "",
  }))
}

export const resolveUser = async (
  ro: TwitterApiReadOnly,
  handle: string,
): Promise<XUser> => {
  const username = handle.replace(/^@/, "")
  const res = await ro.v2.userByUsername(username, {
    "user.fields": ["name", "username", "public_metrics"],
  })
  if (!res.data) throw new Error(`User @${username} not found`)
  return {
    id: res.data.id,
    name: res.data.name,
    username: res.data.username,
  }
}


path: src/xStrategy.ts
content: /**
 * X (Twitter) strategy — polls mentions + keyword feed, posts trade updates.
 *
 * Safety rules enforced here:
 *  - Hard cap: maxTweetsPerHour (default 2) — rolling window
 *  - Never auto-likes or auto-retweets (TOS violation)
 *  - dryRun mode logs all outbound tweets without sending
 */
import { createXClients, getMentions, searchTweets, postTweet, replyToTweet } from "./xClient.ts"
import type { XConfig, XStrategyConfig, Tweet } from "./types.ts"

// ── State ─────────────────────────────────────────────────────────────────────

export type XStrategyState = {
  running: boolean
  lastCheckAt: string | null
  tweetsThisHour: number
  lastTweetId: string | null
  /** id of the last seen mention — used as since_id for incremental polling */
  lastMentionId: string | null
}

const initialState = (): XStrategyState => ({
  running: false,
  lastCheckAt: null,
  tweetsThisHour: 0,
  lastTweetId: null,
  lastMentionId: null,
})

// ── Rate-limit guard ──────────────────────────────────────────────────────────

type TweetTimestamp = number  // epoch ms

const recentTweets: TweetTimestamp[] = []

const canTweet = (maxPerHour: number): boolean => {
  const now = Date.now()
  const hourAgo = now - 60 * 60 * 1_000
  // purge old entries
  while (recentTweets.length && recentTweets[0]! < hourAgo) recentTweets.shift()
  return recentTweets.length < maxPerHour
}

const recordTweet = (): void => { recentTweets.push(Date.now()) }

// ── Jitter helper — avoids mechanical posting intervals ──────────────────────

const jitter = (ms: number, pct = 0.2): number =>
  ms + Math.floor((Math.random() * 2 - 1) * ms * pct)

// ── Main strategy tick ────────────────────────────────────────────────────────

export const runXStrategyTick = async (
  xConfig: XConfig,
  strategyConfig: XStrategyConfig,
  state: XStrategyState,
  onStateUpdate: (s: XStrategyState) => void,
): Promise<void> => {
  const { rw, ro, userId } = createXClients(xConfig)
  const next: XStrategyState = { ...state, lastCheckAt: new Date().toISOString() }

  try {
    const uid = await userId()

    // ── 1. Poll mentions ────────────────────────────────────────────────────
    let newMentions: Tweet[] = []
    try {
      newMentions = await getMentions(rw, uid, state.lastMentionId ?? undefined)
    } catch (err) {
      console.warn("[x-strategy] mentions fetch failed (rate limit or auth):", String(err))
    }

    if (newMentions.length > 0) {
      next.lastMentionId = newMentions[0]!.id
      console.log(`[x-strategy] ${newMentions.length} new mention(s)`)

      if (strategyConfig.autoReplyToMentions) {
        for (const mention of newMentions.slice(0, 2)) {  // cap at 2 replies per tick
          await safeTweet(rw, strategyConfig, next, async () => {
            const reply = buildMentionReply(mention.text)
            return replyToTweet(rw, mention.id, reply)
          })
        }
      }
    }

    // ── 2. Keyword monitoring ───────────────────────────────────────────────
    // Requires Basic+ tier — silently skip if we get a 403
    if (strategyConfig.monitorKeywords.length > 0) {
      const query = strategyConfig.monitorKeywords
        .map(k => (k.includes(" ") ? `"${k}"` : k))
        .join(" OR ")
        + " -is:retweet lang:en"

      try {
        const found = await searchTweets(ro, query, 10)
        if (found.length > 0) {
          console.log(`[x-strategy] keyword feed: ${found.length} tweet(s) matching [${strategyConfig.monitorKeywords.join(", ")}]`)
          // Log them; agent (via plugin) decides whether to act
          for (const t of found.slice(0, 3)) {
            console.log(`  @${t.authorId ?? "?"}: ${t.text.slice(0, 80)}…`)
          }
        }
      } catch (err: unknown) {
        const status = (err as { code?: number })?.code
        if (status === 403 || status === 401) {
          console.warn("[x-strategy] Keyword search requires Basic+ tier — skipping")
        } else {
          console.warn("[x-strategy] search failed:", String(err))
        }
      }
    }

  } catch (err) {
    console.error("[x-strategy] tick error:", err instanceof Error ? err.message : String(err))
  }

  // rolling tweet count
  next.tweetsThisHour = recentTweets.length
  onStateUpdate(next)
}

// ── Trade-triggered posting ───────────────────────────────────────────────────

/**
 * Called by strategyManager when any strategy fires a trade.
 * Builds a summary tweet and posts it (respects rate cap + dryRun).
 */
export const postTradeUpdate = async (
  xConfig: XConfig,
  strategyConfig: XStrategyConfig,
  state: XStrategyState,
  onStateUpdate: (s: XStrategyState) => void,
  summary: string,
): Promise<void> => {
  if (!strategyConfig.postTradeUpdates) return
  const { rw } = createXClients(xConfig)

  await safeTweet(rw, strategyConfig, state, async () => {
    const tweet = await postTweet(rw, summary)
    console.log(`[x-strategy] trade update posted: ${tweet.id}`)
    onStateUpdate({ ...state, lastTweetId: tweet.id, tweetsThisHour: recentTweets.length })
    return tweet
  })
}

// ── Helpers ───────────────────────────────────────────────────────────────────

/** Posts a tweet only if within rate cap; dry-run logs instead of sending */
const safeTweet = async (
  rw: ReturnType<typeof createXClients>["rw"],
  config: XStrategyConfig,
  state: XStrategyState,
  fn: () => Promise<Tweet>,
): Promise<Tweet | null> => {
  if (!canTweet(config.maxTweetsPerHour)) {
    console.log(`[x-strategy] rate cap hit (${config.maxTweetsPerHour}/hr) — skipping tweet`)
    return null
  }

  if (config.dryRun) {
    console.log("[x-strategy] [dry-run] would send tweet")
    return null
  }

  try {
    const tweet = await fn()
    recordTweet()
    return tweet
  } catch (err) {
    console.error("[x-strategy] tweet failed:", err instanceof Error ? err.message : String(err))
    return null
  }
}

/**
 * Builds a concise reply for a mention.
 * Keep it simple and varied to avoid template-detection.
 */
const buildMentionReply = (mentionText: string): string => {
  const templates = [
    "On it. Scanning now.",
    "Noted — checking the feed.",
    "Got it. Running analysis.",
    "Acknowledged.",
    "Reading the market. Stand by.",
  ]
  return templates[Math.floor(Math.random() * templates.length)]!
}

// ── Strategy closure (used by strategyManager) ────────────────────────────────

export const createXStrategy = () => {
  let intervalId: NodeJS.Timeout | null = null
  let currentState: XStrategyState = initialState()

  const onStateUpdate = (s: XStrategyState) => { currentState = s }

  const start = (xConfig: XConfig, strategyConfig: XStrategyConfig): void => {
    if (intervalId) return

    currentState = { ...initialState(), running: true }

    const tick = () =>
      runXStrategyTick(xConfig, strategyConfig, currentState, onStateUpdate).catch(console.error)

    tick()  // immediate first tick
    const ms = jitter(strategyConfig.intervalSeconds * 1_000)
    intervalId = setInterval(tick, ms)

    console.log(`[x-strategy] started (interval ~${Math.round(ms / 1000)}s, dryRun=${strategyConfig.dryRun})`)
  }

  const stop = (): void => {
    if (intervalId) {
      clearInterval(intervalId)
      intervalId = null
      currentState = { ...currentState, running: false }
      console.log("[x-strategy] stopped")
    }
  }

  const getState = (): XStrategyState => ({ ...currentState, running: intervalId !== null })

  const onTrade = (xConfig: XConfig, strategyConfig: XStrategyConfig, summary: string): void => {
    postTradeUpdate(xConfig, strategyConfig, currentState, onStateUpdate, summary).catch(console.error)
  }

  return { start, stop, getState, onTrade }
}


path: tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "allowImportingTsExtensions": true,
    "noEmit": true
  },
  "include": ["src/**/*", "bin/**/*", "scripts/**/*"],
  "exclude": ["node_modules", "dist"]
}


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)