Skill: binance237a-hash/solana-memecoin-trade (version 1.0.0)

# Solana Memecoin Guardian v2 ## Overview Solana Memecoin Guardian v2 is an autonomous trading skill designed to discover and trade Solana memecoins while minimizing downside risk. The system combines several intelligence layers: - Smart wallet copy trading - AI-driven market analysis - Social narrative detection (X / Twitter trends) - Strict on-chain risk validation The goal is not to chase every pump, but to selectively enter high‑probability opportunities while avoiding rug pulls and unsafe tokens. --- ## Core Capabilities ### 1. Smart Wallet Copy Trading The skill monitors high-performing Solana wallets and selectively mirrors their trades. Features: - Wallet performance filtering - Randomized delay before copying trades - Anti‑FOMO entry protection - Automatic exit when tracked wallets sell ### 2. AI Market Analysis The AI engine scans Solana tokens using data sources such as: - Pump.fun - DexScreener - On‑chain liquidity data Signals are generated using rule‑based scoring models that evaluate: - Liquidity stability - Transaction activity - Volume distribution - Price volatility - Market structure Only tokens passing strict safety filters are considered. ### 3. Narrative Detection Engine The system monitors viral narratives from social platforms such as X (Twitter). When influential accounts or trending discussions appear, related tokens are prioritized for analysis. Narrative signals increase priority but **never bypass risk validation**. ### 4. Risk Gate System Every trade must pass the Risk Gate. The system checks: - Liquidity thresholds - Mint authority status - Freeze authority status - Holder concentration - Transaction anomalies - Price impact risk If any safety rule fails, the trade is rejected. ### 5. Position Management Positions are automatically managed using: - Stop Loss - Multi‑level Take Profit - Trailing Stop - Liquidity monitoring - Emergency exit triggers ### 6. Rug Pull Protection A Rug Alarm system monitors: - Liquidity removal - Whale holder spikes - Abnormal price impact - Suspicious trading patterns If a rug signal appears, the system exits immediately. --- ## Data Sources - Pump.fun - DexScreener - Solana RPC - Smart wallet transaction streams - Social trend detection from X (Twitter) --- ## Execution Layer Trades are executed using: - Jupiter Swap API - Solana RPC - Automated transaction signing Both modes are supported: - Paper trading mode - Live trading mode --- ## Risk Philosophy Solana memecoin markets are extremely volatile. The core philosophy of this skill: Missing 100 pumps is acceptable. Surviving one rug is mandatory. Capital preservation always comes first.

path: README.md
content: # Solana Memecoin Guardian v2 (OpenClaw Skill Project)

This is a **TypeScript project skeleton** implementing BOTH:
1) **Selective Copy-Trade Engine** (smart wallet follower)  
2) **AI Self-Analysis Engine** (rule-based signals)

It runs in **paper mode** by default.

## Quick start
```bash
npm install
cp .env.example .env
npm run dev -- --mode paper --minutes 10
```

## What’s implemented in this bundle
✅ Shared Risk Gate (safe-by-default)  
✅ AI Engine (candidate scan + buy in paper mode)  
✅ Copy-Trade Engine (event handler + filters + anti-chase + budget)  
✅ Position Monitor (SL/TP1/TP2/Trailing)  
✅ Rug Alarms (LP drop proxy + impact proxy + holder spike placeholder)  
✅ Risk Budget Split (copy vs AI daily budget)  

## What is still a stub (you must wire)
- Real Pump.fun candidate feed (endpoints change)
- Real smart wallet stream/polling (Helius/Shyft/RPC logs)
- Real on-chain meta (mint/freeze authority + holders distribution)
- Live swap execution (Jupiter / your router)

Paper mode is safe: it will not send transactions.

---
⚠️ Memecoins are extremely risky. Audit everything.


path: package.json
content: {
  "name": "solana-memecoin-guardian-v2",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "tsx src/main.ts",
    "build": "tsc -p tsconfig.json",
    "start": "node dist/main.js",
    "lint": "echo \"(optional) add eslint\""
  },
  "dependencies": {
    "yaml": "^2.5.1",
    "zod": "^3.24.1",
    "node-fetch": "^3.3.2",
    "pino": "^9.3.2",
    "pino-pretty": "^11.2.2",
    "minimist": "^1.2.8",
    "@solana/web3.js": "^1.98.0",
    "bs58": "^5.0.0",
    "dotenv": "^16.4.5"
  },
  "devDependencies": {
    "tsx": "^4.19.2",
    "typescript": "^5.6.3"
  }
}

path: tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "Bundler",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "include": [
    "src/**/*.ts"
  ]
}

path: docs/solana_memecoin_guardian_v2.md
content: # Solana Memecoin Guardian v2  
## OpenClaw Skill Specification

## Overview
Solana Memecoin Guardian v2 is a dual-engine trading skill for Solana memecoins that:
- Selectively copy-trades high-quality smart wallets
- Executes independent AI-driven trades
- Applies strict risk gates and portfolio limits
- Prioritizes capital preservation over aggressive profit chasing

The system is designed to skip most opportunities and only execute when risk conditions are acceptable.

## Core Architecture
- Copy Trade Engine
- AI Analysis Engine
- Shared Risk Gate
- Position Manager
- Rug Alarm System
- Risk Budget Orchestrator

All trade paths must pass through the same Risk Gate.

## Risk Budget Allocation
```yaml
risk_budget_split:
  copy_trade_pct: 60
  ai_trade_pct: 40
```
If daily loss limit is 2.5% of bankroll:
- Copy trades: 1.5%
- AI trades: 1.0%

## Data Sources
- Pump.fun: new/trending/graduating
- DexScreener: liquidity, volume, tx count, price change, pair age, impact estimates
- Bags: smart wallet holdings + internal watchlist
- Smart wallet monitor: buy/sell events

## Risk Gate
```yaml
risk_gates:
  min_liquidity_usd: 50000
  max_entry_slippage_pct: 1.5
  cooldown_token_age_min: 15
  reject_if_mint_authority: true
  reject_if_freeze_authority: true
  max_single_holder_pct: 12
  max_top10_holders_pct: 50
  reject_if_tx_anomaly: true
  max_price_impact_pct: 1.8
```
Fail ⇒ SKIP. Missing authority/holders ⇒ SKIP.

## Copy Trade Engine
Qualification: min 30 trades, win rate ≥ 55%, profit factor ≥ 1.3

Rules:
```yaml
smart_wallets:
  max_price_chase_pct: 20
  copy_delay_sec_min: 30
  copy_delay_sec_max: 120

copy_trade_rules:
  min_age_min: 20
  max_wallet_buys_per_hour: 8
  require_trade_count_5m: 80
```

## AI Trade Engine
Rule-based scoring and stability checks.
```yaml
ai_signal:
  stability_window_min: 20
  min_tx5m: 100
  max_vol_per_tx_usd: 1200
  max_price_change_5m_pct: 15
  require_liq_non_decreasing: true
  riskScore_threshold: 30
```

## Portfolio
```yaml
portfolio:
  bankroll_usd: 1000
  max_open_positions: 3
  max_trades_per_day: 4
  daily_loss_limit_pct: 2.5
  risk_per_trade_pct_copy: 0.5
  risk_per_trade_pct_ai: 0.35
```

## Exits
```yaml
exits:
  stop_loss_pct: 10
  tp1_pct: 20
  tp1_sell_pct: 35
  tp2_pct: 60
  tp2_sell_pct: 35
  trailing_stop_pct: 12
```

## Rug alarms
```yaml
rug_alarms:
  lp_drop_pct: 25
  lp_drop_window_min: 5
  holder_spike_pct: 6
  price_impact_spike_pct: 3.0
```

## Philosophy
Missing 100 pumps is acceptable.  
Surviving one rug is mandatory.


path: docs/testing.md
content: # Testing (Paper Mode)

## Test Copy-Trade Engine using mock wallet events

1) Create a JSONL file, e.g. `mock_events.jsonl`:

```json
{"wallet":"WALLET_A","side":"BUY","mint":"<TOKEN_MINT>","ts":1700000000000,"priceRefUsd":0.0012}
{"wallet":"WALLET_A","side":"SELL","mint":"<TOKEN_MINT>","ts":1700000009999,"priceRefUsd":0.0018}
```

2) Run with env var:
```bash
export MOCK_WALLET_EVENTS_JSONL=./mock_events.jsonl
npm run dev -- --mode paper --minutes 10
```

The skill will apply:
- Delay (30–120s)
- Anti-chase (max 20%)
- Risk gate
- Extra copy filters (age, tx count)
- Then simulate BUY/SELL.


path: docs/live_setup.md
content: # Live Setup (Helius + Jupiter)

## 1) Environment
Create `.env` from `.env.example` and fill:

- `SOLANA_RPC_URL`
- `HELIUS_API_KEY` (+ optional `HELIUS_BASE_URL`)
- `JUP_API_KEY` (+ optional `JUP_BASE_URL`)
- `WALLET_PRIVATE_KEY_BASE58` (recommended) or `WALLET_PRIVATE_KEY_JSON`

## 2) What live mode does
- Smart-wallet monitoring via **Helius Enhanced Transactions** (`GET /v0/addresses/{address}/transactions`)  
- Swaps via **Jupiter Metis Swap API**:
  - `GET /swap/v1/quote`
  - `POST /swap/v1/swap`
- USD sizing is converted to SOL using **Jupiter Price API V3** (`GET /price/v3?ids=...`)

## 3) Run
```bash
npm install
cp .env.example .env
# edit .env
npm run dev -- --mode live --minutes 30
```

## 4) Safety notes
- If mint/freeze/holders cannot be fetched via RPC => RiskGate rejects.
- Helius swap parsing is best-effort; always audit the event mapping.
- Sells require `tokenAmountRaw` from Jupiter quote. If missing, exits will warn and skip (safer than blind selling).


path: docs/narrative_setup.md
content: # Narrative Engine Setup (X / Twitter)

This engine lets the bot enter based on viral narratives, e.g. posts from high-impact accounts like @elonmusk.

## Use official interfaces
X’s filtered stream lets you stream posts matching rules, and supports rules like `from:elonmusk`.  
See X docs for endpoints and examples.

## Setup
1) Put your Bearer token in `.env`:
- X_BEARER_TOKEN=...

2) Add narratives in `config/narratives.yaml` (manual mapping is safest).

3) Run:
```bash
npm run dev -- --mode paper --minutes 10
npm run dev -- --mode live --minutes 30
```


path: config/skill.yaml
content: # Main configuration for Solana Memecoin Guardian v2
skill_name: solana_memecoin_guardian_v2
chain: solana

engines:
  copy_trade: true
  ai_trade: true

sources:
  pumpfun:
    enabled: true
    mode: ["new", "trending", "graduating"]
    poll_seconds: 30
  dexscreener:
    enabled: true
    poll_seconds: 20

bags:
  enabled: true
  mode: ["smart_wallet_holdings", "internal_watchlist"]

smart_wallets:
  enabled: true
  min_trades: 30
  min_winrate: 0.55
  min_profit_factor: 1.30
  copy_delay_sec_min: 30
  copy_delay_sec_max: 120
  max_price_chase_pct: 20

risk_budget_split:
  copy_trade_pct: 60
  ai_trade_pct: 40

risk_gates:
  min_liquidity_usd: 50000
  max_entry_slippage_pct: 1.5
  max_price_impact_pct: 1.8
  cooldown_token_age_min: 15
  reject_if_mint_authority: true
  reject_if_freeze_authority: true
  max_single_holder_pct: 12
  max_top10_holders_pct: 50
  reject_if_tx_anomaly: true

copy_trade_rules:
  min_age_min: 20
  max_wallet_buys_per_hour: 8
  require_trade_count_5m: 80

ai_signal:
  stability_window_min: 20
  min_tx5m: 100
  max_vol_per_tx_usd: 1200
  max_price_change_5m_pct: 15
  require_liq_non_decreasing: true
  riskScore_threshold: 30

portfolio:
  bankroll_usd: 1000
  max_open_positions: 3
  max_trades_per_day: 4
  daily_loss_limit_pct: 2.5
  risk_per_trade_pct_copy: 0.5
  risk_per_trade_pct_ai: 0.35
  max_position_liquidity_pct: 1.0 # sizeUsd <= 1% liquidityUsd

exits:
  stop_loss_pct: 10
  tp1_pct: 20
  tp1_sell_pct: 35
  tp2_pct: 60
  tp2_sell_pct: 35
  trailing_stop_pct: 12

rug_alarms:
  lp_drop_pct: 25
  lp_drop_window_min: 5
  holder_spike_pct: 6
  price_impact_spike_pct: 3.0

monitor:
  poll_seconds: 10

execution:
  mode_default: paper # paper|live
  router: jupiter
  confirm_level: confirmed
  quote_retries: 2

execution_live:
  default_input_mint: So11111111111111111111111111111111111111112 # wSOL
  slippage_bps: 80
  restrict_intermediate_tokens: true
  only_direct_routes: false

ignore_mints:
  - So11111111111111111111111111111111111111112
  - EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v

narrative:
  enabled: true
  x_stream:
    enabled: true
    rules_from_config: true
    extra_rules:
      - "from:elonmusk -is:retweet"
    reconnect_min_seconds: 5
    reconnect_max_seconds: 60

  scoring:
    min_hype_score: 10
    hot_window_minutes: 60
    max_hot_mints: 20

  # Narrative signals only influence candidate selection and scoring.
  # All trades still must pass Risk Gate.


path: config/smart_wallets.json
content: {
  "wallets": [
    {
      "address": "WALLET_A",
      "label": "smart-wallet-a"
    },
    {
      "address": "WALLET_B",
      "label": "smart-wallet-b"
    }
  ]
}

path: config/internal_watchlist.txt
content: # Put one mint per line (optional)
# Example:
# So11111111111111111111111111111111111111112


path: config/narratives.yaml
content: # Narrative-to-token mapping (safe default: manual mapping)
# Add narratives you want to follow and map them to Solana mints or keywords.
#
# Example:
# narratives:
#   - id: "elon_meme"
#     description: "Elon tweets about meme narrative"
#     x_rules:
#       - "from:elonmusk (meme OR doge OR dogecoin OR pepe) -is:retweet"
#     keywords: ["doge","dogecoin","meme","pepe"]
#     mints:
#       - "<SOLANA_TOKEN_MINT>"
#     boost:
#       score_bonus: 8
#
narratives: []


path: src/types.ts
content: export type Mode = "paper" | "live";

export type Candidate = {
  mint: string;
  sourceTags: string[];
};

export type DexMetrics = {
  mint: string;
  pairAddress?: string;
  liqUsd: number;
  vol5mUsd: number;
  tx5m: number;
  priceChange5mPct: number;
  pairAgeMin: number;
  priceImpactPct?: number;
  currentPriceUsd?: number;
};

export type TokenMeta = {
  mint: string;
  mintAuthorityActive: boolean | null;
  freezeAuthorityActive: boolean | null;
  singleHolderPct: number | null;
  top10HoldersPct: number | null;
};

export type WalletEvent = {
  wallet: string;
  side: "BUY" | "SELL";
  mint: string;
  ts: number;
  priceRefUsd?: number;
};

export type PositionTag = "COPY" | "AI";

export type Position = {
  mint: string;
  tag: PositionTag;
  entryPriceUsd: number;
  sizeUsd: number;
  openTs: number;
  stopLossUsd: number;
  tp1Usd: number;
  tp2Usd: number;
  trailingStopPct: number;
  remainingPct: number; // 0..100
  peakPriceUsd: number;
  tokenAmountRaw?: string; // raw units as string
  tokenDecimals?: number;
};

export type Decision = {
  action: "BUY" | "SELL" | "SKIP";
  reason: string;
  mint: string;
  tag?: PositionTag;
  sizeUsd?: number;
};


path: src/logger.ts
content: import pino from "pino";
export const logger = pino({
  level: process.env.LOG_LEVEL || "info",
  transport: process.env.NODE_ENV !== "production" ? { target: "pino-pretty" } : undefined,
});


path: src/config.ts
content: import fs from "node:fs";
import YAML from "yaml";
import { z } from "zod";

const SkillConfigSchema = z.object({
  skill_name: z.string(),
  chain: z.string(),
  sources: z.any(),
  bags: z.any(),
  smart_wallets: z.any(),
  risk_budget_split: z.any(),
  risk_gates: z.any(),
  copy_trade_rules: z.any(),
  ai_signal: z.any(),
  portfolio: z.any(),
  exits: z.any(),
  rug_alarms: z.any(),
  execution: z.any(),
});

export type SkillConfig = z.infer<typeof SkillConfigSchema>;

export function loadConfig(path = "config/skill.yaml"): SkillConfig {
  const raw = fs.readFileSync(path, "utf-8");
  const parsed = YAML.parse(raw);
  return SkillConfigSchema.parse(parsed);
}

export function loadSmartWallets(path = "config/smart_wallets.json"): { wallets: { address: string; label?: string }[] } {
  const raw = fs.readFileSync(path, "utf-8");
  return JSON.parse(raw);
}


path: src/riskGate.ts
content: import { DexMetrics, TokenMeta } from "./types.js";

export type GateResult = { reject: boolean; reason?: string };

export function riskGate(metrics: DexMetrics, meta: TokenMeta, cfg: any): GateResult {
  const g = cfg.risk_gates;

  if (metrics.liqUsd < g.min_liquidity_usd) return { reject: true, reason: "liquidityTooLow" };
  if (metrics.pairAgeMin < g.cooldown_token_age_min) return { reject: true, reason: "tokenTooNew" };

  // Missing critical data => reject (safe default)
  if (g.reject_if_mint_authority && meta.mintAuthorityActive === null) return { reject: true, reason: "mintAuthorityUnknown" };
  if (g.reject_if_freeze_authority && meta.freezeAuthorityActive === null) return { reject: true, reason: "freezeAuthorityUnknown" };
  if (meta.singleHolderPct === null) return { reject: true, reason: "holdersUnknown" };
  if (meta.top10HoldersPct === null) return { reject: true, reason: "holdersUnknown" };

  if (g.reject_if_mint_authority && meta.mintAuthorityActive) return { reject: true, reason: "mintAuthorityActive" };
  if (g.reject_if_freeze_authority && meta.freezeAuthorityActive) return { reject: true, reason: "freezeAuthorityActive" };

  if (meta.singleHolderPct > g.max_single_holder_pct) return { reject: true, reason: "singleHolderTooHigh" };
  if (meta.top10HoldersPct > g.max_top10_holders_pct) return { reject: true, reason: "top10TooHigh" };

  if (g.reject_if_tx_anomaly) {
    // simple anomaly proxy: high volume but too few tx
    const volPerTx = metrics.tx5m > 0 ? metrics.vol5mUsd / metrics.tx5m : metrics.vol5mUsd;
    if (metrics.tx5m < 10 && metrics.vol5mUsd > 10_000) return { reject: true, reason: "txAnomalyFewTxHighVol" };
    if (volPerTx > 5_000) return { reject: true, reason: "txAnomalyWhalePrints" };
  }

  return { reject: false };
}


path: src/scoring.ts
content: import { DexMetrics, TokenMeta } from "./types.js";

export function riskScore(metrics: DexMetrics, meta: TokenMeta): number {
  // 0..100 higher is worse
  let score = 0;

  // Liquidity penalty (0..25)
  if (metrics.liqUsd < 200_000) score += Math.min(25, (200_000 - metrics.liqUsd) / 8_000);

  // Holder concentration (0..25)
  if (meta.singleHolderPct !== null) score += Math.min(15, Math.max(0, meta.singleHolderPct - 5) * 1.5);
  if (meta.top10HoldersPct !== null) score += Math.min(10, Math.max(0, meta.top10HoldersPct - 35) * 0.5);

  // Slippage/impact proxy via vol/tx and price change (0..20)
  const volPerTx = metrics.tx5m > 0 ? metrics.vol5mUsd / metrics.tx5m : metrics.vol5mUsd;
  score += Math.min(10, Math.max(0, (volPerTx - 800) / 200));
  score += Math.min(10, Math.max(0, Math.abs(metrics.priceChange5mPct) - 15) * 0.5);

  // Age penalty (0..10)
  if (metrics.pairAgeMin < 60) score += Math.min(10, (60 - metrics.pairAgeMin) / 6);

  // Tx anomaly (0..15)
  if (metrics.tx5m < 50) score += Math.min(15, (50 - metrics.tx5m) / 3);

  return Math.max(0, Math.min(100, Math.round(score)));
}

export function aiSignal(metrics: DexMetrics, cfg: any): { buy: boolean; reason: string } {
  const s = cfg.ai_signal;
  const volPerTx = metrics.tx5m > 0 ? metrics.vol5mUsd / metrics.tx5m : metrics.vol5mUsd;

  if (metrics.tx5m < s.min_tx5m) return { buy: false, reason: "txTooLow" };
  if (volPerTx > s.max_vol_per_tx_usd) return { buy: false, reason: "whalePrintLikely" };
  if (Math.abs(metrics.priceChange5mPct) > s.max_price_change_5m_pct) return { buy: false, reason: "tooSpiky" };

  return { buy: true, reason: "stableFlow" };
}


path: src/position.ts
content: import { Position, PositionTag } from "./types.js";

export function computeSizeUsd(bankrollUsd: number, riskPerTradePct: number, stopLossPct: number): number {
  const riskUsd = bankrollUsd * (riskPerTradePct / 100);
  const sizeUsd = (riskUsd / stopLossPct) * 100;
  return Math.max(0, sizeUsd);
}

export function openPosition(opts: {
  mint: string;
  tag: PositionTag;
  entryPriceUsd: number;
  sizeUsd: number;
  exits: any;
  tokenAmountRaw?: string;
  tokenDecimals?: number;
}): Position {
  const { stop_loss_pct, tp1_pct, tp2_pct, trailing_stop_pct } = opts.exits;
  const stopLossUsd = opts.entryPriceUsd * (1 - stop_loss_pct / 100);
  const tp1Usd = opts.entryPriceUsd * (1 + tp1_pct / 100);
  const tp2Usd = opts.entryPriceUsd * (1 + tp2_pct / 100);

  return {
    mint: opts.mint,
    tag: opts.tag,
    entryPriceUsd: opts.entryPriceUsd,
    sizeUsd: opts.sizeUsd,
    openTs: Date.now(),
    stopLossUsd,
    tp1Usd,
    tp2Usd,
    trailingStopPct: trailing_stop_pct,
    remainingPct: 100,
    peakPriceUsd: opts.entryPriceUsd,
    tokenAmountRaw: opts.tokenAmountRaw,
    tokenDecimals: opts.tokenDecimals,
  };
}


path: src/vaultRules.ts
content: export function halted(state: {
  dailyPnlPct: number;
  tradesToday: number;
  openPositions: number;
}, cfg: any): { stop: boolean; reason?: string } {
  const p = cfg.portfolio;
  if (state.dailyPnlPct <= -p.daily_loss_limit_pct) return { stop: true, reason: "dailyLossLimit" };
  if (state.tradesToday >= p.max_trades_per_day) return { stop: true, reason: "maxTradesPerDay" };
  if (state.openPositions >= p.max_open_positions) return { stop: true, reason: "maxOpenPositions" };
  return { stop: false };
}


path: src/main.ts
content: import minimist from "minimist";
import { loadConfig, loadSmartWallets } from "./config.js";
import { logger } from "./logger.js";
import { fetchPumpfunCandidates } from "./providers/pumpfun.js";
import { resolveDexScreener } from "./providers/dexscreener.js";
import { fetchTokenMeta } from "./providers/solanaRpc.js";
import { startWalletStream } from "./providers/walletStream.js";
import { riskGate } from "./riskGate.js";
import { aiSignal, riskScore } from "./scoring.js";
import { computeSizeUsd, openPosition } from "./position.js";
import { buySim, sellSim, buyLiveByUsd, sellLiveExactIn } from "./providers/swapExecutor.js";
import { halted } from "./vaultRules.js";
import { engineHalted } from "./budget.js";
import { monitorPositions } from "./monitor.js";
import { NarrativeTracker, loadNarratives } from "./narrative.js";
import { startXFilteredStream, xAddRules } from "./providers/xFilteredStream.js";
import type { Mode, Position, WalletEvent } from "./types.js";

function sleep(ms: number) { return new Promise(res => setTimeout(res, ms)); }
function randInt(min: number, max: number) { return Math.floor(Math.random() * (max - min + 1)) + min; }

function priceChaseTooHigh(ref: number | undefined, now: number | undefined, maxPct: number): boolean {
  if (!ref || !now || !Number.isFinite(ref) || !Number.isFinite(now)) return false;
  const pct = ((now - ref) / ref) * 100;
  return pct > maxPct;
}

async function run() {
  const args = minimist(process.argv.slice(2));
  const mode = (args.mode || "paper") as Mode;
  const minutes = Number(args.minutes || 10);
  const cfg = loadConfig();
  const sw = loadSmartWallets();

  const narrativesCfg = loadNarratives();
  const narrativeTracker = new NarrativeTracker(cfg, narrativesCfg.narratives || []);

  let stopX: null | (() => void) = null;
  if (cfg.narrative?.enabled && cfg.narrative?.x_stream?.enabled && process.env.X_BEARER_TOKEN) {
    const ruleValues: Array<{ value: string; tag?: string }> = [];
    if (cfg.narrative.x_stream.rules_from_config) {
      for (const n of (narrativesCfg.narratives || [])) {
        for (const r of (n.x_rules || [])) ruleValues.push({ value: r, tag: n.id });
      }
    }
    for (const r of (cfg.narrative.x_stream.extra_rules || [])) ruleValues.push({ value: r, tag: "extra" });
    try { await xAddRules(ruleValues); } catch (err) { logger.warn({ err }, "X: add rules failed (continue)"); }

    stopX = startXFilteredStream({
      onEvent: (e) => narrativeTracker.ingestX(e),
      reconnectMinSeconds: cfg.narrative.x_stream.reconnect_min_seconds ?? 5,
      reconnectMaxSeconds: cfg.narrative.x_stream.reconnect_max_seconds ?? 60,
    });
  }


  logger.info({ mode, minutes }, "Starting Solana Memecoin Guardian v2 (COPY + AI + MONITOR)");

  const state = {
    dailyPnlPct: 0,
    tradesToday: 0,
    openPositions: new Map<string, Position>(),
    // separate engine PnL budgets (paper mode tracks realized as 0 in this skeleton)
    budgets: { pnlCopyPct: 0, pnlAiPct: 0 },
    // track wallet buy frequency per hour
    walletBuyTimestamps: new Map<string, number[]>(),
    // liquidity snapshots for rug alarms
    liqSnapshot: new Map<string, { liqUsd: number; ts: number }>(),
  };

  // COPY-TRADE handler
  const onWalletEvent = async (e: WalletEvent) => {
    if (!cfg.engines?.copy_trade) return;
    if (!cfg.smart_wallets.enabled) return;

    const hBudget = engineHalted("COPY", state.budgets, cfg);
    if (hBudget.stop) {
      logger.warn({ reason: hBudget.reason }, "COPY engine halted by budget");
      return;
    }

    // Global vault rules also apply
    const h = halted({ dailyPnlPct: state.dailyPnlPct, tradesToday: state.tradesToday, openPositions: state.openPositions.size }, cfg);
    if (h.stop) return;

    if (e.side === "SELL") {
      const pos = state.openPositions.get(e.mint);
      if (!pos) return;

      if (mode === "paper") {
        await sellSim(e.mint, pos.remainingPct);
      } else {
        if (!pos.tokenAmountRaw) {
          logger.warn({ mint: e.mint }, "LIVE SELL skipped: missing tokenAmountRaw");
        } else {
          const amt = BigInt(pos.tokenAmountRaw);
          const sellAmt = (amt * BigInt(Math.round(pos.remainingPct))) / BigInt(100);
          await sellLiveExactIn({
            inputMint: e.mint,
            outputMint: [REDACTED_SECRET],
            inAmountRaw: sellAmt.toString(),
            slippageBps: cfg.execution_live?.slippage_bps ?? 80,
            restrictIntermediateTokens: cfg.execution_live?.restrict_intermediate_tokens ?? true,
            onlyDirectRoutes: cfg.execution_live?.only_direct_routes ?? false,
          });
        }
      }
      state.openPositions.delete(e.mint);
      logger.info({ mint: e.mint, wallet: e.wallet }, "COPY: wallet SOLD -> EXIT position");
      return;
    }

    if (e.side !== "BUY") return;
    if (state.openPositions.has(e.mint)) return;
    if (state.openPositions.size >= cfg.portfolio.max_open_positions) return;

    // Enforce max wallet buys/hour (anti spray)
    const arr = state.walletBuyTimestamps.get(e.wallet) ?? [];
    const cutoff = Date.now() - 60 * 60 * 1000;
    const filtered = arr.filter(ts => ts >= cutoff);
    filtered.push(Date.now());
    state.walletBuyTimestamps.set(e.wallet, filtered);
    if (filtered.length > cfg.copy_trade_rules.max_wallet_buys_per_hour) {
      logger.info({ wallet: e.wallet, n: filtered.length }, "COPY: wallet buying too frequently -> SKIP");
      return;
    }

    // Delay
    const delay = randInt(cfg.smart_wallets.copy_delay_sec_min, cfg.smart_wallets.copy_delay_sec_max) * 1000;
    await sleep(delay);

    const m = await resolveDexScreener(e.mint);
    if (!m) return;

    // anti chase
    if (priceChaseTooHigh(e.priceRefUsd, m.currentPriceUsd, cfg.smart_wallets.max_price_chase_pct)) {
      logger.info({ mint: e.mint }, "COPY: price chased too high -> SKIP");
      return;
    }

    // extra copy filters: age & tx count
    if (m.pairAgeMin < cfg.copy_trade_rules.min_age_min) {
      logger.info({ mint: e.mint, ageMin: m.pairAgeMin }, "COPY: token too new -> SKIP");
      return;
    }
    if (m.tx5m < cfg.copy_trade_rules.require_trade_count_5m) {
      logger.info({ mint: e.mint, tx5m: m.tx5m }, "COPY: tx5m too low -> SKIP");
      return;
    }

    const meta = await fetchTokenMeta(e.mint);
    const gate = riskGate(m, meta, cfg);
    if (gate.reject) {
      logger.info({ mint: e.mint, reason: gate.reason }, "COPY: RiskGate reject -> SKIP");
      return;
    }

    // size for COPY engine
    const bankroll = cfg.portfolio.bankroll_usd as number;
    const riskPct = cfg.portfolio.risk_per_trade_pct_copy as number;
    const sizeUsdRaw = computeSizeUsd(bankroll, riskPct, cfg.exits.stop_loss_pct);
    const cap = (cfg.portfolio.max_position_liquidity_pct / 100) * m.liqUsd;
    const sizeUsd = Math.min(sizeUsdRaw, cap);

    if (sizeUsd <= 5) {
      logger.info({ mint: e.mint, sizeUsd }, "COPY: size too small -> SKIP");
      return;
    }

    const entryPrice = m.currentPriceUsd ?? NaN;
    if (!Number.isFinite(entryPrice)) {
      logger.info({ mint: e.mint }, "COPY: missing price -> SKIP");
      return;
    }

    if (mode === "paper") {
      await buySim(e.mint, sizeUsd);
    } else {
      logger.warn("LIVE mode not implemented yet. Wire swap executor.");
      return;
    }

    const pos = openPosition({ mint: e.mint, tag: "COPY", entryPriceUsd: entryPrice, sizeUsd, exits: cfg.exits });
    state.openPositions.set(e.mint, pos);
    state.tradesToday += 1;
    logger.info({ mint: e.mint, sizeUsd, entryPrice, wallet: e.wallet }, "COPY: OPEN_POSITION");
  };

  // Start wallet stream (can be fed by MOCK_WALLET_EVENTS_JSONL in paper mode)
  const stopWallet = startWalletStream(sw.wallets.map(w => w.address), onWalletEvent);

  const t0 = Date.now();
  while ((Date.now() - t0) < minutes * 60_000) {
    const h = halted({ dailyPnlPct: state.dailyPnlPct, tradesToday: state.tradesToday, openPositions: state.openPositions.size }, cfg);
    if (h.stop) {
      logger.warn({ reason: h.reason }, "HALT");
      break;
    }

    // MONITOR positions (exits + rug alarms)
    await monitorPositions(state.openPositions, cfg, mode, state.liqSnapshot);

    // AI discovery loop
    if (cfg.engines?.ai_trade) {
      const hAi = engineHalted("AI", state.budgets, cfg);
      if (!hAi.stop) {
        const candidates = await fetchPumpfunCandidates(); // stub by default
        const hot = narrativeTracker.getHotMints();
        for (const hm of hot) {
          candidates.unshift({ mint: hm.mint, sourceTags: ["narrative"] });
        }
        for (const c of candidates.slice(0, 50)) {
          const h2 = halted({ dailyPnlPct: state.dailyPnlPct, tradesToday: state.tradesToday, openPositions: state.openPositions.size }, cfg);
          if (h2.stop) break;
          if (state.openPositions.has(c.mint)) continue;

          const m = await resolveDexScreener(c.mint);
          if (!m) continue;

          const meta = await fetchTokenMeta(c.mint);
          const gate = riskGate(m, meta, cfg);
          if (gate.reject) continue;

          const rs = riskScore(m, meta);
          if (rs > cfg.ai_signal.riskScore_threshold) continue;

          const sig = aiSignal(m, cfg);
          if (!sig.buy) continue;

          const entryPrice = m.currentPriceUsd ?? NaN;
          if (!Number.isFinite(entryPrice)) continue;

          const bankroll = cfg.portfolio.bankroll_usd as number;
          const riskPct = cfg.portfolio.risk_per_trade_pct_ai as number;
          const sizeUsdRaw = computeSizeUsd(bankroll, riskPct, cfg.exits.stop_loss_pct);
          const cap = (cfg.portfolio.max_position_liquidity_pct / 100) * m.liqUsd;
          const sizeUsd = Math.min(sizeUsdRaw, cap);

          if (sizeUsd <= 5) continue;

          if (mode === "paper") {
            await buySim(c.mint, sizeUsd);
            const pos = openPosition({ mint: c.mint, tag: "AI", entryPriceUsd: entryPrice, sizeUsd, exits: cfg.exits });
            state.openPositions.set(c.mint, pos);
          } else {
            const live = await buyLiveByUsd({
              outputMint: c.mint,
              usdAmount: sizeUsd,
              slippageBps: cfg.execution_live?.slippage_bps ?? 80,
              restrictIntermediateTokens: cfg.execution_live?.restrict_intermediate_tokens ?? true,
              onlyDirectRoutes: cfg.execution_live?.only_direct_routes ?? false,
            });
            const pos = openPosition({
              mint: c.mint,
              tag: "AI",
              entryPriceUsd: entryPrice,
              sizeUsd,
              exits: cfg.exits,
              tokenAmountRaw: live.outAmountRaw,
              tokenDecimals: live.outDecimals,
            });
            state.openPositions.set(c.mint, pos);
          }

          state.tradesToday += 1;
          logger.info({ mint: c.mint, sizeUsd, entryPrice }, "AI: OPEN_POSITION");
          state.openPositions.set(c.mint, pos);
          state.tradesToday += 1;
          logger.info({ mint: c.mint, sizeUsd, entryPrice }, "AI: OPEN_POSITION");
        }
      } else {
        logger.warn({ reason: hAi.reason }, "AI engine halted by budget");
      }
    }

    await sleep((cfg.monitor?.poll_seconds ?? 10) * 1000);
  }

  stopWallet();
  if (stopX) stopX();
  logger.info("Done.");
}

run().catch(err => {
  // eslint-disable-next-line no-console
  console.error(err);
  process.exit(1);
});


path: src/budget.ts
content: export type EngineTag = "COPY" | "AI";

export type BudgetState = {
  // pnl in percentage of bankroll (negative means loss)
  pnlCopyPct: number;
  pnlAiPct: number;
};

export function budgetLimits(cfg: any): { copyMaxLossPct: number; aiMaxLossPct: number } {
  const daily = cfg.portfolio.daily_loss_limit_pct as number;
  const split = cfg.risk_budget_split;
  const copyMaxLossPct = daily * (split.copy_trade_pct / 100);
  const aiMaxLossPct = daily * (split.ai_trade_pct / 100);
  return { copyMaxLossPct, aiMaxLossPct };
}

export function engineHalted(tag: EngineTag, b: BudgetState, cfg: any): { stop: boolean; reason?: string } {
  const lim = budgetLimits(cfg);
  if (tag === "COPY" && b.pnlCopyPct <= -lim.copyMaxLossPct) return { stop: true, reason: "copyBudgetExceeded" };
  if (tag === "AI" && b.pnlAiPct <= -lim.aiMaxLossPct) return { stop: true, reason: "aiBudgetExceeded" };
  return { stop: false };
}


path: src/monitor.ts
content: import { Position } from "./types.js";
import { resolveDexScreener } from "./providers/dexscreener.js";
import { logger } from "./logger.js";
import { sellSim, sellLiveExactIn } from "./providers/swapExecutor.js";

export type CloseResult = { closed: boolean; reason?: string; txid?: string; pctSold?: number };

function pctChange(a: number, b: number): number {
  if (!Number.isFinite(a) || a === 0) return 0;
  return ((b - a) / a) * 100;
}

export async function monitorPositions(
  positions: Map<string, Position>,
  cfg: any,
  mode: "paper" | "live",
  // For rug alarm deltas, we store last seen liquidity per mint
  liqSnapshot: Map<string, { liqUsd: number; ts: number }>
): Promise<Array<{ mint: string; reason: string }>> {
  const events: Array<{ mint: string; reason: string }> = [];
  const now = Date.now();

  for (const [mint, pos] of positions.entries()) {
    const m = await resolveDexScreener(mint);
    if (!m || !Number.isFinite(m.currentPriceUsd ?? NaN)) continue;

    const price = m.currentPriceUsd as number;

    // Update peak for trailing
    if (price > pos.peakPriceUsd) pos.peakPriceUsd = price;

    // Rug alarms: liquidity drop in window
    const prev = liqSnapshot.get(mint);
    liqSnapshot.set(mint, { liqUsd: m.liqUsd, ts: now });
    if (prev) {
      const ageMin = (now - prev.ts) / 60000;
      if (ageMin <= cfg.rug_alarms.lp_drop_window_min) {
        const dropPct = -pctChange(prev.liqUsd, m.liqUsd);
        if (dropPct >= cfg.rug_alarms.lp_drop_pct) {
          // Exit all
          if (mode === "paper") {
            await sellSim(mint, pos.remainingPct);
          } else {
            if (pos.tokenAmountRaw) {
              const amt = BigInt(pos.tokenAmountRaw);
              const sellAmt = (amt * BigInt(Math.round(pos.remainingPct))) / BigInt(100);
              await sellLiveExactIn({
                inputMint: mint,
                outputMint: [REDACTED_SECRET],
                inAmountRaw: sellAmt.toString(),
                slippageBps: cfg.execution_live?.slippage_bps ?? 80,
                restrictIntermediateTokens: cfg.execution_live?.restrict_intermediate_tokens ?? true,
                onlyDirectRoutes: cfg.execution_live?.only_direct_routes ?? false,
              });
            } else {
              logger.warn({ mint }, "LIVE EXIT skipped: missing tokenAmountRaw");
            }
          }
          positions.delete(mint);
          events.push({ mint, reason: `RUG_ALARM_LP_DROP_${dropPct.toFixed(1)}%` });
          logger.warn({ mint, dropPct }, "RUG_ALARM: LP drop => EXIT ALL");
          continue;
        }
      }
    }

    // Stop loss
    if (price <= pos.stopLossUsd) {
      if (mode === "paper") {
        await sellSim(mint, pos.remainingPct);
      } else {
        if (pos.tokenAmountRaw) {
          const amt = BigInt(pos.tokenAmountRaw);
          const sellAmt = (amt * BigInt(Math.round(pos.remainingPct))) / BigInt(100);
          await sellLiveExactIn({
            inputMint: mint,
            outputMint: [REDACTED_SECRET],
            inAmountRaw: sellAmt.toString(),
            slippageBps: cfg.execution_live?.slippage_bps ?? 80,
            restrictIntermediateTokens: cfg.execution_live?.restrict_intermediate_tokens ?? true,
            onlyDirectRoutes: cfg.execution_live?.only_direct_routes ?? false,
          });
        } else {
          logger.warn({ mint }, "LIVE EXIT skipped: missing tokenAmountRaw");
        }
      }
      positions.delete(mint);
      events.push({ mint, reason: "STOP_LOSS" });
      logger.info({ mint }, "STOP_LOSS => EXIT");
      continue;
    }

    // TP1 / TP2 partials
    if (pos.remainingPct > 0) {
      if (price >= pos.tp2Usd && pos.remainingPct > (100 - cfg.exits.tp1_sell_pct - cfg.exits.tp2_sell_pct)) {
        // sell TP2 chunk once
        const pct = cfg.exits.tp2_sell_pct;
        if (mode === "paper") await sellSim(mint, pct);
        pos.remainingPct = Math.max(0, pos.remainingPct - pct);
        events.push({ mint, reason: "TAKE_PROFIT_2" });
        logger.info({ mint, pct }, "TP2 partial sell");
      } else if (price >= pos.tp1Usd && pos.remainingPct === 100) {
        const pct = cfg.exits.tp1_sell_pct;
        if (mode === "paper") await sellSim(mint, pct);
        pos.remainingPct = Math.max(0, pos.remainingPct - pct);
        events.push({ mint, reason: "TAKE_PROFIT_1" });
        logger.info({ mint, pct }, "TP1 partial sell");
      }
    }

    // Trailing stop on remaining
    if (pos.remainingPct > 0) {
      const trailStop = pos.peakPriceUsd * (1 - pos.trailingStopPct / 100);
      if (price <= trailStop && pos.peakPriceUsd > pos.entryPriceUsd) {
        if (mode === "paper") await sellSim(mint, pos.remainingPct);
        positions.delete(mint);
        events.push({ mint, reason: "TRAILING_STOP" });
        logger.info({ mint }, "TRAILING_STOP => EXIT");
        continue;
      }
    }
  }

  return events;
}


path: src/narrative.ts
content: import fs from "node:fs";
import YAML from "yaml";
import { logger } from "./logger.js";
import { XStreamEvent } from "./providers/xFilteredStream.js";

export type Narrative = {
  id: string;
  description?: string;
  x_rules?: string[];
  keywords?: string[];
  mints?: string[];
  boost?: { score_bonus?: number };
};

export type NarrativeConfig = { narratives: Narrative[] };
export type HotMint = { mint: string; hypeScore: number; reasons: string[]; lastSeenTs: number };

export function loadNarratives(path = "config/narratives.yaml"): NarrativeConfig {
  const raw = fs.readFileSync(path, "utf-8");
  return (YAML.parse(raw) || { narratives: [] }) as NarrativeConfig;
}

function tokenize(text: string): string[] {
  return text
    .toLowerCase()
    .replace(/https?:\/\/\S+/g, " ")
    .replace(/[^a-z0-9$#_\s]/g, " ")
    .split(/\s+/)
    .filter(Boolean);
}

function baseHypeFromMetrics(m: any): number {
  const like = Number(m?.like_count || 0);
  const rt = Number(m?.repost_count || m?.retweet_count || 0);
  const reply = Number(m?.reply_count || 0);
  const quote = Number(m?.quote_count || 0);
  const raw = like + 2 * rt + 1.5 * reply + 2 * quote;
  return Math.round(Math.min(25, Math.log10(1 + raw) * 10));
}

export class NarrativeTracker {
  private hot = new Map<string, HotMint>();

  constructor(private cfg: any, private narratives: Narrative[]) {}

  ingestX(e: XStreamEvent) {
    const text = e.text || "";
    const tokens = tokenize(text);
    if (!tokens.length) return;

    const base = baseHypeFromMetrics(e.public_metrics || {});

    for (const n of this.narratives) {
      const kws = (n.keywords || []).map(k => k.toLowerCase());
      const hit = kws.some(k => tokens.includes(k) || text.toLowerCase().includes(k));
      if (!hit) continue;

      const bonus = n.boost?.score_bonus ?? 0;
      const hype = base + 5 + bonus;

      for (const mint of (n.mints || [])) {
        if (!mint) continue;
        const prev = this.hot.get(mint);
        const reason = `X:${n.id}`;
        const reasons = prev?.reasons || [];
        const next: HotMint = {
          mint,
          hypeScore: Math.min(100, (prev?.hypeScore || 0) + hype),
          reasons: reasons.includes(reason) ? reasons : [...reasons, reason],
          lastSeenTs: Date.now(),
        };
        this.hot.set(mint, next);
        logger.info({ mint, hypeScore: next.hypeScore, reason }, "Narrative HOT mint updated");
      }
    }
  }

  getHotMints(): HotMint[] {
    const winMin = this.cfg.narrative?.scoring?.hot_window_minutes ?? 60;
    const minScore = this.cfg.narrative?.scoring?.min_hype_score ?? 10;
    const cutoff = Date.now() - winMin * 60_000;

    const out: HotMint[] = [];
    for (const v of this.hot.values()) {
      if (v.lastSeenTs < cutoff) continue;
      if (v.hypeScore < minScore) continue;
      out.push(v);
    }
    out.sort((a, b) => b.hypeScore - a.hypeScore);
    const cap = this.cfg.narrative?.scoring?.max_hot_mints ?? 20;
    return out.slice(0, cap);
  }
}


path: src/providers/http.ts
content: import fetch from "node-fetch";

export async function httpGetJson<T>(url: string, headers?: Record<string, string>): Promise<T> {
  const res = await fetch(url, {
    method: "GET",
    headers: {
      "user-agent": process.env.USER_AGENT || "solana-memecoin-guardian/1.0",
      ...(headers || {}),
    },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
  return (await res.json()) as T;
}


path: src/providers/pumpfun.ts
content: import { httpGetJson } from "./http.js";
import { Candidate } from "../types.js";
import { logger } from "../logger.js";

/**
 * NOTE: Pump.fun endpoints can change.
 * Treat this module as a stub. Replace URL(s) with your actual source.
 */
export async function fetchPumpfunCandidates(): Promise<Candidate[]> {
  // Placeholder: return empty unless you wire a valid endpoint.
  // You can implement:
  // - new tokens
  // - trending
  // - graduating
  logger.debug("pumpfun: fetch candidates (stub)");
  return [];
}


path: src/providers/dexscreener.ts
content: import { httpGetJson } from "./http.js";
import { DexMetrics } from "../types.js";

/**
 * DexScreener public API.
 * We resolve by token mint address for Solana pairs.
 */
type DsTokenPairs = {
  pairs?: Array<{
    chainId: string;
    pairAddress: string;
    liquidity?: { usd?: number };
    volume?: { m5?: number };
    txns?: { m5?: { buys?: number; sells?: number } };
    priceChange?: { m5?: number };
    pairCreatedAt?: number;
    priceUsd?: string;
  }>;
};

export async function resolveDexScreener(mint: string): Promise<DexMetrics | null> {
  const url = `https://api.dexscreener.com/latest/dex/tokens/${mint}`;
  const data = await httpGetJson<DsTokenPairs>(url);
  const pair = (data.pairs || []).find(p => (p.chainId || "").toLowerCase() === "solana");
  if (!pair) return null;

  const liqUsd = pair.liquidity?.usd ?? 0;
  const vol5mUsd = pair.volume?.m5 ?? 0;
  const buys = pair.txns?.m5?.buys ?? 0;
  const sells = pair.txns?.m5?.sells ?? 0;
  const tx5m = buys + sells;
  const priceChange5mPct = pair.priceChange?.m5 ?? 0;
  const createdAt = pair.pairCreatedAt ? Math.floor((Date.now() - pair.pairCreatedAt) / 60000) : 0;
  const currentPriceUsd = pair.priceUsd ? Number(pair.priceUsd) : undefined;

  return {
    mint,
    pairAddress: pair.pairAddress,
    liqUsd,
    vol5mUsd,
    tx5m,
    priceChange5mPct,
    pairAgeMin: createdAt,
    currentPriceUsd,
  };
}


path: src/providers/solanaRpc.ts
content: import { Connection, PublicKey } from "@solana/web3.js";
import { TokenMeta } from "../types.js";
import { logger } from "../logger.js";

function conn(): Connection {
  const url = process.env.SOLANA_RPC_URL;
  if (!url) throw new Error("Missing SOLANA_RPC_URL");
  return new Connection(url, "confirmed");
}

export async function fetchMintInfo(mint: string): Promise<{ decimals: number; mintAuthorityActive: boolean; freezeAuthorityActive: boolean }> {
  const c = conn();
  const pk = new PublicKey(mint);
  const info = await c.getParsedAccountInfo(pk, "confirmed");
  const val: any = info.value;
  const parsed = val?.data?.parsed;
  const decimals = parsed?.info?.decimals;
  const mintAuth = parsed?.info?.mintAuthority;
  const freezeAuth = parsed?.info?.freezeAuthority;

  if (typeof decimals !== "number") throw new Error("Unable to parse mint decimals");
  return {
    decimals,
    mintAuthorityActive: !!mintAuth,
    freezeAuthorityActive: !!freezeAuth,
  };
}

export async function fetchHolderConcentration(mint: string): Promise<{ singleHolderPct: number; top10HoldersPct: number }> {
  const c = conn();
  const mintPk = new PublicKey(mint);

  const supply = await c.getTokenSupply(mintPk, "confirmed");
  const supplyUi = supply.value.uiAmount ?? 0;
  if (!Number.isFinite(supplyUi) || supplyUi <= 0) return { singleHolderPct: 0, top10HoldersPct: 0 };

  const largest = await c.getTokenLargestAccounts(mintPk, "confirmed");
  const vals = largest.value || [];
  const uiAmounts = vals.map(v => v.uiAmount ?? 0).filter(x => Number.isFinite(x) && x > 0);

  const top1 = uiAmounts[0] ?? 0;
  const top10 = uiAmounts.slice(0, 10).reduce((a, b) => a + b, 0);

  return {
    singleHolderPct: (top1 / supplyUi) * 100,
    top10HoldersPct: (top10 / supplyUi) * 100,
  };
}

/**
 * Fetch authority flags + holder concentration.
 * Safe default: if any RPC call fails, we return nulls and RiskGate will SKIP.
 */
export async function fetchTokenMeta(mint: string): Promise<TokenMeta> {
  try {
    const mi = await fetchMintInfo(mint);
    const hc = await fetchHolderConcentration(mint);
    return {
      mint,
      mintAuthorityActive: mi.mintAuthorityActive,
      freezeAuthorityActive: mi.freezeAuthorityActive,
      singleHolderPct: hc.singleHolderPct,
      top10HoldersPct: hc.top10HoldersPct,
    };
  } catch (err) {
    logger.warn({ mint, err }, "solanaRpc: meta fetch failed (safe SKIP)");
    return {
      mint,
      mintAuthorityActive: null,
      freezeAuthorityActive: null,
      singleHolderPct: null,
      top10HoldersPct: null,
    };
  }
}


path: src/providers/walletStream.ts
content: import { WalletEvent } from "../types.js";
import { logger } from "../logger.js";
import fs from "node:fs";
import { startHeliusPolling } from "./heliusWalletPoller.js";
import { loadConfig } from "../config.js";

/**
 * Wallet event provider.
 * Priority:
 * 1) MOCK_WALLET_EVENTS_JSONL (paper testing)
 * 2) Helius Enhanced Transactions polling (requires HELIUS_API_KEY)
 * 3) stub no-op
 */
export function startWalletStream(wallets: string[], onEvent: (e: WalletEvent) => void): () => void {
  const cfg = loadConfig();
  const jsonl = process.env.MOCK_WALLET_EVENTS_JSONL;
  const heliusKey = process.env.HELIUS_API_KEY;

  logger.info({ wallets: wallets.length, jsonl: !!jsonl, helius: !!heliusKey }, "walletStream: start");

  if (jsonl && fs.existsSync(jsonl)) {
    let offset = 0;
    const lines = fs.readFileSync(jsonl, "utf-8").split(/\r?\n/).filter(Boolean);
    const timer = setInterval(() => {
      if (offset >= lines.length) return;
      try {
        const e = JSON.parse(lines[offset++]) as WalletEvent;
        if (!wallets.includes(e.wallet)) return;
        onEvent(e);
      } catch (err) {
        logger.warn({ err }, "walletStream: bad JSONL line");
      }
    }, 1500);
    return () => clearInterval(timer);
  }

  if (heliusKey) {
    return startHeliusPolling(wallets, cfg, onEvent);
  }

  const timer = setInterval(() => {}, 10_000);
  return () => clearInterval(timer);
}


path: src/providers/swapExecutor.ts
content: import { logger } from "../logger.js";
import bs58 from "bs58";
import { Connection, Keypair, VersionedTransaction } from "@solana/web3.js";
import { buildSwapTx, getQuote, getSolUsdPrice, getPriceDecimals } from "./jupiter.js";
import { fetchMintInfo } from "./solanaRpc.js";

function getConnection(): Connection {
  const url = process.env.SOLANA_RPC_URL;
  if (!url) throw new Error("Missing SOLANA_RPC_URL");
  return new Connection(url, "confirmed");
}

function loadKeypair(): Keypair {
  const b58 = process.env.WALLET_PRIVATE_KEY_BASE58;
  const json = process.env.WALLET_PRIVATE_KEY_JSON;
  if (b58) return Keypair.fromSecretKey(bs58.decode(b58.trim()));
  if (json) return Keypair.fromSecretKey(Uint8Array.from(JSON.parse(json)));
  throw new Error("Missing WALLET_PRIVATE_KEY_BASE58 or WALLET_PRIVATE_KEY_JSON");
}

export async function buySim(mint: string, sizeUsd: number): Promise<{ ok: boolean; txid?: string; fillPriceUsd: number; outAmountRaw?: string }> {
  logger.info({ mint, sizeUsd }, "PAPER BUY");
  return { ok: true, txid: `paper-buy-${Date.now()}`, fillPriceUsd: NaN };
}

export async function sellSim(mint: string, pct: number): Promise<{ ok: boolean; txid?: string }> {
  logger.info({ mint, pct }, "PAPER SELL");
  return { ok: true, txid: `paper-sell-${Date.now()}` };
}

export async function buyLiveByUsd(params: {
  outputMint: string;
  usdAmount: number;
  slippageBps: number;
  restrictIntermediateTokens?: boolean;
  onlyDirectRoutes?: boolean;
}): Promise<{ ok: boolean; txid?: string; inAmountLamports?: string; outAmountRaw?: string; outDecimals?: number }> {
  const jupBase = process.env.JUP_BASE_URL || "https://api.jup.ag";
  const [REDACTED_SECRET] || undefined;

  const solUsd = await getSolUsdPrice(jupBase, apiKey);
  if (!solUsd) throw new Error("Could not fetch SOL USD price from Jupiter Price API");

  const lamports = Math.floor((params.usdAmount / solUsd) * 1e9);
  if (lamports <= 0) throw new Error("usdAmount too small to convert to lamports");

  const inputMint = [REDACTED_SECRET]; // wSOL
  const quote = await getQuote({
    jupBase,
    apiKey,
    inputMint,
    outputMint: params.outputMint,
    amount: String(lamports),
    slippageBps: params.slippageBps,
    restrictIntermediateTokens: params.restrictIntermediateTokens ?? true,
    onlyDirectRoutes: params.onlyDirectRoutes ?? false,
  });

  const kp = loadKeypair();
  const user = kp.publicKey.toBase58();

  const priorityMax = Number(process.env.PRIORITY_MAX_LAMPORTS || "1000000");
  const priorityLevel = process.env.PRIORITY_LEVEL || "veryHigh";

  const swap = await buildSwapTx({
    jupBase,
    apiKey,
    quoteResponse: quote,
    userPublicKey: user,
    wrapAndUnwrapSol: true,
    dynamicComputeUnitLimit: true,
    dynamicSlippage: true,
    prioritizationFeeLamports: {
      priorityLevelWithMaxLamports: {
        maxLamports: priorityMax,
        priorityLevel,
      },
    },
  });

  const txB64 = swap.swapTransaction;
  if (!txB64) throw new Error("No swapTransaction returned by Jupiter");

  const tx = VersionedTransaction.deserialize(Buffer.from(txB64, "base64"));
  tx.sign([kp]);

  const c = getConnection();
  const sig = await c.sendRawTransaction(tx.serialize(), { skipPreflight: false, preflightCommitment: "confirmed" });
  logger.info({ sig }, "LIVE BUY sent");

  // confirm
  if (swap.lastValidBlockHeight) {
    const bh = tx.message.recentBlockhash;
    await c.confirmTransaction({ signature: sig, blockhash: bh, lastValidBlockHeight: swap.lastValidBlockHeight }, "confirmed");
  } else {
    await c.confirmTransaction(sig, "confirmed");
  }

  // decimals for output token (prefer RPC mint decimals)
  let outDecimals: number | undefined;
  try {
    outDecimals = (await fetchMintInfo(params.outputMint)).decimals;
  } catch {
    try {
      const d = await getPriceDecimals(jupBase, [params.outputMint], apiKey);
      outDecimals = d?.[params.outputMint]?.decimals;
    } catch {}
  }

  return { ok: true, txid: sig, inAmountLamports: String(lamports), outAmountRaw: quote.outAmount, outDecimals };
}

export async function sellLiveExactIn(params: {
  inputMint: string; // token to sell
  outputMint: string; // usually wSOL
  inAmountRaw: string;
  slippageBps: number;
  restrictIntermediateTokens?: boolean;
  onlyDirectRoutes?: boolean;
}): Promise<{ ok: boolean; txid?: string }> {
  const jupBase = process.env.JUP_BASE_URL || "https://api.jup.ag";
  const [REDACTED_SECRET] || undefined;

  const quote = await getQuote({
    jupBase,
    apiKey,
    inputMint: params.inputMint,
    outputMint: params.outputMint,
    amount: params.inAmountRaw,
    slippageBps: params.slippageBps,
    restrictIntermediateTokens: params.restrictIntermediateTokens ?? true,
    onlyDirectRoutes: params.onlyDirectRoutes ?? false,
  });

  const kp = loadKeypair();
  const user = kp.publicKey.toBase58();

  const priorityMax = Number(process.env.PRIORITY_MAX_LAMPORTS || "1000000");
  const priorityLevel = process.env.PRIORITY_LEVEL || "veryHigh";

  const swap = await buildSwapTx({
    jupBase,
    apiKey,
    quoteResponse: quote,
    userPublicKey: user,
    wrapAndUnwrapSol: true,
    dynamicComputeUnitLimit: true,
    dynamicSlippage: true,
    prioritizationFeeLamports: {
      priorityLevelWithMaxLamports: {
        maxLamports: priorityMax,
        priorityLevel,
      },
    },
  });

  const txB64 = swap.swapTransaction;
  if (!txB64) throw new Error("No swapTransaction returned by Jupiter");

  const tx = VersionedTransaction.deserialize(Buffer.from(txB64, "base64"));
  tx.sign([kp]);

  const c = getConnection();
  const sig = await c.sendRawTransaction(tx.serialize(), { skipPreflight: false, preflightCommitment: "confirmed" });
  logger.info({ sig }, "LIVE SELL sent");

  if (swap.lastValidBlockHeight) {
    const bh = tx.message.recentBlockhash;
    await c.confirmTransaction({ signature: sig, blockhash: bh, lastValidBlockHeight: swap.lastValidBlockHeight }, "confirmed");
  } else {
    await c.confirmTransaction(sig, "confirmed");
  }

  return { ok: true, txid: sig };
}


path: src/providers/jupiter.ts
content: import { httpGetJson } from "./http.js";

export type JupQuote = any;

export async function getSolUsdPrice(jupBase: string, apiKey?: string): Promise<number | null> {
  const url = `${jupBase}/price/v3?ids=So11111111111111111111111111111111111111112`;
  const data = await httpGetJson<Record<string, { usdPrice: number }>>(url, apiKey ? { "x-api-key": apiKey } : undefined);
  const sol = data[[REDACTED_SECRET]];
  return sol?.usdPrice ?? null;
}

export async function getPriceDecimals(jupBase: string, mints: string[], apiKey?: string): Promise<Record<string, { usdPrice?: number; decimals?: number }>> {
  const url = `${jupBase}/price/v3?ids=${encodeURIComponent(mints.join(","))}`;
  return await httpGetJson<any>(url, apiKey ? { "x-api-key": apiKey } : undefined);
}

export async function getQuote(params: {
  jupBase: string;
  apiKey?: string;
  inputMint: string;
  outputMint: string;
  amount: string; // raw amount, before decimals
  slippageBps: number;
  restrictIntermediateTokens?: boolean;
  onlyDirectRoutes?: boolean;
  swapMode?: "ExactIn" | "ExactOut";
}): Promise<JupQuote> {
  const qs = new URLSearchParams();
  qs.set("inputMint", params.inputMint);
  qs.set("outputMint", params.outputMint);
  qs.set("amount", params.amount);
  qs.set("slippageBps", String(params.slippageBps));
  qs.set("swapMode", params.swapMode ?? "ExactIn");
  if (params.restrictIntermediateTokens !== undefined) qs.set("restrictIntermediateTokens", String(params.restrictIntermediateTokens));
  if (params.onlyDirectRoutes !== undefined) qs.set("onlyDirectRoutes", String(params.onlyDirectRoutes));
  const url = `${params.jupBase}/swap/v1/quote?${qs.toString()}`;
  return await httpGetJson<any>(url, params.apiKey ? { "x-api-key": params.apiKey } : undefined);
}

export async function buildSwapTx(params: {
  jupBase: string;
  apiKey?: string;
  quoteResponse: any;
  userPublicKey: string;
  wrapAndUnwrapSol?: boolean;
  dynamicComputeUnitLimit?: boolean;
  dynamicSlippage?: boolean;
  prioritizationFeeLamports?: any;
}): Promise<any> {
  const url = `${params.jupBase}/swap/v1/swap`;
  const res = await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      ...(params.apiKey ? { "x-api-key": params.apiKey } : {}),
    },
    body: JSON.stringify({
      quoteResponse: params.quoteResponse,
      userPublicKey: params.userPublicKey,
      wrapAndUnwrapSol: params.wrapAndUnwrapSol ?? true,
      dynamicComputeUnitLimit: params.dynamicComputeUnitLimit ?? true,
      dynamicSlippage: params.dynamicSlippage ?? true,
      prioritizationFeeLamports: params.prioritizationFeeLamports,
    }),
  });
  if (!res.ok) throw new Error(`Jupiter swap build failed: HTTP ${res.status}`);
  return await res.json();
}


path: src/providers/heliusWalletPoller.ts
content: import { httpGetJson } from "./http.js";
import { WalletEvent } from "../types.js";
import { logger } from "../logger.js";

type EnhancedTx = any;

function heliusBase(): string {
  return process.env.HELIUS_BASE_URL || "https://api-mainnet.helius-rpc.com";
}
function heliusKey(): string {
  const k = process.env.HELIUS_API_KEY;
  if (!k) throw new Error("Missing HELIUS_API_KEY");
  return k;
}

function isIgnoredMint(mint: string, cfg: any): boolean {
  const ign: string[] = cfg.ignore_mints || [];
  return ign.includes(mint);
}

function pickSwapEvents(wallet: string, tx: EnhancedTx, cfg: any): WalletEvent[] {
  const out: WalletEvent[] = [];
  const ev = tx?.events?.swap;
  if (!ev) return out;

  // tokenInputs => wallet spent tokens (SELL of that mint)
  const tokenInputs = Array.isArray(ev.tokenInputs) ? ev.tokenInputs : [];
  for (const ti of tokenInputs) {
    if (ti?.userAccount === wallet && ti?.mint && !isIgnoredMint(ti.mint, cfg)) {
      out.push({ wallet, side: "SELL", mint: ti.mint, ts: (tx.timestamp ?? Math.floor(Date.now()/1000)) * 1000 });
    }
  }

  // tokenOutputs => wallet received tokens (BUY of that mint)
  const tokenOutputs = Array.isArray(ev.tokenOutputs) ? ev.tokenOutputs : [];
  for (const to of tokenOutputs) {
    if (to?.userAccount === wallet && to?.mint && !isIgnoredMint(to.mint, cfg)) {
      out.push({ wallet, side: "BUY", mint: to.mint, ts: (tx.timestamp ?? Math.floor(Date.now()/1000)) * 1000 });
    }
  }

  return out;
}

export async function pollWalletOnce(wallet: string, cfg: any, cursorAfterSig: string | null): Promise<{ events: WalletEvent[]; newestSig: string | null }> {
  const base = heliusBase();
  const key = heliusKey();
  const limit = 20;

  const qs = new URLSearchParams();
  qs.set("api-key", key);
  qs.set("limit", String(limit));
  qs.set("commitment", "confirmed");
  if (cursorAfterSig) qs.set("after-signature", cursorAfterSig);

  const url = `${base}/v0/addresses/${wallet}/transactions?${qs.toString()}`;
  const txs = await httpGetJson<EnhancedTx[]>(url);

  if (!Array.isArray(txs) || txs.length === 0) return { events: [], newestSig: cursorAfterSig };

  // API returns transactions in reverse chronological by default; we handle both just in case:
  const sigs = txs.map(t => t.signature).filter(Boolean);
  const newestSig = sigs[0] ?? cursorAfterSig;

  const events: WalletEvent[] = [];
  for (const tx of txs) {
    // ignore failed tx
    if (tx?.transactionError?.error) continue;
    for (const e of pickSwapEvents(wallet, tx, cfg)) events.push(e);
  }

  // De-duplicate per (side,mint,ts)
  const seen = new Set<string>();
  const dedup = events.filter(e => {
    const k2 = `${e.side}:${e.mint}:${e.ts}`;
    if (seen.has(k2)) return false;
    seen.add(k2);
    return true;
  });

  return { events: dedup, newestSig };
}

export function startHeliusPolling(wallets: string[], cfg: any, onEvent: (e: WalletEvent) => void): () => void {
  const pollMs = 2000;
  const cursors = new Map<string, string | null>();
  for (const w of wallets) cursors.set(w, null);

  logger.info({ wallets: wallets.length, pollMs }, "Helius polling: start");

  const timer = setInterval(async () => {
    for (const wallet of wallets) {
      try {
        const { events, newestSig } = await pollWalletOnce(wallet, cfg, cursors.get(wallet) ?? null);
        cursors.set(wallet, newestSig);
        for (const e of events) onEvent(e);
      } catch (err) {
        logger.warn({ wallet, err }, "Helius polling error");
      }
    }
  }, pollMs);

  return () => clearInterval(timer);
}


path: src/providers/xFilteredStream.ts
content: import { logger } from "../logger.js";

export type XStreamEvent = {
  id?: string;
  text?: string;
  created_at?: string;
  author_id?: string;
  public_metrics?: any;
  matching_rules?: Array<{ id: string; tag?: string }>;
};

function xBase(): string {
  return process.env.X_API_BASE_URL || "https://api.x.com";
}

function bearer(): string {
  const t = process.env.X_BEARER_TOKEN;
  if (!t) throw new Error("Missing X_BEARER_TOKEN");
  return t;
}

async function xFetch(url: string, init?: RequestInit): Promise<Response> {
  return await fetch(url, {
    ...init,
    headers: {
      "Authorization": `Bearer ${bearer()}`,
      "Content-Type": "application/json",
      ...(init?.headers || {}),
    },
  });
}

export async function xGetRules(): Promise<any> {
  const url = `${xBase()}/2/tweets/search/stream/rules`;
  const res = await xFetch(url);
  if (!res.ok) throw new Error(`X rules GET failed: HTTP ${res.status}`);
  return await res.json();
}

export async function xAddRules(rules: Array<{ value: string; tag?: string }>): Promise<void> {
  if (!rules.length) return;
  const url = `${xBase()}/2/tweets/search/stream/rules`;
  const res = await xFetch(url, { method: "POST", body: JSON.stringify({ add: rules }) });
  if (!res.ok) throw new Error(`X rules POST failed: HTTP ${res.status}: ${await res.text()}`);
}

export function startXFilteredStream(opts: {
  onEvent: (e: XStreamEvent) => void;
  reconnectMinSeconds: number;
  reconnectMaxSeconds: number;
}): () => void {
  let stopped = false;
  let backoff = opts.reconnectMinSeconds;

  const sleep = (s: number) => new Promise(r => setTimeout(r, s * 1000));
  const clamp = (x: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, x));

  async function connectOnce() {
    const url = new URL(`${xBase()}/2/tweets/search/stream`);
    url.searchParams.set("tweet.fields", "created_at,public_metrics,author_id,lang");

    const res = await xFetch(url.toString(), { method: "GET" });
    if (!res.ok) throw new Error(`X stream failed: HTTP ${res.status}: ${await res.text()}`);

    logger.info("X stream connected");
    backoff = opts.reconnectMinSeconds;

    const reader = res.body?.getReader();
    if (!reader) throw new Error("X stream: no reader");

    let buf = "";
    while (!stopped) {
      const { value, done } = await reader.read();
      if (done) break;
      buf += new TextDecoder().decode(value);
      const parts = buf.split(/\r?\n/);
      buf = parts.pop() || "";
      for (const line of parts) {
        const trimmed = line.trim();
        if (!trimmed) continue; // keep-alive
        try {
          const msg = JSON.parse(trimmed);
          const data = msg?.data;
          if (data?.text) {
            opts.onEvent({
              id: data.id,
              text: data.text,
              created_at: data.created_at,
              author_id: data.author_id,
              public_metrics: data.public_metrics,
              matching_rules: msg.matching_rules,
            });
          }
        } catch {
          // skip
        }
      }
    }
  }

  (async () => {
    while (!stopped) {
      try {
        await connectOnce();
      } catch (err) {
        logger.warn({ err, backoff }, "X stream disconnected; reconnecting");
        await sleep(backoff);
        backoff = clamp(Math.round(backoff * 1.7), opts.reconnectMinSeconds, opts.reconnectMaxSeconds);
      }
    }
  })().catch(err => logger.error({ err }, "X stream loop crashed"));

  return () => { stopped = true; };
}


path: scripts/print-config.ts
content: import { loadConfig } from "../src/config.js";
console.log(JSON.stringify(loadConfig(), null, 2));


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