diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..22a14f7 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Copy this file to .env.local (gitignored) and fill in your values. +# The npm scripts (server / encrypt-keystore / fund / recover) auto-load .env.local. + +# Encrypts .secrets/wallets.json at rest (AES-256-GCM). REQUIRED to use the keystore. +# This passphrase IS the decryption key — losing it means losing the wallets. +LILY_KEYSTORE_PASSPHRASE= + +# Optional spend guardrails (defaults shown). Override only if you know why. +# LILY_MAX_DEV_BUY=5 +# LILY_MAX_PER_BUY=5 +# LILY_MAX_TOTAL_SPEND=20 +# LILY_MAX_SLIPPAGE=50 +# LILY_MAX_PRIORITY_FEE=0.01 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3571a6b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +# Runs the full local gate (typecheck client + server, graph-validation suite, +# unit tests) on every push and PR. No secrets, no deploy — pure verification. +on: + push: + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run check diff --git a/.gitignore b/.gitignore index ce6ec4f..4723e65 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ node_modules dist .secrets +.env +.env.* +!.env.example *.tsbuildinfo .vercel .video diff --git a/README.md b/README.md index 61d873e..9357169 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,24 @@ accurate bonding-curve chart update as you build — then run the exact strategy --- +> ## ⚠️ Before you start — bring your own keys & services +> +> The **Alpha** (on-chain) build runs against **your own** wallets, RPC, and Jito access. None of +> this is bundled — you supply it. Create `.secrets/` (git-ignored) with: +> +> | File | What it is | Where to get it | +> | --- | --- | --- | +> | `.secrets/rpc-url.txt` | Your Solana RPC URL | **Helius** (or any mainnet RPC) — paid plan recommended for landing speed | +> | `.secrets/jito-uuid.txt` | Your Jito Block Engine UUID | **Jito** — required for bundled launches | +> | `.secrets/jito-tip-sol.txt` | Jito tip per launch (default `0.1`) | your call; 0.1◎ lands reliably | +> | `.secrets/wallets.json` | Your encrypted wallet keystore | created by the app / `npm run encrypt-keystore` | +> | `.secrets/pinata-jwt.txt` | (optional) Pinata JWT for metadata pinning | **Pinata** | +> +> Also copy `.env.example` → `.env.local` and set `LILY_KEYSTORE_PASSPHRASE` (encrypts your +> wallets at rest — **lose it and you lose the wallets**). Everything in `.secrets/` and +> `.env.local` is git-ignored and **never** committed. **Your keys, your funds, your RPC/Jito +> accounts — this repo ships none of them.** + ## Overview Lily Algo Launcher models a pump.fun launch as an editable graph. Every wallet is a node, and diff --git a/docs/V2-FLOW.md b/docs/V2-FLOW.md new file mode 100644 index 0000000..5944b93 --- /dev/null +++ b/docs/V2-FLOW.md @@ -0,0 +1,74 @@ +# Lily Algo Launcher — V2 (Jito Bundle + Extended Rotation Flow) + +V2 turns the launcher into a real executor: an **atomic Jito-bundled launch** followed by +**multi-hop rotation chains**, all verified on-chain. Everything below was tested with real +SOL on mainnet and confirmed against the chain (not logs). + +## What's new in V2 + +- **Real Jito bundling** — the launch (create + dev buy + buyers) lands **atomically in one + slot** as a true Jito bundle (verified: contiguous tx indices in-block + Jito tip). The + old build "looked" bundled but ran sequentially across 6 slots; V2 actually bundles. +- **Default 0.1◎ Jito tip** — lands on the first attempt. No congestion guessing, no knobs. +- **Auto-retry** — if a bundle ever fails to land, it rebuilds with a fresh blockhash and + resubmits (up to 3×) instead of freezing. +- **Extended rotation flow** — each bundle wallet can rotate its position into a new wallet, + which rotates into another, N hops deep. Each hop: sell → sweep SOL → re-buy. +- **Slippage-aware funding** — wallets are funded for the slippage-max cost so buys can't run + out of SOL mid-flow. Rotation re-buys are sized to fit the swept amount. +- **One-click Recover → Main** — sells every wallet's bag and sweeps all SOL back to Main. +- **Fast landing detection** — `processed` commitment + tight polling, so create returns the + instant the bundle lands. + +## The ideal flow (what we tested in V2 — working) + +``` + ┌─ DEV (Wallet 1): CREATE + optional dev buy ──┐ ← ATOMIC JITO BUNDLE + │ │ (0.1◎ tip, 1 slot) + BUNDLE ├─ Wallet 2 buy ~1◎ ─┐ │ + (4–5 ├─ Wallet 3 buy ~1◎ ─┤ all in the same │ + buyers) ├─ Wallet 4 buy ~1◎ ─┤ Jito bundle │ + └─ Wallet 5 buy ~1◎ ─┘ ┘ + + ROTATE each bundle wallet → a NEW wallet (sell → sweep → re-buy) + HOP 1 W2→W6 W3→W7 W4→W8 W5→W9 + + ROTATE each of those → ANOTHER new wallet + HOP 2 W6→W11 W7→W12 W8→W13 W9→W14 + + ROTATE …and again, as deep as you want + HOP 3 W11→W16 W12→W17 W13→W18 W14→W19 +``` + +**In words:** a **4–5 wallet bundle** opens the coin atomically (paying the **0.1◎ Jito +tip**), then each of those wallets **rotates 5-into-5-into-5** — every wallet hands its +position to a fresh wallet, several hops deep. The result is a wide, organic-looking holder +spread that all originated from one atomic launch. + +### Bundle size limit +A Jito bundle holds **max 5 transactions**: `create + dev buy + buyers (2 per tx)`. So one +launch bundle fits **up to 6 buyers with a dev buy, or 8 without**. Rotation wallets are *not* +in the bundle — they run as fast sequential hops after the launch. + +## Stress test vs. production + +The largest run we verified — **13 wallets, 4 chains × 3 rotation hops, 12 rotations** — is a +**stress test** to demonstrate capability. It landed clean on-chain: bundle atomic in one +slot, all 12 rotations executed, **0 failures**. Use it as proof the engine holds up. + +For day-to-day launches, the **sweet spot is ~4–5 bundle wallets + 2–3 rotation hops** — fast, +reliable, and cheaper. Scale the depth up only when you want a wider spread. + +## On-chain results (verified, real SOL) + +- Bundle: **atomic, single slot**, Jito tip paid, contiguous in-block ordering. +- Rotations: **every hop landed, 0 failures** across all chains. +- Each rotation hop loses a little to fees + bonding-curve spread + slippage (**rotation + decay**), so deeper leaves hold smaller bags than a single direct buy — expected behavior. +- `Recover → Main` cleanly sold all bags and swept SOL back to Main. + +## Notes / known item + +- The canvas can display a token amount higher than the wallet's true on-chain balance after + multi-hop rotations (it doesn't fully reflect rotation decay). Execution is correct; the + displayed number is being aligned to the chain. diff --git a/package.json b/package.json index e7096d4..e2cb336 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,17 @@ "type": "module", "scripts": { "dev": "vite --host 127.0.0.1", - "server": "tsx server/local-server.ts", + "server": "tsx --env-file-if-exists=.env.local server/local-server.ts", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", "typecheck:server": "tsc -p tsconfig.server.json", - "check": "tsc --noEmit && tsc -p tsconfig.server.json && tsx scripts/audit.ts", + "check": "tsc --noEmit && tsc -p tsconfig.server.json && tsc -p tsconfig.test.json && tsx scripts/audit.ts && tsx --test tests/*.test.ts", "audit": "tsx scripts/audit.ts", + "test": "tsx --test tests/*.test.ts", + "encrypt-keystore": "tsx --env-file-if-exists=.env.local scripts/encrypt-keystore.ts", + "fund": "tsx --env-file-if-exists=.env.local scripts/fund.ts", + "recover": "tsx --env-file-if-exists=.env.local scripts/recover.ts", + "resume-smoketest": "tsx --env-file-if-exists=.env.local scripts/resume-smoketest.ts", "preview": "vite preview --host 127.0.0.1" }, "dependencies": { diff --git a/scripts/audit.ts b/scripts/audit.ts index b04147a..ffe9ab5 100644 --- a/scripts/audit.ts +++ b/scripts/audit.ts @@ -33,7 +33,8 @@ add("09 30 chained buys", "ok", G(w(30), w(30).map((x, i) => buy(i === 0 ? "laun add("10 empty", "ok", G([W("a")], [])); // ---- WARN ---- -add("11 oversize launch bundle (5>4)", "warn", G(w(5), [buy("launch", "w0", 0.5, "B"), buy("w0", "w1", 0.5, "B"), buy("w1", "w2", 0.5, "B"), buy("w2", "w3", 0.5, "B"), buy("w3", "w4", 0.5, "B")])); +add("11 launch bundle 5 buyers fits (≤5 tx)", "ok", G(w(5), [buy("launch", "w0", 0.5, "B"), buy("w0", "w1", 0.5, "B"), buy("w1", "w2", 0.5, "B"), buy("w2", "w3", 0.5, "B"), buy("w3", "w4", 0.5, "B")])); +add("11b oversize launch bundle (9 buyers > 5 tx)", "warn", G(w(9), [buy("launch", "w0", 0.5, "B"), buy("w0", "w1", 0.5, "B"), buy("w1", "w2", 0.5, "B"), buy("w2", "w3", 0.5, "B"), buy("w3", "w4", 0.5, "B"), buy("w4", "w5", 0.5, "B"), buy("w5", "w6", 0.5, "B"), buy("w6", "w7", 0.5, "B"), buy("w7", "w8", 0.5, "B")])); add("11b creator double-buy (auto dev)", "warn", Gd([W("a"), W("b")], [buy("launch", "a"), buy("a", "b")])); add("11c separate dev wallet (no double)", "ok", Gd([W("dev"), W("a"), W("b")], [buy("launch", "a"), buy("a", "b")], "dev")); diff --git a/scripts/encrypt-keystore.ts b/scripts/encrypt-keystore.ts new file mode 100644 index 0000000..5bdd51d --- /dev/null +++ b/scripts/encrypt-keystore.ts @@ -0,0 +1,38 @@ +// Encrypt (or re-encrypt) .secrets/wallets.json at rest. +// +// LILY_KEYSTORE_PASSPHRASE='your strong passphrase' npx tsx scripts/encrypt-keystore.ts +// +// Reads the current keystore (plaintext OR already-encrypted), writes a timestamped +// backup of the raw file, then writes it back ENCRYPTED with AES-256-GCM under a +// scrypt-derived key. After this, every reader/writer that has the passphrase in its +// environment works transparently; without it, the file is unreadable. +// +// Keep LILY_KEYSTORE_PASSPHRASE in the same place you run `npm run server` (e.g. an +// .env.local that is gitignored). Losing it means losing access to these wallets. +import { copyFileSync, existsSync, readFileSync } from "node:fs"; +import { readWalletsFile, writeWalletsFile } from "../server/keystore"; + +const FILE = ".secrets/wallets.json"; + +(async () => { + if (!process.env.LILY_KEYSTORE_PASSPHRASE?.trim()) { + console.error("Set LILY_KEYSTORE_PASSPHRASE before running (it is the key to your wallets)."); + process.exit(1); + } + if (!existsSync(FILE)) { console.error(`No ${FILE} found.`); process.exit(1); } + + const alreadyEncrypted = (() => { + try { return JSON.parse(readFileSync(FILE, "utf8"))?.encrypted === true; } catch { return false; } + })(); + + const wallets = await readWalletsFile>(FILE); // decrypts if needed + if (!wallets.length) { console.error("Keystore has no wallets — nothing to do."); process.exit(1); } + + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backup = FILE.replace(/\.json$/, `-preencrypt-${stamp}.json`); + copyFileSync(FILE, backup); + + await writeWalletsFile(FILE, wallets); // passphrase set → writes encrypted envelope + console.log(`${alreadyEncrypted ? "Re-encrypted" : "Encrypted"} ${wallets.length} wallets → ${FILE}`); + console.log(`Raw backup: ${backup} (delete it once you've confirmed decryption works)`); +})().catch((e) => { console.error("encrypt-keystore failed:", e); process.exit(1); }); diff --git a/scripts/fund.ts b/scripts/fund.ts index 4c880fc..5c120b2 100644 --- a/scripts/fund.ts +++ b/scripts/fund.ts @@ -3,23 +3,27 @@ import { readFileSync } from "node:fs"; import { Connection, Keypair, LAMPORTS_PER_SOL, PublicKey, SystemProgram, TransactionMessage, VersionedTransaction } from "@solana/web3.js"; import bs58 from "bs58"; +import { readWalletsFile } from "../server/keystore"; -const TARGET = Number(process.env.FUND_TARGET || 0.02); +const TARGET = Number(process.env.FUND_TARGET || 0.03); // ≥ bundle launch dev buffer (reconciled with fundNeeds) +const DRY = process.env.FUND_DRY_RUN === "1" || process.env.FUND_DRY_RUN === "true"; const rpc = readFileSync(".secrets/rpc-url.txt", "utf8").trim(); const c = new Connection(rpc, "confirmed"); type Rec = { label: string; publicKey: string; secretKeyBase58: string }; -const ws: Rec[] = JSON.parse(readFileSync(".secrets/wallets.json", "utf8")).wallets; const sol = async (pk: string) => (await c.getBalance(new PublicKey(pk), "confirmed")) / LAMPORTS_PER_SOL; (async () => { + const ws: Rec[] = await readWalletsFile(".secrets/wallets.json"); if (ws.length < 2) { console.log("Need at least 2 wallets (main + targets)."); return; } const dev = ws[0]; const devKp = Keypair.fromSecretKey(bs58.decode(dev.secretKeyBase58)); + if (DRY) console.log("DRY RUN — no transactions will be sent"); console.log(`Main ${dev.publicKey} = ${(await sol(dev.publicKey)).toFixed(4)}◎ · topping ${ws.length - 1} wallets to ${TARGET}◎`); for (const w of ws.slice(1)) { const bal = await sol(w.publicKey); const need = TARGET - bal; if (need <= 0.0005) { console.log(` ${w.label.padEnd(10)} ok (${bal.toFixed(4)}◎)`); continue; } + if (DRY) { console.log(` ${w.label.padEnd(10)} would +${need.toFixed(4)}◎ (dry-run)`); continue; } const { blockhash, lastValidBlockHeight } = await c.getLatestBlockhash("confirmed"); const tx = new VersionedTransaction(new TransactionMessage({ payerKey: devKp.publicKey, recentBlockhash: blockhash, diff --git a/scripts/recover.ts b/scripts/recover.ts index 144f893..b659385 100644 --- a/scripts/recover.ts +++ b/scripts/recover.ts @@ -1,19 +1,23 @@ // Full recovery: for every non-main wallet, SELL any pump token bag back to its -// curve, then SWEEP all SOL to Main. Fixes the audit gap where recovery left -// token bags (e.g. a rotation endpoint) stranded on-chain. -// npx tsx scripts/recover.ts +// curve, CLOSE the now-empty token account to reclaim its ~0.00204◎ rent, then +// SWEEP all SOL to Main. Fixes the audit gaps where recovery (a) left token bags +// stranded and (b) burned ATA rent forever (~0.122◎ per 60-wallet cycle). +// npx tsx scripts/recover.ts # live +// RECOVER_DRY_RUN=1 npx tsx scripts/recover.ts # preview, no sends +// RECOVER_MAIN= npx tsx scripts/recover.ts # explicit sweep destination import { readFileSync } from "node:fs"; import { Connection, Keypair, LAMPORTS_PER_SOL, PublicKey, SystemProgram, TransactionMessage, VersionedTransaction, ComputeBudgetProgram } from "@solana/web3.js"; -import { getAssociatedTokenAddressSync, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { createCloseAccountInstruction, getAssociatedTokenAddressSync, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from "@solana/spl-token"; import { OnlinePumpSdk, PUMP_SDK, getSellSolAmountFromTokenAmount } from "@pump-fun/pump-sdk"; import BN from "bn.js"; import bs58 from "bs58"; +import { readWalletsFile } from "../server/keystore"; const rpc = readFileSync(".secrets/rpc-url.txt", "utf8").trim(); const c = new Connection(rpc, "confirmed"); const sdk = new OnlinePumpSdk(c); +const DRY = process.env.RECOVER_DRY_RUN === "1" || process.env.RECOVER_DRY_RUN === "true"; type Rec = { id: string; label: string; publicKey: string; secretKeyBase58: string }; -const ws: Rec[] = JSON.parse(readFileSync(".secrets/wallets.json", "utf8")).wallets; const kp = (r: Rec) => Keypair.fromSecretKey(bs58.decode(r.secretKeyBase58)); const sol = async (pk: string) => (await c.getBalance(new PublicKey(pk), "confirmed")) / LAMPORTS_PER_SOL; @@ -23,11 +27,15 @@ async function sellBag(owner: Rec, mint: PublicKey, global: Awaited null); const amount = new BN(bal?.value.amount || "0"); if (amount.isZero()) return null; + if (DRY) return { sig: "(dry-run)", sol: 0 }; const state = await sdk.fetchSellState(mint, signer.publicKey, TOKEN_2022_PROGRAM_ID); const quoteAmount = getSellSolAmountFromTokenAmount({ global, feeConfig, mintSupply: state.bondingCurve.tokenTotalSupply, bondingCurve: state.bondingCurve, amount }); const ixs = await PUMP_SDK.sellV2Instructions({ global, ...state, mint, user: signer.publicKey, amount, quoteAmount, slippage: 50, tokenProgram: TOKEN_2022_PROGRAM_ID, quoteTokenProgram: TOKEN_PROGRAM_ID }); + // Selling the FULL balance empties the ATA, so we can close it IN THE SAME TX to + // reclaim its rent (~0.00204◎) — otherwise it's locked forever (audit CRITICAL). + const closeIx = createCloseAccountInstruction(ata, signer.publicKey, signer.publicKey, [], TOKEN_2022_PROGRAM_ID); const { blockhash, lastValidBlockHeight } = await c.getLatestBlockhash("confirmed"); - const tx = new VersionedTransaction(new TransactionMessage({ payerKey: signer.publicKey, recentBlockhash: blockhash, instructions: [ComputeBudgetProgram.setComputeUnitLimit({ units: 230_000 }), ...ixs] }).compileToV0Message()); + const tx = new VersionedTransaction(new TransactionMessage({ payerKey: signer.publicKey, recentBlockhash: blockhash, instructions: [ComputeBudgetProgram.setComputeUnitLimit({ units: 230_000 }), ...ixs, closeIx] }).compileToV0Message()); tx.sign([signer]); const sig = bs58.encode(tx.signatures[0]); await c.sendRawTransaction(tx.serialize(), { skipPreflight: true, maxRetries: 3 }); @@ -37,9 +45,11 @@ async function sellBag(owner: Rec, mint: PublicKey, global: Awaited { - const main = ws[0]; const mainPk = new PublicKey(main.publicKey); - console.log(`Main ${main.publicKey} = ${(await sol(main.publicKey)).toFixed(4)}◎ before`); + const ws: Rec[] = await readWalletsFile(".secrets/wallets.json"); + if (ws.length < 2) { console.log("Need at least 2 wallets (main + targets)."); return; } + // Sweep destination: explicit RECOVER_MAIN (pubkey or wallet id), else wallet[0]. + // Index-independent so a reordered wallets.json can't redirect funds by accident. + const wanted = process.env.RECOVER_MAIN?.trim(); + const main = (wanted && ws.find((w) => w.publicKey === wanted || w.id === wanted)) || ws[0]; + const mainPk = new PublicKey(main.publicKey); + if (DRY) console.log("DRY RUN — no transactions will be sent\n"); + console.log(`Sweep destination: ${main.label} ${main.publicKey} = ${(await sol(main.publicKey)).toFixed(4)}◎ before`); const global = await sdk.fetchGlobal(); const feeConfig = await sdk.fetchFeeConfig(); - for (const wlt of ws.slice(1)) { + for (const wlt of ws.filter((w) => w.id !== main.id)) { // sell every pump token bag (TOKEN_2022 mints) this wallet holds const accts = await c.getParsedTokenAccountsByOwner(new PublicKey(wlt.publicKey), { programId: TOKEN_2022_PROGRAM_ID }, "confirmed").catch(() => ({ value: [] as never[] })); for (const a of accts.value) { diff --git a/scripts/resume-smoketest.ts b/scripts/resume-smoketest.ts new file mode 100644 index 0000000..b2e365e --- /dev/null +++ b/scripts/resume-smoketest.ts @@ -0,0 +1,87 @@ +// LIVE resume-from-checkpoint smoke test (tiny MAINNET spend — a few cents of SOL). +// +// SMOKETEST_CONFIRM=1 npm run resume-smoketest +// +// pump.fun is mainnet-only (no devnet program), so this verifies resume on mainnet +// with minimal amounts. It does NOT kill the process — it reproduces the exact resume +// CODE PATH against the real chain: +// Phase 1 launch + dev buy + buy wallet A (stands in for "what completed before the crash") +// Phase 2 executeLive(full plan, { resume: mint }) (the resume path) +// Then asserts on-chain: ONE coin, A NOT bought twice, B & C filled. A bug here = the +// double-spend resume exists to prevent, so this is the gate before trusting it with size. +import { readFileSync } from "node:fs"; +import { Connection, PublicKey } from "@solana/web3.js"; +import { getAssociatedTokenAddressSync, TOKEN_2022_PROGRAM_ID } from "@solana/spl-token"; +import { executeLive, type ExecutePlan } from "../server/execute"; +import { readWalletsFile } from "../server/keystore"; + +type Rec = { id: string; label: string; publicKey: string; secretKeyBase58: string }; +const num = (k: string, d: number) => Number(process.env[k]) || d; + +(async () => { + if (process.env.SMOKETEST_CONFIRM !== "1") { + console.log("This SPENDS real SOL on mainnet (~0.005◎ + fees). Re-run with SMOKETEST_CONFIRM=1 to proceed."); + process.exit(0); + } + const rpc = readFileSync(".secrets/rpc-url.txt", "utf8").trim(); + const c = new Connection(rpc, "confirmed"); + const ws = await readWalletsFile(".secrets/wallets.json"); + if (ws.length < 4) { console.error("Need ≥4 wallets (dev + A + B + C). Create/fund them first."); process.exit(1); } + const [dev, A, B, C] = ws; + + const devBuySol = num("DEV_BUY", 0.002); + const buySol = num("BUY", 0.001); + const launch = { + name: "ResumeSmoke", symbol: "RSMK", devWalletId: dev.id, devBuySol, + slippage: 40, priorityFee: 0.00002, useJito: false, rewardMode: "creator" as const, + }; + const tokenBal = async (mint: string, owner: string) => { + const ata = getAssociatedTokenAddressSync(new PublicKey(mint), new PublicKey(owner), true, TOKEN_2022_PROGRAM_ID); + return await c.getTokenAccountBalance(ata, "confirmed").then((b) => Number(b.value.amount)).catch(() => 0); + }; + + // Phase 1: launch + dev buy + buy A (the "completed-before-crash" slice). + console.log("Phase 1 — launch + buy A …"); + const plan1: ExecutePlan = { launch, steps: [{ sendMode: "normal", actions: [{ kind: "buy", walletId: A.id, sol: buySol }] }], sells: [] }; + const r1 = await executeLive(plan1, (e) => { const k = (e as { kind?: string }).kind; if (k && k !== "buy-sent") process.stdout.write(` ·${k}`); }); + process.stdout.write("\n"); + if (!r1.ok || !r1.mint) { console.error("Phase 1 launch failed:", r1.error); process.exit(1); } + const mint = r1.mint; + const aAfter1 = await tokenBal(mint, A.publicKey); + console.log(` mint ${mint} · A holds ${aAfter1}`); + if (aAfter1 <= 0) { console.error("Phase 1: wallet A did not receive tokens — aborting."); process.exit(1); } + + // Phase 2: RESUME the full plan (A already done) onto the SAME mint. + console.log("Phase 2 — resume(full A+B+C) …"); + const plan2: ExecutePlan = { launch, steps: [{ sendMode: "normal", actions: [ + { kind: "buy", walletId: A.id, sol: buySol }, { kind: "buy", walletId: B.id, sol: buySol }, { kind: "buy", walletId: C.id, sol: buySol }, + ] }], sells: [] }; + const skipped: string[] = []; + const r2 = await executeLive(plan2, (e) => { + const ev = e as { kind?: string; reason?: string; wallet?: string }; + if (ev.kind === "buy-skipped") skipped.push(ev.wallet ?? "?"); + if (ev.kind && ev.kind !== "buy-sent") process.stdout.write(` ·${ev.kind}`); + }, { resume: { mint } }); + process.stdout.write("\n"); + + // Assertions — the chain is the judge. + const aAfter2 = await tokenBal(mint, A.publicKey); + const bAfter2 = await tokenBal(mint, B.publicKey); + const cAfter2 = await tokenBal(mint, C.publicKey); + const mintAcct = await c.getAccountInfo(new PublicKey(mint), "confirmed"); + + const checks: [string, boolean][] = [ + ["resume reused the same mint (no second coin)", r2.mint === mint && !!mintAcct], + ["wallet A NOT bought twice (token balance unchanged)", aAfter2 === aAfter1], + ["wallet A reported skipped on resume", skipped.includes(A.label)], + ["wallet B filled by resume", bAfter2 > 0], + ["wallet C filled by resume", cAfter2 > 0], + ["resume run ok", r2.ok === true], + ]; + console.log("\n=== RESULTS ==="); + let pass = true; + for (const [name, ok] of checks) { console.log(` ${ok ? "✓" : "✗"} ${name}`); if (!ok) pass = false; } + console.log(`\n${pass ? "PASS — resume is safe (no double-create, no double-spend)" : "FAIL — DO NOT trust resume with size; investigate above"}`); + console.log("Recover leftover SOL/tokens with: npm run recover"); + process.exit(pass ? 0 : 1); +})().catch((e) => { console.error("smoketest crashed:", e); process.exit(1); }); diff --git a/server/engine/buy.ts b/server/engine/buy.ts index 6e529d6..6e3b7e9 100644 --- a/server/engine/buy.ts +++ b/server/engine/buy.ts @@ -13,6 +13,7 @@ import { PUMP_SDK, } from '@pump-fun/pump-sdk' import { + getAssociatedTokenAddressSync, NATIVE_MINT, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, @@ -59,6 +60,7 @@ export async function buyPumpSolToken({ blockhash: bhIn, lastValidBlockHeight: lvbhIn, fast = false, + onSent, }: { rpcUrl: string mint: string @@ -75,6 +77,9 @@ export async function buyPumpSolToken({ blockhash?: string lastValidBlockHeight?: number fast?: boolean + // fires with the signature immediately after broadcast (before confirm) so the + // caller can persist what was actually sent — crash-recoverable mid-batch. + onSent?: (signature: string) => void }) { const connection = connIn ?? new Connection(rpcUrl, 'confirmed') const onlineSdk = new OnlinePumpSdk(connection) @@ -86,7 +91,7 @@ export async function buyPumpSolToken({ let state: Awaited> | undefined for (let i = 0; i < 7; i += 1) { try { state = await onlineSdk.fetchBuyState(mintKey, signer.publicKey, TOKEN_2022_PROGRAM_ID); break; } - catch (e) { if (i === 6) throw e; await new Promise((r) => setTimeout(r, fast ? 500 : 1300)); } + catch (e) { if (i === 6) throw e; const base = fast ? 400 : 1000; await new Promise((r) => setTimeout(r, base * (i + 1) + Math.random() * 250)); } // backoff + jitter } if (!state) throw new Error("Bonding curve not found after retries"); const quoteMint = state.bondingCurve.quoteMint.equals(PublicKey.default) ? NATIVE_MINT : state.bondingCurve.quoteMint @@ -116,7 +121,7 @@ export async function buyPumpSolToken({ }) const { blockhash, lastValidBlockHeight } = bhIn && lvbhIn ? { blockhash: bhIn, lastValidBlockHeight: lvbhIn } - : await connection.getLatestBlockhash('confirmed') + : await connection.getLatestBlockhash('processed') const tx = new VersionedTransaction( new TransactionMessage({ payerKey: signer.publicKey, @@ -127,27 +132,39 @@ export async function buyPumpSolToken({ ], }).compileToV0Message(), ) + // pre-buy token balance (ATA may not exist yet → 0) for a true on-chain fill check + const buyerAta = getAssociatedTokenAddressSync(mintKey, signer.publicKey, true, TOKEN_2022_PROGRAM_ID) + const preTok = await connection.getTokenAccountBalance(buyerAta, 'processed').then((b) => BigInt(b.value.amount)).catch(() => 0n) + tx.sign([signer]) const signature = txSignature(tx) await connection.sendRawTransaction(tx.serialize(), { skipPreflight: fast, // fast path skips the preflight simulation round-trip maxRetries: 3, }) + onSent?.(signature) // persist-before-confirm: caller records the broadcast sig now try { - const confirmed = await connection.confirmTransaction({ signature, blockhash, lastValidBlockHeight }, 'confirmed') + const confirmed = await connection.confirmTransaction({ signature, blockhash, lastValidBlockHeight }, 'processed') if (confirmed.value.err) throw new Error(`Buy failed: ${JSON.stringify(confirmed.value.err)}`) } catch (err) { // blockhash can expire on a tx that actually landed — reconfirm before failing const st = await connection.getSignatureStatus(signature, { searchTransactionHistory: true }).catch(() => null) const v = st?.value - if (!(v && !v.err && (v.confirmationStatus === 'confirmed' || v.confirmationStatus === 'finalized'))) throw err + if (!(v && !v.err && (v.confirmationStatus === 'processed' || v.confirmationStatus === 'confirmed' || v.confirmationStatus === 'finalized'))) throw err } + // PHANTOM-FILL GUARD (buy side): confirm tokens actually ARRIVED on-chain. A reverted + // buy can transiently read as "confirmed" via signature status — never trust a fill we + // can't see in the balance. `verified` is true only when we positively saw the delta. + const postTok = await connection.getTokenAccountBalance(buyerAta, 'processed').then((b) => BigInt(b.value.amount)).catch(() => null) + if (postTok !== null && postTok <= preTok) throw new Error('Buy did not increase token balance (reverted / phantom fill)') + return { mint: mintKey.toBase58(), buyer: signer.publicKey.toBase58(), solAmount, tokenAmountRaw: tokenAmount.toString(), signature, + verified: postTok !== null, } } diff --git a/server/engine/launch.ts b/server/engine/launch.ts index 4e41aff..b709639 100644 --- a/server/engine/launch.ts +++ b/server/engine/launch.ts @@ -248,7 +248,7 @@ function sleep(ms: number) { async function fetchBuyStateRetry(sdk: OnlinePumpSdk, mint: PublicKey, user: PublicKey, tries = 7) { let lastErr: unknown for (let i = 0; i < tries; i += 1) { - try { return await sdk.fetchBuyState(mint, user) } catch (e) { lastErr = e; await sleep(1300) } + try { return await sdk.fetchBuyState(mint, user, TOKEN_2022_PROGRAM_ID) } catch (e) { lastErr = e; await sleep(1300) } // Token-2022: correct ATA lookup, no redundant create } throw lastErr } @@ -420,7 +420,11 @@ async function jitoTipIx(payer: PublicKey, jitoUuid: string, tipSol: number): Pr const cacheKey = jitoUuid.trim() let accounts = jitoTipAccountsByUuid.get(cacheKey) if (!accounts?.length) { - accounts = await jitoBundleRpc('getTipAccounts', [], jitoUuid, 'getTipAccounts') + // getTipAccounts is canonically a JSON-RPC method on /api/v1/bundles; some proxies + // also expose it at /api/v1/getTipAccounts. Try canonical first, then fall back, so + // a tip-account fetch can't silently fail every bundled launch (audit MEDIUM). + accounts = await jitoBundleRpc('getTipAccounts', [], jitoUuid, 'bundles') + .catch(() => jitoBundleRpc('getTipAccounts', [], jitoUuid, 'getTipAccounts')) jitoTipAccountsByUuid.set(cacheKey, accounts) } const tipAccount = new PublicKey(accounts[Math.floor(Math.random() * accounts.length)]) @@ -571,15 +575,17 @@ async function verifyBundleOnChain( ) { const started = Date.now() while (Date.now() - started < 45_000) { + // 'processed' detects the landing ~1-2 slots sooner than 'confirmed' → the create + // step returns fast once the bundle lands (was the slow part). const [mintInfo, statuses] = await Promise.all([ - connection.getAccountInfo(mint, 'confirmed'), + connection.getAccountInfo(mint, 'processed'), connection.getSignatureStatuses(signatures, { searchTransactionHistory: true }), ]) const failed = statuses.value.find((status) => status?.err) if (failed?.err) throw new Error(`Bundle transaction failed on-chain: ${JSON.stringify(failed.err)}`) - const confirmed = statuses.value.filter((status) => status?.confirmationStatus === 'confirmed' || status?.confirmationStatus === 'finalized') + const confirmed = statuses.value.filter((status) => status?.confirmationStatus === 'processed' || status?.confirmationStatus === 'confirmed' || status?.confirmationStatus === 'finalized') if (mintInfo && confirmed.length === signatures.length) return - await sleep(1_500) + await sleep(500) } throw new Error('Bundle landed signal was seen, but mint/signatures were not confirmed by RPC yet') } @@ -980,44 +986,36 @@ async function sendBundledLaunch({ feeConfig: FeeConfig | null uri: string }): Promise<{ signature: string; bundleId: string; buySignatures: string[] }> { - const buyers = input.followBuyers - .filter((item) => Number.isFinite(item.amount) && item.amount > 0) - .slice(0, 4) + // Buyers fit the 5-tx Jito ceiling: 5 − (create + dev buy) tx, batched 2 buyers/tx. + const reservedTx = 1 + (input.devBuySol > 0 ? 1 : 0) + const maxBuyers = (MAX_JITO_BUNDLE_TXS - reservedTx) * 2 // 6 with a dev buy, 8 without + const allBuyers = input.followBuyers.filter((item) => Number.isFinite(item.amount) && item.amount > 0) + const buyers = allBuyers.slice(0, maxBuyers) if (!input.jitoUuid.trim()) throw new Error('Add Jito UUID for bundle') if (!params.quoteMint.equals(NATIVE_MINT)) throw new Error('Bundle v1 supports SOL pairs only') if (params.agent) throw new Error('Bundle v1 does not support agent launches') if (buyers.length === 0) throw new Error('Add bundler wallets') - if (buyers.length !== input.followBuyers.filter((item) => Number.isFinite(item.amount) && item.amount > 0).length) { - throw new Error('Jito bundle max 4 buyer wallets') + if (allBuyers.length > maxBuyers) { + throw new Error(`Jito bundle max ${maxBuyers} buyer wallets (5-tx limit${input.devBuySol > 0 ? " with a dev buy" : ""})`) } - if (buyQuoteAmount.eq(new BN(0))) throw new Error('Bundle needs dev buy') - const jitoTipPayer = input.jitoTipWallet ? keypair(input.jitoTipWallet) : devBuyer - const externalJitoTipPayer = !jitoTipPayer.publicKey.equals(devBuyer.publicKey) + // The DEV/creator wallet ALWAYS pays the Jito tip — no standalone tip wallet, no + // prefund dance. The tip rides the dev-buy tx when there's a dev buy, else the create + // tx (the dev signs both), so a 0-SOL dev buy bundles fine. + const hasDevBuy = buyQuoteAmount.gt(new BN(0)) + // Fixed, generous default tip so the bundle lands on the FIRST attempt (0.1◎ is what + // reliably worked) — no congestion guessing, no slow non-landing retries. Held flat + // across retries (it already wins inclusion; retries just refresh the blockhash). + const baseTipSol = Math.max(Number.isFinite(input.jitoTipSol) ? input.jitoTipSol : 0, 0.1) + const maxTipLamports = Math.ceil(baseTipSol * LAMPORTS_PER_SOL) const devBalance = await connection.getBalance(devBuyer.publicKey, 'processed') const devFundingBufferSol = Number(input.jitoDevPrefundSol) > 0 ? Number(input.jitoDevPrefundSol) : 0.03 - const requiredDevLamports = Math.ceil((input.devBuySol + devFundingBufferSol) * LAMPORTS_PER_SOL) - const minimumDevBalance = requiredDevLamports + (externalJitoTipPayer ? 0 : Math.ceil(input.jitoTipSol * LAMPORTS_PER_SOL)) - const autoPrefundLamports = externalJitoTipPayer ? Math.max(0, requiredDevLamports - devBalance) : 0 - const prefundLamports = autoPrefundLamports - if (devBalance + prefundLamports < minimumDevBalance) { - throw new Error(`Dev wallet needs at least ${(minimumDevBalance / LAMPORTS_PER_SOL).toFixed(3)} SOL for this bundle${externalJitoTipPayer ? ' dev buy/rent' : ' tip/dev buy/rent'}. Current: ${(devBalance / LAMPORTS_PER_SOL).toFixed(6)} SOL`) - } - if (externalJitoTipPayer) { - const tipBalance = await connection.getBalance(jitoTipPayer.publicKey, 'processed') - const minimumTipBalance = Math.ceil(input.jitoTipSol * LAMPORTS_PER_SOL) + prefundLamports + Math.ceil(0.002 * LAMPORTS_PER_SOL) - if (tipBalance < minimumTipBalance) { - throw new Error(`Jito tip wallet needs at least ${(minimumTipBalance / LAMPORTS_PER_SOL).toFixed(3)} SOL for tip/fees. Current: ${(tipBalance / LAMPORTS_PER_SOL).toFixed(6)} SOL`) - } + const minimumDevBalance = Math.ceil((input.devBuySol + devFundingBufferSol) * LAMPORTS_PER_SOL) + maxTipLamports + if (devBalance < minimumDevBalance) { + throw new Error(`Dev wallet needs at least ${(minimumDevBalance / LAMPORTS_PER_SOL).toFixed(3)} SOL for this bundle (create + dev buy + Jito tip). Current: ${(devBalance / LAMPORTS_PER_SOL).toFixed(6)} SOL`) } - const { blockhash } = await connection.getLatestBlockhash('processed') - const localCurve = newBondingCurve(global, params.quoteMint) - localCurve.creator = creator - localCurve.isCashbackCoin = params.cashback - localCurve.quoteMint = params.quoteMint - - const txs: VersionedTransaction[] = [] + // --- invariants (blockhash/tip-independent), built once and reused across retries --- const createIx = await PUMP_SDK.createV2Instruction({ mint: mint.publicKey, name: input.name.trim(), @@ -1029,32 +1027,10 @@ async function sendBundledLaunch({ cashback: params.cashback, quoteMint: params.quoteForCreate, }) - const createTx = buildTx( - signer.publicKey, - blockhash, - [createIx], - uniqueSigners([signer, mint, devBuyer]), - 'bundle create tx', - ) - - const applyBuy = (quoteAmountValue: BN) => - applyBuyToLocalCurve(global, feeConfig, localCurve, quoteAmountValue, params.quoteMint) - const buildBuyIxs = async (buyer: Keypair, amountValue: BN, maxQuoteAmount: BN): Promise => { - const associatedUser = getAssociatedTokenAddressSync( - mint.publicKey, - buyer.publicKey, - true, - TOKEN_2022_PROGRAM_ID, - ) + const associatedUser = getAssociatedTokenAddressSync(mint.publicKey, buyer.publicKey, true, TOKEN_2022_PROGRAM_ID) return [ - createAssociatedTokenAccountIdempotentInstruction( - buyer.publicKey, - associatedUser, - buyer.publicKey, - mint.publicKey, - TOKEN_2022_PROGRAM_ID, - ), + createAssociatedTokenAccountIdempotentInstruction(buyer.publicKey, associatedUser, buyer.publicKey, mint.publicKey, TOKEN_2022_PROGRAM_ID), await PUMP_SDK.getBuyInstructionRaw({ user: buyer.publicKey, mint: mint.publicKey, @@ -1067,92 +1043,83 @@ async function sendBundledLaunch({ }), ] } - - const computedDevTokenAmount = applyBuy(buyQuoteAmount) - if (computedDevTokenAmount.eq(new BN(0)) || devTokenAmount.eq(new BN(0))) throw new Error('Bundle dev buy amount too small') - const devBuyIxs = await buildBuyIxs(devBuyer, computedDevTokenAmount, maxQuoteBeforeSdkSlippage) - if (!externalJitoTipPayer) { - devBuyIxs.push(await jitoTipIx(devBuyer.publicKey, input.jitoUuid, input.jitoTipSol)) - } - const devBuyTx = buildTx(devBuyer.publicKey, blockhash, devBuyIxs, [devBuyer], 'bundle dev buy tx') - if (!input.fastMode) { - const createSimulation = await connection.simulateTransaction(createTx, { - sigVerify: false, - commitment: 'processed', - }) - if (createSimulation.value.err) { - throw new Error(`Bundle TX1 simulation failed: ${JSON.stringify(createSimulation.value.err)} ${createSimulation.value.logs?.slice(-8).join(' | ') || ''}`) - } - } - if (externalJitoTipPayer) { - const tipAndPrefundIxs: TransactionInstruction[] = [] - if (prefundLamports > 0) { - tipAndPrefundIxs.push(SystemProgram.transfer({ - fromPubkey: jitoTipPayer.publicKey, - toPubkey: devBuyer.publicKey, - lamports: prefundLamports, - })) - } - tipAndPrefundIxs.push(await jitoTipIx(jitoTipPayer.publicKey, input.jitoUuid, input.jitoTipSol)) - txs.push(buildTx( - jitoTipPayer.publicKey, - blockhash, - tipAndPrefundIxs, - [jitoTipPayer], - 'bundle jito tip/prefund tx', - )) - } - txs.push(createTx, devBuyTx) - const buyEntries = buyers.map((buyerInput) => { - const quoteAmountValue = quoteAmount(buyerInput.amount, params.quoteDecimals) - return { - signer: keypair(buyerInput.wallet), - quoteAmountValue, - maxQuoteAmount: quoteAmountForSlippage(quoteAmountValue, input.slippage), - } - }).filter((entry) => entry.quoteAmountValue.gt(new BN(0))) - + const quoteAmountValue = quoteAmount(buyerInput.amount, params.quoteDecimals) + return { signer: keypair(buyerInput.wallet), quoteAmountValue, maxQuoteAmount: quoteAmountForSlippage(quoteAmountValue, input.slippage) } + }).filter((entry) => entry.quoteAmountValue.gt(new BN(0))) const buyBatches: Array = [] - for (let i = 0; i < buyEntries.length; i += 2) { - buyBatches.push(buyEntries.slice(i, i + 2)) - } - for (const batch of buyBatches) { - const instructions: TransactionInstruction[] = [] - const signers: Keypair[] = [] - for (const entry of batch) { - const tokenAmount = applyBuy(entry.quoteAmountValue) - if (tokenAmount.eq(new BN(0))) continue - const buyer = entry.signer - signers.push(buyer) - instructions.push(...await buildBuyIxs(buyer, tokenAmount, entry.maxQuoteAmount)) + for (let i = 0; i < buyEntries.length; i += 2) buyBatches.push(buyEntries.slice(i, i + 2)) + + // Build the WHOLE bundle for a given blockhash + tip. A FRESH local curve each call so + // re-built buys quote identically on every retry attempt. + const buildBundle = async (blockhash: string, tipSol: number): Promise<{ txs: VersionedTransaction[]; createTx: VersionedTransaction; devBuyTx: VersionedTransaction | null }> => { + const localCurve = newBondingCurve(global, params.quoteMint) + localCurve.creator = creator + localCurve.isCashbackCoin = params.cashback + localCurve.quoteMint = params.quoteMint + const applyBuy = (q: BN) => applyBuyToLocalCurve(global, feeConfig, localCurve, q, params.quoteMint) + const txs: VersionedTransaction[] = [] + // No dev buy → tip rides the create tx (dev signs it). Else it rides the dev buy. + const createIxs = hasDevBuy ? [createIx] : [createIx, await jitoTipIx(signer.publicKey, input.jitoUuid, tipSol)] + const createTx = buildTx(signer.publicKey, blockhash, createIxs, uniqueSigners([signer, mint, devBuyer]), 'bundle create tx') + let devBuyTx: VersionedTransaction | null = null + if (hasDevBuy) { + const computedDevTokenAmount = applyBuy(buyQuoteAmount) + if (computedDevTokenAmount.eq(new BN(0)) || devTokenAmount.eq(new BN(0))) throw new Error('Bundle dev buy amount too small') + const devBuyIxs = await buildBuyIxs(devBuyer, computedDevTokenAmount, maxQuoteBeforeSdkSlippage) + devBuyIxs.push(await jitoTipIx(devBuyer.publicKey, input.jitoUuid, tipSol)) + devBuyTx = buildTx(devBuyer.publicKey, blockhash, devBuyIxs, [devBuyer], 'bundle dev buy tx') + } + txs.push(createTx) + if (devBuyTx) txs.push(devBuyTx) + for (const batch of buyBatches) { + const instructions: TransactionInstruction[] = [] + const signers: Keypair[] = [] + for (const entry of batch) { + const tokenAmount = applyBuy(entry.quoteAmountValue) + if (tokenAmount.eq(new BN(0))) continue + signers.push(entry.signer) + instructions.push(...await buildBuyIxs(entry.signer, tokenAmount, entry.maxQuoteAmount)) + } + if (instructions.length > 0 && signers[0]) txs.push(buildTx(signers[0].publicKey, blockhash, instructions, uniqueSigners(signers), `bundle buyer tx ${txs.length + 1}`)) } - if (instructions.length > 0 && signers[0]) { - txs.push(buildTx(signers[0].publicKey, blockhash, instructions, uniqueSigners(signers), `bundle buyer tx ${txs.length + 1}`)) + if (txs.length > MAX_JITO_BUNDLE_TXS) throw new Error('Jito bundle max 5 txs') + if (!input.fastMode) { + const sim = await connection.simulateTransaction(createTx, { sigVerify: false, commitment: 'processed' }) + if (sim.value.err) throw new Error(`Bundle TX1 simulation failed: ${JSON.stringify(sim.value.err)} ${sim.value.logs?.slice(-8).join(' | ') || ''}`) + await simulateJitoBundle(input.rpcUrl, txs) } + return { txs, createTx, devBuyTx } } - if (txs.length > MAX_JITO_BUNDLE_TXS) throw new Error('Jito bundle max 5 txs') - if (!input.fastMode) await simulateJitoBundle(input.rpcUrl, txs) - const bundleId = await sendJitoBundle(txs, input.jitoUuid, input.rpcUrl) - const signatures = txs.map(txSignature) - const createSignature = txSignature(createTx) - const buySignatures = [txSignature(devBuyTx), ...txs.slice(txs.indexOf(devBuyTx) + 1).map(txSignature)] - let stopJitoPoll = false - const jitoStatusPromise = pollJitoBundle(bundleId, input.jitoUuid, () => stopJitoPoll).catch((error) => error) - try { - await verifyBundleOnChain(connection, mint.publicKey, signatures) - stopJitoPoll = true - } catch (error) { - const jitoStatusError = await jitoStatusPromise - const jitoMessage = jitoStatusError instanceof Error ? ` Jito status: ${jitoStatusError.message}.` : '' - throw new Error(`Jito bundle ${bundleId} did not confirm on-chain.${jitoMessage} RPC verify: ${error instanceof Error ? error.message : 'unknown error'}`) - } - return { - signature: createSignature, - bundleId, - buySignatures, + // A bundle is NOT guaranteed to land — it can lose the slot race and the blockhash goes + // stale (Jito reports "Invalid"). Retry up to 3× with a FRESH blockhash + an escalating + // tip so a transient drop self-heals instead of failing the launch. The tip is only paid + // on the attempt that actually lands, so retries don't stack cost. + const ATTEMPTS = 3 + let lastError: Error | null = null + for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) { + const tipSol = baseTipSol // flat 0.1◎ — lands first try; retries only refresh the blockhash + const { blockhash } = await connection.getLatestBlockhash('processed') + const { txs, createTx, devBuyTx } = await buildBundle(blockhash, tipSol) + const bundleId = await sendJitoBundle(txs, input.jitoUuid, input.rpcUrl) + const signatures = txs.map(txSignature) + let stopJitoPoll = false + const jitoStatusPromise = pollJitoBundle(bundleId, input.jitoUuid, () => stopJitoPoll).catch((error) => error) + try { + await verifyBundleOnChain(connection, mint.publicKey, signatures) + stopJitoPoll = true + const buyerTxs = txs.filter((t) => t !== createTx && t !== devBuyTx) + const buySignatures = [...(devBuyTx ? [txSignature(devBuyTx)] : []), ...buyerTxs.map(txSignature)] + return { signature: txSignature(createTx), bundleId, buySignatures } + } catch (error) { + stopJitoPoll = true + const jitoStatusError = await jitoStatusPromise + const jitoMessage = jitoStatusError instanceof Error ? ` Jito status: ${jitoStatusError.message}.` : '' + lastError = new Error(`Jito bundle ${bundleId} did not land (attempt ${attempt + 1}/${ATTEMPTS}, tip ${tipSol.toFixed(4)}◎).${jitoMessage} RPC verify: ${error instanceof Error ? error.message : 'unknown error'}`) + } } + throw lastError ?? new Error('Jito bundle did not land after retries') } export async function launchToken(input: LaunchInput): Promise { @@ -1393,7 +1360,7 @@ export async function launchToken(input: LaunchInput): Promise { const buyer = keypair(buyerInput.wallet) const buyerQuoteAmount = quoteAmount(buyerInput.amount, params.quoteDecimals) if (buyerQuoteAmount.eq(new BN(0))) continue - const state = await onlineSdk.fetchBuyState(mint.publicKey, buyer.publicKey) + const state = await onlineSdk.fetchBuyState(mint.publicKey, buyer.publicKey, TOKEN_2022_PROGRAM_ID) const buyerTokenAmount = getBuyTokenAmountFromSolAmount({ global, feeConfig, diff --git a/server/execute.ts b/server/execute.ts index 85e63ad..fd038e3 100644 --- a/server/execute.ts +++ b/server/execute.ts @@ -31,6 +31,7 @@ import bs58 from "bs58"; import { launchToken, type CoinType, type RewardMode } from "./engine/launch"; import { buyPumpSolToken } from "./engine/buy"; import type { WalletRecord } from "./engine/session"; +import { readWalletsFile } from "./keystore"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const secretsDir = path.join(root, ".secrets"); @@ -70,12 +71,18 @@ export type ExecutePlan = { launch: LaunchCfg; steps: PlanStep[]; sells?: PlanSe type StoredWallet = { id: string; label: string; publicKey: string; secretKeyBase58: string }; -async function rpcUrl(): Promise { - if (process.env.RPC_URL?.trim()) return process.env.RPC_URL.trim(); +// Pure: merge env (RPC_URLS comma-sep or RPC_URL) + rpc-url.txt (one URL per line), +// dedupe, default to public mainnet. Multiple URLs enable failover at run start. +export function parseRpcUrls(envCsv: string, fileText: string): string[] { + const out = [...envCsv.split(","), ...fileText.split(/\r?\n/)].map((s) => s.trim()).filter(Boolean); + return out.length ? [...new Set(out)] : ["https://api.mainnet-beta.solana.com"]; +} +async function rpcUrls(): Promise { const file = path.join(secretsDir, "rpc-url.txt"); - if (existsSync(file)) { const v = (await readFile(file, "utf8")).trim(); if (v) return v; } - return "https://api.mainnet-beta.solana.com"; + const fileText = existsSync(file) ? await readFile(file, "utf8") : ""; + return parseRpcUrls(process.env.RPC_URLS || process.env.RPC_URL || "", fileText); } +async function rpcUrl(): Promise { return (await rpcUrls())[0]; } async function readOptional(name: string): Promise { const file = path.join(secretsDir, name); if (!existsSync(file)) return ""; @@ -83,9 +90,8 @@ async function readOptional(name: string): Promise { } async function loadWallets(): Promise> { const file = path.join(secretsDir, "wallets.json"); - if (!existsSync(file)) return new Map(); - const parsed = JSON.parse(await readFile(file, "utf8")) as { wallets?: StoredWallet[] }; - return new Map((parsed.wallets ?? []).map((w) => [w.id, w])); + const wallets = await readWalletsFile(file); // transparent plaintext/encrypted + return new Map(wallets.map((w) => [w.id, w])); } function toRecord(w: StoredWallet): WalletRecord { return { id: w.id, name: w.label, secretKey: w.secretKeyBase58, publicKey: w.publicKey }; @@ -109,6 +115,19 @@ function resolveDevWallet(plan: ExecutePlan, wallets: Map) return (firstBuy && wallets.get(firstBuy)) || [...wallets.values()][0] || null; } +// Spend guardrails — hard caps so a malformed/fat-finger plan can't drain a wallet +// (override via env). Shared by the dry-run audit AND the live runtime guard so a +// rotation relay can't move more than the cap at execution time. +function execCaps() { + return { + devBuy: Number(process.env.LILY_MAX_DEV_BUY) || 5, + perBuy: Number(process.env.LILY_MAX_PER_BUY) || 5, + totalSpend: Number(process.env.LILY_MAX_TOTAL_SPEND) || 20, + slippage: Number(process.env.LILY_MAX_SLIPPAGE) || 50, + priorityFee: Number(process.env.LILY_MAX_PRIORITY_FEE) || 0.01, + }; +} + // ---------- AUDIT (dry-run) ---------- export async function audit(plan: ExecutePlan) { const wallets = await loadWallets(); @@ -123,34 +142,39 @@ export async function audit(plan: ExecutePlan) { if (!plan.launch.imageDataUrl) warnings.push("No image set — pump upload will be used at launch."); // ---- spend guardrails: hard caps so a malformed/fat-finger plan can't drain a wallet (override via env) ---- - const CAPS = { - devBuy: Number(process.env.LILY_MAX_DEV_BUY) || 5, - perBuy: Number(process.env.LILY_MAX_PER_BUY) || 5, - totalSpend: Number(process.env.LILY_MAX_TOTAL_SPEND) || 20, - slippage: Number(process.env.LILY_MAX_SLIPPAGE) || 50, - }; + const CAPS = execCaps(); const num = (v: unknown) => Number(v) || 0; const devBuy = num(plan.launch.devBuySol); const allBuys = plan.steps.flatMap((s) => s.actions).filter((a) => a.kind === "buy"); - const totalSpend = devBuy + allBuys.reduce((t, a) => t + num(a.sol), 0); + const rotateBuys = plan.steps.flatMap((s) => s.actions).filter((a) => a.kind === "rotate"); + // rotations re-buy with swept proceeds — counted as an UPPER BOUND so the cap can't be + // bypassed via relays (see planSpendEstimate). + const totalSpend = planSpendEstimate(plan.launch.devBuySol, allBuys, rotateBuys); if (devBuy < 0 || allBuys.some((a) => num(a.sol) < 0)) errors.push("Negative amounts are not allowed."); if (devBuy > CAPS.devBuy) errors.push(`Dev buy ${devBuy}◎ exceeds cap ${CAPS.devBuy}◎.`); for (const a of allBuys) if (num(a.sol) > CAPS.perBuy) errors.push(`A buy of ${num(a.sol)}◎ exceeds the per-buy cap ${CAPS.perBuy}◎.`); - if (totalSpend > CAPS.totalSpend) errors.push(`Total buy spend ${totalSpend.toFixed(3)}◎ exceeds cap ${CAPS.totalSpend}◎.`); + if (totalSpend > CAPS.totalSpend) errors.push(`Total spend ${totalSpend.toFixed(3)}◎ (incl. rotation re-buys) exceeds cap ${CAPS.totalSpend}◎.`); if (num(plan.launch.slippage) > CAPS.slippage) errors.push(`Slippage ${num(plan.launch.slippage)}% exceeds cap ${CAPS.slippage}%.`); + if (num(plan.launch.priorityFee) > CAPS.priorityFee) errors.push(`Priority fee ${num(plan.launch.priorityFee)}◎ exceeds cap ${CAPS.priorityFee}◎.`); // SOL needed per wallet (buys + dev create/devbuy). Rotate-in buys are funded // by the preceding sell, so they don't add fresh funding need. + const pri = Math.max(0, Number(plan.launch.priorityFee) || 0); // every tx pays this on top of the fee buffer + // MUST match walletFundNeeds (src/lib/fundNeeds.ts): a buy can spend up to sol*(1+slippage), + // so provision the slippage-max or buys fail "insufficient lamports" after the bundle moves price. + const slip = Math.max(0, Number(plan.launch.slippage) || 0) / 100; + const buyCost = (sol: unknown) => (Number(sol) || 0) * (1 + slip) + FEE_BUFFER_SOL + pri; const need = new Map(); const addNeed = (id: string, sol: number) => need.set(id, (need.get(id) ?? 0) + sol); - if (dev) addNeed(dev.id, CREATE_BUFFER_SOL + (Number(plan.launch.devBuySol) || 0) + FEE_BUFFER_SOL); + if (dev) addNeed(dev.id, CREATE_BUFFER_SOL + buyCost(Number(plan.launch.devBuySol) || 0)); const annotated = plan.steps.map((step, i) => { const actions = step.actions.map((a) => { if (!wallets.has(a.walletId)) errors.push(`Step ${i + 1}: unknown wallet ${a.walletId.slice(0, 6)}`); if (a.kind === "rotate" && a.toWalletId && !wallets.has(a.toWalletId)) errors.push(`Step ${i + 1}: unknown rotate target`); - if (a.kind === "buy") addNeed(a.walletId, (Number(a.sol) || 0) + FEE_BUFFER_SOL); - if (a.kind === "sell" || a.kind === "rotate") addNeed(a.walletId, FEE_BUFFER_SOL); + if (a.kind === "buy") addNeed(a.walletId, buyCost(a.sol)); + if (a.kind === "sell") addNeed(a.walletId, FEE_BUFFER_SOL + pri); + if (a.kind === "rotate") { addNeed(a.walletId, FEE_BUFFER_SOL + pri); if (a.toWalletId) addNeed(a.toWalletId, FEE_BUFFER_SOL + pri); } // source sells, target re-buys (rent+fee buffer) return { ...a, wallet: wallets.get(a.walletId)?.label ?? a.walletId }; }); // Honesty: only the opening bundle (first step, bundled, Jito on) actually @@ -164,7 +188,7 @@ export async function audit(plan: ExecutePlan) { [...need.entries()].map(async ([id, needSol]) => { const w = wallets.get(id); const have = w ? await connection.getBalance(new PublicKey(w.publicKey), "confirmed").then((l) => l / LAMPORTS_PER_SOL).catch(() => 0) : 0; - const ok = have + 1e-9 >= needSol; + const ok = have + 0.001 >= needSol; // rounding-safe: "funded to the displayed need" must not read as short if (!ok) warnings.push(`${w?.label ?? id}: needs ${needSol.toFixed(3)}◎, has ${have.toFixed(3)}◎`); return { walletId: id, label: w?.label ?? id, publicKey: w?.publicKey, needSol, haveSol: have, ok }; }), @@ -185,18 +209,113 @@ export async function audit(plan: ExecutePlan) { } // ---------- LIVE on-chain ---------- +const RPC_TIMEOUT_MS = 20_000; // cap any single RPC await so a hung socket can't wedge the run +const BUY_TIMEOUT_MS = 60_000; // cap a whole buy (build+send+confirm) — bounds the buy path +const MAX_RUN_MS = 8 * 60 * 1000; // overall live-run wall-clock deadline + +/** Reject if a promise (e.g. a hung RPC call) doesn't settle in `ms`. Without this + * a single stuck getBalance/getTokenAccountBalance call hangs executeLive forever, + * the single-flight lock never releases, and the launcher is bricked (audit HIGH). */ +function withTimeout(p: Promise, ms: number, what: string): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(`timeout: ${what} (${ms}ms)`)), ms); + p.then((v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); }); + }); +} + +/** Failover at run start: probe each RPC (cheap getLatestBlockhash) and use the first + * healthy one for the whole run, so a dead/rate-limited primary doesn't fail a launch. + * Falls back to the first URL if none respond (best effort — the run will surface it). */ +async function pickHealthyConnection(urls: string[]): Promise<{ connection: Connection; url: string }> { + for (const url of urls) { + try { + const c = new Connection(url, "confirmed"); + await withTimeout(c.getLatestBlockhash("confirmed"), 8_000, "rpc-probe"); + return { connection: c, url }; + } catch { /* try next endpoint */ } + } + return { connection: new Connection(urls[0], "confirmed"), url: urls[0] }; +} + +/** Bounded-concurrency map, results in input order, allSettled semantics (never + * rejects). Firing all N buys at once on one curve cascades slippage and 429s the + * RPC into silent under-fills at scale (audit HIGH) — cap how many are in flight and + * jitter their starts so 60 wallets land smoothly instead of stampeding one slot. */ +export async function mapLimit( + items: T[], limit: number, staggerMs: number, fn: (item: T, index: number) => Promise, +): Promise[]> { + const results = new Array>(items.length); + let next = 0; + const worker = async () => { + for (;;) { + const i = next++; + if (i >= items.length) return; + if (staggerMs > 0 && i >= limit) await new Promise((r) => setTimeout(r, Math.random() * staggerMs)); + try { results[i] = { status: "fulfilled", value: await fn(items[i], i) }; } + catch (reason) { results[i] = { status: "rejected", reason }; } + } + }; + await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length || 1)) }, worker)); + return results; +} + +// ---- on-chain P&L reconciliation ---- +// Honor the rule: report the CHAIN, not the logs. After a run we read each involved +// wallet's real SOL + token balance and diff against the opening snapshot, so the +// operator sees what actually happened on-chain (not what the optimistic log claims). +type CloseRow = { id: string; label: string; sol: number; tokens: number }; + +/** Pure: turn opening SOL + closing rows into a per-wallet + aggregate P&L report. */ +export function summarizeReconciliation(opening: Map, closing: CloseRow[]) { + const wallets = closing.map((c) => ({ + label: c.label, + solDelta: Number((c.sol - (opening.get(c.id) ?? c.sol)).toFixed(6)), + tokens: c.tokens, + })); + const withTokens = wallets.filter((w) => w.tokens > 0); + return { + netSolDelta: Number(wallets.reduce((s, w) => s + w.solDelta, 0).toFixed(6)), + walletsWithTokens: withTokens.length, + totalTokens: Number(withTokens.reduce((s, w) => s + w.tokens, 0).toFixed(2)), + wallets, + }; +} + +async function snapshotOpeningSol(connection: Connection, wallets: { id: string; publicKey: string }[]): Promise> { + const m = new Map(); + const res = await mapLimit(wallets, 8, 0, async (w) => ({ + id: w.id, + sol: await withTimeout(connection.getBalance(new PublicKey(w.publicKey), "confirmed"), RPC_TIMEOUT_MS, "getBalance(open)").then((l) => l / LAMPORTS_PER_SOL), + })); + for (const r of res) if (r.status === "fulfilled") m.set(r.value.id, r.value.sol); + return m; +} + +async function snapshotClosing(connection: Connection, mint: PublicKey, wallets: { id: string; label: string; publicKey: string }[]): Promise { + const res = await mapLimit(wallets, 8, 0, async (w): Promise => { + const pk = new PublicKey(w.publicKey); + const sol = await withTimeout(connection.getBalance(pk, "confirmed"), RPC_TIMEOUT_MS, "getBalance(close)").then((l) => l / LAMPORTS_PER_SOL).catch(() => 0); + const ata = getAssociatedTokenAddressSync(mint, pk, true, TOKEN_2022_PROGRAM_ID); + const bal = await withTimeout(connection.getTokenAccountBalance(ata, "confirmed"), RPC_TIMEOUT_MS, "getTokenBal(close)").catch(() => null); + return { id: w.id, label: w.label, sol, tokens: bal?.value.uiAmount ?? 0 }; + }); + return res.flatMap((r) => (r.status === "fulfilled" ? [r.value] : [])); +} + // Confirm a tx, but treat blockhash-expiry as success if the signature actually // landed (confirmTransaction throws TransactionExpiredBlockheightExceededError // even for txns that confirmed) — prevents false failures that strand a rotate. async function confirmSig(connection: Connection, signature: string, blockhash: string, lastValidBlockHeight: number) { try { - const c = await connection.confirmTransaction({ signature, blockhash, lastValidBlockHeight }, "confirmed"); + // "processed" confirms ~1 slot sooner than "confirmed" — faster relay; the token-delta + // guard is the safety net against a dropped/processed-then-reverted tx. + const c = await connection.confirmTransaction({ signature, blockhash, lastValidBlockHeight }, "processed"); if (c.value.err) throw new Error(`tx failed: ${JSON.stringify(c.value.err)}`); return; } catch (err) { const st = await connection.getSignatureStatus(signature, { searchTransactionHistory: true }).catch(() => null); const v = st?.value; - if (v && !v.err && (v.confirmationStatus === "confirmed" || v.confirmationStatus === "finalized")) return; + if (v && !v.err && (v.confirmationStatus === "processed" || v.confirmationStatus === "confirmed" || v.confirmationStatus === "finalized")) return; throw err; } } @@ -208,7 +327,7 @@ const COMPUTE_UNITS = 230_000; let bhCache: { v: { blockhash: string; lastValidBlockHeight: number }; at: number } | null = null; async function sharedBlockhash(connection: Connection) { if (bhCache && Date.now() - bhCache.at < 30_000) return bhCache.v; - const v = await connection.getLatestBlockhash("confirmed"); + const v = await withTimeout(connection.getLatestBlockhash("processed"), RPC_TIMEOUT_MS, "getLatestBlockhash"); bhCache = { v, at: Date.now() }; return v; } @@ -233,15 +352,15 @@ async function sellByPercent(opts: { // a valid rotation can be wrongly skipped if the source's just-bought balance hasn't propagated. let total = new BN(0); for (let i = 0; i < 7; i++) { - const bal = await connection.getTokenAccountBalance(ata, "confirmed").catch(() => null); + const bal = await withTimeout(connection.getTokenAccountBalance(ata, "processed"), RPC_TIMEOUT_MS, "getTokenAccountBalance").catch(() => null); total = new BN(bal?.value.amount || "0"); if (!total.isZero()) break; - if (i < 6) await new Promise((r) => setTimeout(r, 1200)); + if (i < 6) await new Promise((r) => setTimeout(r, 400 * (i + 1) + Math.random() * 200)); // backoff + jitter (tightened for speed) } if (total.isZero()) return { skipped: "empty", solOut: 0 }; const amount = total.muln(Math.max(1, Math.min(100, Math.round(pct)))).divn(100); if (amount.isZero()) return { skipped: "too-small", solOut: 0 }; - const state = await sdk.fetchSellState(mint, seller.publicKey, TOKEN_2022_PROGRAM_ID); + const state = await withTimeout(sdk.fetchSellState(mint, seller.publicKey, TOKEN_2022_PROGRAM_ID), RPC_TIMEOUT_MS, "fetchSellState"); const quoteAmount = getSellSolAmountFromTokenAmount({ global, feeConfig, mintSupply: state.bondingCurve.tokenTotalSupply, bondingCurve: state.bondingCurve, amount }); const ixs = await PUMP_SDK.sellV2Instructions({ global, ...state, mint, user: seller.publicKey, amount, quoteAmount, slippage, tokenProgram: TOKEN_2022_PROGRAM_ID, quoteTokenProgram: TOKEN_PROGRAM_ID }); const { blockhash, lastValidBlockHeight } = await sharedBlockhash(connection); @@ -253,6 +372,23 @@ async function sellByPercent(opts: { const signature = bs58.encode(tx.signatures[0]); await connection.sendRawTransaction(tx.serialize(), { skipPreflight: true, maxRetries: 3 }); await confirmSig(connection, signature, blockhash, lastValidBlockHeight); + // PHANTOM-FILL GUARD: confirm the sell actually reduced our on-chain token balance — + // but RETRY to absorb RPC read-after-write lag. A freshly-confirmed sell can read stale + // for a second or two (Helius routes reads across nodes); without retries a perfectly + // good sell false-reads as "unconfirmed" and the relay skips the sweep, stranding the + // proceeds and breaking the chain (observed live: 3 of 4 rotation chains broke this way). + let after = total; + for (let i = 0; i < 6; i += 1) { + after = await withTimeout( + connection.getTokenAccountBalance(ata, "processed").then((b) => new BN(b.value.amount)).catch(() => after), + RPC_TIMEOUT_MS, "getTokenAccountBalance(verify)", + ); + if (after.lt(total)) break; // balance dropped → sell genuinely landed + await new Promise((r) => setTimeout(r, 350 + Math.random() * 200)); + } + // Only a sell whose balance NEVER dropped after retries is a real phantom (confirmSig + // already verified the tx had no error, so this is rare — a true revert via the status race). + if (after.gte(total)) return { skipped: "unconfirmed", solOut: 0 }; return { signature, solOut: quoteAmount.toNumber() / LAMPORTS_PER_SOL }; } @@ -271,58 +407,147 @@ async function transferSol(connection: Connection, from: Keypair, to: PublicKey, return signature; } -export async function executeLive(plan: ExecutePlan, onLog: (entry: unknown) => void) { +/** Pure resume decision: given the planned buy wallets + rotation targets and the set + * of wallets that ALREADY hold tokens on-chain, return what to skip. The chain is the + * source of truth — we never re-run an action whose effect we can already see, and we + * never trust a checkpoint over the chain. This is the safety-critical core of resume. */ +export function resumeSkipSets(args: { + buyWalletIds: string[]; + rotateTargets: (string | undefined)[]; + done: Set; // completed action keys from the checkpoint: "buy:", "rotate:" + heldIds: Set; // wallets currently holding tokens of the mint on-chain + singleFill: Set; // wallets whose ONLY token-acquiring action is one (so "held" unambiguously ⇒ that action ran) +}): { buysToSkip: Set; rotateIdxToSkip: Set } { + const { buyWalletIds, rotateTargets, done, heldIds, singleFill } = args; + // Skip an action iff the checkpoint recorded it done, OR the chain proves its effect + // already landed — but the chain ("holds tokens") is only an unambiguous proxy for a + // SINGLE-fill wallet. A multi-fill wallet (dev's launch buy + a follow buy, or a spine + // buyer that's also a rotation target) is skipped strictly per-action via `done`, so we + // never wrongly skip its second action just because it holds tokens from the first. + const buysToSkip = new Set(); + for (const id of buyWalletIds) { + if (done.has(`buy:${id}`) || (heldIds.has(id) && singleFill.has(id))) buysToSkip.add(id); + } + const rotateIdxToSkip = new Set(); + rotateTargets.forEach((t, i) => { + if (done.has(`rotate:${i}`) || (t !== undefined && heldIds.has(t) && singleFill.has(t))) rotateIdxToSkip.add(i); + }); + return { buysToSkip, rotateIdxToSkip }; +} + +/** Pure upper-bound spend estimate for the cap (incl. rotation re-buys funded by sweeps). + * Shared by the dry-run audit and exported for tests. */ +export function planSpendEstimate(devBuySol: unknown, buys: { walletId: string; sol?: unknown }[], rotates: { walletId: string; sellPct?: unknown }[]): number { + const n = (v: unknown) => Number(v) || 0; + const buyTotal = buys.reduce((t, a) => t + n(a.sol), 0); + const rotEst = rotates.reduce((t, r) => { const src = buys.find((b) => b.walletId === r.walletId); return t + n(src?.sol) * (n(r.sellPct || 100) / 100); }, 0); + return n(devBuySol) + buyTotal + rotEst; +} + +export async function executeLive( + plan: ExecutePlan, + onLog: (entry: unknown) => void, + opts: { resume?: { mint: string; done?: string[]; spentSol?: number }; onCheckpoint?: (p: { mint?: string; done?: string[]; spentSol?: number }) => void } = {}, +) { const pre = await audit(plan); if (!pre.ok) throw new Error(`Audit failed: ${pre.errors.join("; ")}`); + // Funding pre-flight now GATES execution (was advisory) — never spend on a launch + // the plan can't fully fund, or the dev buy is sunk while follow-buys fail. + if (!pre.fundingOk) { + const short = pre.fundingChecks.filter((c) => !c.ok).map((c) => `${c.label} needs ${c.needSol.toFixed(3)}◎ has ${c.haveSol.toFixed(3)}◎`); + throw new Error(`Underfunded — fund wallets before launching: ${short.join("; ")}`); + } + bhCache = null; // fresh blockhash for this run (no cross-run contamination) + const caps = execCaps(); + const runDeadline = Date.now() + MAX_RUN_MS; + // CUMULATIVE spend guard: on resume, seed from the checkpoint so the cap spans the + // original run + this resume (not just this invocation). Per-action `done` set carries + // forward so resume skips precisely what already completed (not "anything that holds"). + let spentSol = opts.resume?.spentSol ?? (Number(plan.launch.devBuySol) || 0); + const done = new Set(opts.resume?.done ?? []); + const saveCp = () => opts.onCheckpoint?.({ done: [...done], spentSol }); const wallets = await loadWallets(); const dev = resolveDevWallet(plan, wallets)!; - const rpc = await rpcUrl(); - const connection = new Connection(rpc, "confirmed"); + const urls = await rpcUrls(); + const picked = await pickHealthyConnection(urls); // failover: use the first healthy RPC + const rpc = picked.url; + const connection = picked.connection; const sdk = new OnlinePumpSdk(connection); const log: unknown[] = []; + if (urls.length > 1) { try { onLog({ kind: "rpc", endpoint: new URL(rpc).host, candidates: urls.length }); } catch { /* */ } } + + // P&L reconciliation: capture the TRUE opening SOL of every involved wallet before + // a single lamport moves, so the post-run report reflects the chain, not the log. + const involvedIds = new Set([dev.id]); + for (const s of plan.steps) for (const a of s.actions) { involvedIds.add(a.walletId); if (a.toWalletId) involvedIds.add(a.toWalletId); } + for (const s of plan.sells ?? []) involvedIds.add(s.walletId); + const involved = [...involvedIds].map((id) => wallets.get(id)).filter(Boolean) as StoredWallet[]; + const openingSol = await snapshotOpeningSol(connection, involved).catch(() => new Map()); const push = (e: unknown) => { log.push(e); onLog(e); }; // 1) Launch: create + dev buy, bundling the first step's buys when useJito. + const resuming = Boolean(opts.resume?.mint); const firstStep = plan.steps[0]; const firstBundleBuys = firstStep && firstStep.sendMode === "bundle" ? firstStep.actions.filter((a) => a.kind === "buy" && a.walletId !== dev.id) : []; const followBuyers = firstBundleBuys.map((a) => ({ wallet: toRecord(wallets.get(a.walletId)!), amount: Number(a.sol) || 0 })); + const launchedBuyers = new Set(firstBundleBuys.map((a) => a.walletId)); - push({ kind: "launch-start", name: plan.launch.name, symbol: plan.launch.symbol, devBuy: plan.launch.devBuySol, bundledBuys: followBuyers.length }); - // Launch is the first + largest spend — keep it inside the {ok,error} contract - // so a partial failure returns cleanly instead of throwing a bare 500. - let result: Awaited>; - try { - result = await launchToken({ - deployerWallet: toRecord(dev), devWallet: toRecord(dev), followBuyers, - rpcUrl: rpc, jitoUuid: plan.launch.useJito ? await readOptional("jito-uuid.txt") : "", - jitoTipSol: Number(await readOptional("jito-tip-sol.txt")) || 0.00001, pinataJwt: await readOptional("pinata-jwt.txt"), - donateApiKey: "", coinType: plan.launch.coinType ?? "pump-sol", - rewardMode: plan.launch.rewardMode ?? "creator", - rewardWallet: plan.launch.rewardWallet ?? "", rewardGithubUserId: plan.launch.rewardGithubUserId ?? "", - rewardCharityConfigId: plan.launch.rewardCharityConfigId ?? "", rewardCharities: [], - rewardShareholders: (plan.launch.rewardSplit ?? []).filter((s) => s.wallet && s.pct > 0).map((s) => ({ kind: "wallet" as const, value: s.wallet, shareBps: Math.round(s.pct * 100) })), - name: plan.launch.name, symbol: plan.launch.symbol, description: plan.launch.description ?? "", - imageFile: plan.launch.imageDataUrl ? dataUrlToFile(plan.launch.imageDataUrl) : null, - website: plan.launch.website ?? "", twitter: plan.launch.twitter ?? "", telegram: plan.launch.telegram ?? "", - devBuySol: Number(plan.launch.devBuySol) || 0, slippage: Number(plan.launch.slippage) || 15, - priorityFee: Number(plan.launch.priorityFee) || 0, fastMode: false, - jitoBundle: Boolean(plan.launch.useJito && followBuyers.length), agentBuybackBps: 0, - }); - } catch (error) { - const msg = error instanceof Error ? error.message : "launch failed"; - push({ kind: "error", action: "launch", message: msg }); - return { mode: "live" as const, ok: false, error: msg, mint: "", launchSignature: "", log }; + let mint: PublicKey; + let launchSignature = ""; + if (resuming) { + // RESUME: the coin already exists — never re-create it. Verify it's really on-chain + // before continuing, so we don't run buys against a launch that never landed. + mint = new PublicKey(opts.resume!.mint); + push({ kind: "resume-start", mint: opts.resume!.mint }); + const exists = await withTimeout(connection.getAccountInfo(mint, "confirmed"), RPC_TIMEOUT_MS, "getAccountInfo(resume)").catch(() => null); + if (!exists) return { mode: "live" as const, ok: false, error: "resume aborted: mint not found on-chain (launch never landed) — start a fresh run", mint: opts.resume!.mint, launchSignature: "", log }; + } else { + push({ kind: "launch-start", name: plan.launch.name, symbol: plan.launch.symbol, devBuy: plan.launch.devBuySol, bundledBuys: followBuyers.length }); + // Launch is the first + largest spend — keep it inside the {ok,error} contract + // so a partial failure returns cleanly instead of throwing a bare 500. + let result: Awaited>; + try { + result = await launchToken({ + deployerWallet: toRecord(dev), devWallet: toRecord(dev), followBuyers, + rpcUrl: rpc, jitoUuid: plan.launch.useJito ? await readOptional("jito-uuid.txt") : "", + jitoTipSol: Number(await readOptional("jito-tip-sol.txt")) || 0.1, pinataJwt: await readOptional("pinata-jwt.txt"), // 0.1◎ default = lands first try (launch.ts floors at 0.1 regardless) + donateApiKey: "", coinType: plan.launch.coinType ?? "pump-sol", + rewardMode: plan.launch.rewardMode ?? "creator", + rewardWallet: plan.launch.rewardWallet ?? "", rewardGithubUserId: plan.launch.rewardGithubUserId ?? "", + rewardCharityConfigId: plan.launch.rewardCharityConfigId ?? "", rewardCharities: [], + rewardShareholders: (plan.launch.rewardSplit ?? []).filter((s) => s.wallet && s.pct > 0).map((s) => ({ kind: "wallet" as const, value: s.wallet, shareBps: Math.round(s.pct * 100) })), + name: plan.launch.name, symbol: plan.launch.symbol, description: plan.launch.description ?? "", + imageFile: plan.launch.imageDataUrl ? dataUrlToFile(plan.launch.imageDataUrl) : null, + website: plan.launch.website ?? "", twitter: plan.launch.twitter ?? "", telegram: plan.launch.telegram ?? "", + devBuySol: Number(plan.launch.devBuySol) || 0, slippage: Number(plan.launch.slippage) || 15, + priorityFee: Number(plan.launch.priorityFee) || 0, fastMode: true, // skip pre-send sims (verifyBundleOnChain still confirms) + jitoBundle: Boolean(plan.launch.useJito && followBuyers.length), agentBuybackBps: 0, + }); + } catch (error) { + const msg = error instanceof Error ? error.message : "launch failed"; + push({ kind: "error", action: "launch", message: msg }); + return { mode: "live" as const, ok: false, error: msg, mint: "", launchSignature: "", log }; + } + mint = new PublicKey(result.mint); + launchSignature = result.signature; + opts.onCheckpoint?.({ mint: result.mint }); // persist the mint ASAP → resume anchor (never re-create) + push({ kind: "launch-done", mint: result.mint, signature: result.signature, solscan: `https://solscan.io/tx/${result.signature}`, rewardSignature: result.rewardSignature, agentSignature: result.agentSignature, bundleId: result.bundleId, devBuySignatures: result.buySignatures }); } - const mint = new PublicKey(result.mint); - push({ kind: "launch-done", mint: result.mint, signature: result.signature, solscan: `https://solscan.io/tx/${result.signature}`, rewardSignature: result.rewardSignature, agentSignature: result.agentSignature, bundleId: result.bundleId, devBuySignatures: result.buySignatures }); const global = await sdk.fetchGlobal(); const feeConfig = await sdk.fetchFeeConfig(); const slippage = Number(plan.launch.slippage) || 15; const priorityFee = Number(plan.launch.priorityFee) || 0.00001; - const launchedBuyers = new Set(firstBundleBuys.map((a) => a.walletId)); + + // On resume, read which wallets ALREADY hold tokens on-chain → skip those actions. + let heldIds = new Set(); + if (resuming) { + const holdings = await snapshotClosing(connection, mint, involved.map((w) => ({ id: w.id, label: w.label, publicKey: w.publicKey }))); + heldIds = new Set(holdings.filter((h) => h.tokens > 0).map((h) => h.id)); + push({ kind: "resume-holdings", walletsAlreadyFilled: heldIds.size }); + } // 2) Remaining actions in order. ABORT on the first failure — never keep // executing once an invariant breaks (a failed sell must not let a later @@ -345,25 +570,56 @@ export async function executeLive(plan: ExecutePlan, onLog: (entry: unknown) => } } - // FAST BUY PHASE — every buy spends its own funded SOL, so they're independent: - // fire them ALL concurrently on one shared connection (cached global/feeConfig, - // skipPreflight). N sequential confirms → one parallel batch. - if (buyJobs.length) { - const bh = await sharedBlockhash(connection); - const results = await Promise.allSettled(buyJobs.map((b) => - buyPumpSolToken({ rpcUrl: rpc, mint: result.mint, buyer: toRecord(wallets.get(b.walletId)!), solAmount: b.sol, slippage, priorityFee, connection, global, feeConfig, blockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight, fast: true }))); - results.forEach((res, idx) => { - const b = buyJobs[idx]; - if (res.status === "fulfilled") push({ kind: "buy", wallet: wallets.get(b.walletId)?.label, sol: b.sol, signature: res.value.signature }); - else { buyFailures += 1; push({ kind: "error", action: "buy", wallet: wallets.get(b.walletId)?.label, message: res.reason instanceof Error ? res.reason.message : "buy failed" }); } - }); + // RESUME: skip an action iff the checkpoint recorded it done, or the chain proves its + // effect landed AND the wallet is single-fill (so "held" can't be from another action). + // singleFill = wallets whose only token-acquiring action is one (dev's launch buy counts). + const fillCount = new Map(); + const bump = (id?: string) => { if (id) fillCount.set(id, (fillCount.get(id) ?? 0) + 1); }; + if ((Number(plan.launch.devBuySol) || 0) > 0) bump(dev.id); + for (const b of buyJobs) bump(b.walletId); + for (const a of rotateQueue) bump(a.toWalletId); + const singleFill = new Set([...fillCount.entries()].filter(([, c]) => c === 1).map(([id]) => id)); + const skips = resumeSkipSets({ buyWalletIds: buyJobs.map((b) => b.walletId), rotateTargets: rotateQueue.map((a) => a.toWalletId), done, heldIds, singleFill }); + const buysToRun = buyJobs.filter((b) => { if (skips.buysToSkip.has(b.walletId)) { push({ kind: "buy-skipped", wallet: wallets.get(b.walletId)?.label, reason: "resume-already-done" }); return false; } return true; }); + + // FAST BUY PHASE — every buy spends its own funded SOL, so they're independent, + // but they all hit the SAME curve, so firing all N at once stampedes one slot. + // Cap in-flight buys (LILY_BUY_CONCURRENCY, default 8) + jitter starts so a 60-wallet + // fan-out lands smoothly instead of cascading slippage / 429-ing the RPC. + if (buysToRun.length && Date.now() > runDeadline) { + buyFailures += buysToRun.length; + push({ kind: "error", action: "buy", message: "run deadline exceeded — follow buys skipped" }); + } else if (buysToRun.length) { + const batchSol = buysToRun.reduce((t, b) => t + b.sol, 0); + if (spentSol + batchSol > caps.totalSpend + 1e-9) { + buyFailures += buysToRun.length; + push({ kind: "error", action: "buy", message: `spend cap ${caps.totalSpend}◎ would be exceeded (${(spentSol + batchSol).toFixed(3)}◎) — follow buys skipped` }); + } else { + const conc = Math.max(1, Number(process.env.LILY_BUY_CONCURRENCY) || 8); + // Each buy fetches its OWN fresh blockhash (no shared/pinned one — under bounded + // concurrency a single blockhash expires for the late waves → mass failures) and is + // bounded by a per-buy timeout. onSent persists each sig BEFORE confirm (crash-safe). + const results = await mapLimit(buysToRun, conc, 80, (b) => + withTimeout(buyPumpSolToken({ rpcUrl: rpc, mint: mint.toBase58(), buyer: toRecord(wallets.get(b.walletId)!), solAmount: b.sol, slippage, priorityFee, connection, global, feeConfig, fast: true, + // mark done on SEND (favor under-fill over double-spend on a crash mid-confirm) + onSent: (sig) => { done.add(`buy:${b.walletId}`); saveCp(); push({ kind: "buy-sent", wallet: wallets.get(b.walletId)?.label, sol: b.sol, signature: sig }); } }), BUY_TIMEOUT_MS, `buy ${wallets.get(b.walletId)?.label ?? b.walletId}`)); + results.forEach((res, idx) => { + const b = buysToRun[idx]; + if (res.status === "fulfilled") { spentSol += b.sol; push({ kind: "buy", wallet: wallets.get(b.walletId)?.label, sol: b.sol, signature: res.value.signature, verified: res.value.verified }); } + else { buyFailures += 1; push({ kind: "error", action: "buy", wallet: wallets.get(b.walletId)?.label, message: res.reason instanceof Error ? res.reason.message : "buy failed" }); } + }); + saveCp(); // persist cumulative spend after the batch + } } // ROTATION RELAY — each rotation sells, sweeps proceeds, and the target buys, so // the next can only run after the prior funds it. Sequential by necessity; abort // on failure so a later rotate never runs on phantom state. - for (const a of rotateQueue) { + for (let ri = 0; ri < rotateQueue.length; ri += 1) { + const a = rotateQueue[ri]; if (aborted) break; + if (Date.now() > runDeadline) { push({ kind: "error", action: "rotate", message: "run deadline exceeded — halting before further spend" }); errorMsg = "run deadline exceeded"; aborted = true; break; } + if (skips.rotateIdxToSkip.has(ri)) { push({ kind: "rotate-skipped", to: wallets.get(a.toWalletId!)?.label, reason: "resume-already-filled" }); continue; } try { const from = keypair(wallets.get(a.walletId)!); const to = wallets.get(a.toWalletId!)!; @@ -374,16 +630,27 @@ export async function executeLive(plan: ExecutePlan, onLog: (entry: unknown) => // sweep the wallet's full available SOL (funded headroom + sell proceeds), // leaving a rent/fee buffer — the balance read is load-bearing: it keeps the // relay funded each hop (sell.solOut alone decays to ~0 by hop 3). - const balLamports = await connection.getBalance(from.publicKey, "confirmed"); + const balLamports = await withTimeout(connection.getBalance(from.publicKey, "confirmed"), RPC_TIMEOUT_MS, "getBalance(rotate)"); const avail = balLamports / LAMPORTS_PER_SOL - 0.003; const sweep = Math.max(0, Math.min(sell.solOut, avail)); let buySig: string | undefined; - if (sweep > 0.001) { + if (spentSol + sweep > caps.totalSpend + 1e-9) { + push({ kind: "rotate-skipped", from: wallets.get(a.walletId)?.label, to: to.label, reason: "spend-cap" }); + } else if (sweep > 0.001) { + done.add(`rotate:${ri}`); saveCp(); // mark before the sweep+rebuy (favor under-fill over double-spend) await transferSol(connection, from, new PublicKey(to.publicKey), sweep); - const r = await buyPumpSolToken({ rpcUrl: rpc, mint: result.mint, buyer: toRecord(to), solAmount: sweep, slippage, priorityFee, connection, global, feeConfig, fast: true }); + // Buy with sweep/(1+slippage), NOT the full sweep: a pump buy can spend up to + // quote*(1+slippage), so buying the whole sweep can need more SOL than was swept + // and fails "insufficient lamports" (this halted a live relay). Sizing down keeps + // the max cost ≤ sweep; the small remainder stays in the wallet (recoverable). + const rebuySol = sweep / (1 + slippage / 100); + const r = await withTimeout(buyPumpSolToken({ rpcUrl: rpc, mint: mint.toBase58(), buyer: toRecord(to), solAmount: rebuySol, slippage, priorityFee, connection, global, feeConfig, fast: true }), BUY_TIMEOUT_MS, `rotate-buy ${to.label}`); buySig = r.signature; + spentSol += sweep; saveCp(); + push({ kind: "rotate", from: wallets.get(a.walletId)?.label, to: to.label, sellPct: a.sellPct, sweepSol: Number(sweep.toFixed(4)), sellSig: sell.signature, buySig }); + } else { + push({ kind: "rotate", from: wallets.get(a.walletId)?.label, to: to.label, sellPct: a.sellPct, sweepSol: Number(sweep.toFixed(4)), sellSig: sell.signature, buySig }); } - push({ kind: "rotate", from: wallets.get(a.walletId)?.label, to: to.label, sellPct: a.sellPct, sweepSol: Number(sweep.toFixed(4)), sellSig: sell.signature, buySig }); } } catch (error) { const m = error instanceof Error ? error.message : "failed"; @@ -422,12 +689,27 @@ export async function executeLive(plan: ExecutePlan, onLog: (entry: unknown) => }); } if (pending.every((s) => s.fired)) break; - if (Date.now() - t0 > MAX_MON_MS) { push({ kind: "monitor-timeout" }); break; } + if (Date.now() - t0 > MAX_MON_MS || Date.now() > runDeadline) { push({ kind: "monitor-timeout" }); break; } await new Promise((r) => setTimeout(r, 400)); } } + // ON-CHAIN RECONCILIATION — read the real closing balances and report actual P&L + // per wallet vs the opening snapshot. Best-effort: never fails the run. + let reconciliation: ReturnType & { source: string; trackedSpendSol: number } | null = null; + try { + const closing = await snapshotClosing(connection, mint, involved.map((w) => ({ id: w.id, label: w.label, publicKey: w.publicKey }))); + reconciliation = { source: "on-chain", trackedSpendSol: Number(spentSol.toFixed(6)), ...summarizeReconciliation(openingSol, closing) }; + push({ kind: "reconciliation", ...reconciliation }); + } catch (e) { + push({ kind: "reconcile-failed", message: e instanceof Error ? e.message : "reconcile failed" }); + } + + // Honest status: the launch can succeed yet have failed follow-buys/sells. Surface + // that as "partial" instead of a flat ok:true that hides the failures (audit HIGH). + const partial = !aborted && (buyFailures > 0 || sellFailures > 0); const ok = !aborted; - push({ kind: ok ? "done" : "halted", mint: result.mint, ok, buyFailures, sellFailures }); - return { mode: "live" as const, ok, error: ok ? undefined : errorMsg, buyFailures, sellFailures, mint: result.mint, launchSignature: result.signature, log }; + const status = aborted ? "halted" : partial ? "partial" : "ok"; + push({ kind: aborted ? "halted" : partial ? "partial" : "done", mint: mint.toBase58(), ok, partial, status, buyFailures, sellFailures }); + return { mode: "live" as const, ok, partial, status, error: ok ? undefined : errorMsg, buyFailures, sellFailures, reconciliation, mint: mint.toBase58(), launchSignature, log }; } diff --git a/server/jobs.ts b/server/jobs.ts index 40a0e04..e06c3bd 100644 --- a/server/jobs.ts +++ b/server/jobs.ts @@ -3,11 +3,11 @@ // we run it in the background, persist progress to disk, and let the client poll. // Also the idempotency layer: the same key never starts a second run. -import { mkdir, writeFile, readFile, readdir } from "node:fs/promises"; +import { mkdir, writeFile, readFile, readdir, rename } from "node:fs/promises"; import { existsSync } from "node:fs"; import path from "node:path"; -export type JobStatus = "running" | "done" | "failed"; +export type JobStatus = "running" | "done" | "failed" | "interrupted"; export type Job = { id: string; key?: string; @@ -17,16 +17,55 @@ export type Job = { result?: unknown; error?: string; log: unknown[]; + checkpoint?: { mint?: string; done?: string[]; spentSol?: number }; // resume anchor: mint + completed action keys + cumulative spend }; export class JobStore { private mem = new Map(); + private hydrated = false; constructor(private dir: string) {} private file(id: string) { return path.join(this.dir, `${id}.json`); } + + /** Load every persisted job into memory ONCE at boot so the idempotency layer + * (findByKey) survives a restart/crash — the exact case it exists to protect + * against. Any job still marked "running" was orphaned when its process died: + * mark it "interrupted" so a same-key retry dedupes to it (NOT a fresh launch) + * and the operator can reconcile on-chain before re-spending. */ + async hydrate(): Promise<{ loaded: number; interrupted: number }> { + if (this.hydrated) return { loaded: this.mem.size, interrupted: 0 }; + let loaded = 0, interrupted = 0; + if (existsSync(this.dir)) { + for (const f of await readdir(this.dir).catch(() => [] as string[])) { + if (!f.endsWith(".json")) continue; + try { + const j = JSON.parse(await readFile(path.join(this.dir, f), "utf8")) as Job; + if (j.status === "running") { + j.status = "interrupted"; + j.error = j.error ?? "Process restarted while this run was in flight — reconcile on-chain before retrying."; + j.finishedAt = j.finishedAt ?? new Date().toISOString(); + interrupted += 1; + await this.persist(j); + } + this.mem.set(j.id, j); + loaded += 1; + } catch { /* skip corrupt */ } + } + } + this.hydrated = true; + return { loaded, interrupted }; + } + private writeSeq = 0; private async persist(job: Job) { try { if (!existsSync(this.dir)) await mkdir(this.dir, { recursive: true }); - await writeFile(this.file(job.id), JSON.stringify(job, null, 2)); + // ATOMIC write: a fast run fires many un-awaited persist() calls; writing the file + // in place let two concurrent writes interleave and corrupt the JSON (observed: a + // job file with two spliced objects). Write a unique temp, then rename (atomic on + // the same fs) so a reader only ever sees one complete object. + this.writeSeq += 1; + const tmp = `${this.file(job.id)}.${process.pid}.${this.writeSeq}.tmp`; + await writeFile(tmp, JSON.stringify(job, null, 2)); + await rename(tmp, this.file(job.id)); } catch { /* disk best-effort; mem is source of truth in-process */ } } @@ -47,6 +86,21 @@ export class JobStore { j.log.push(entry); void this.persist(j); } + checkpoint(id: string, patch: { mint?: string }) { + const j = this.mem.get(id); + if (!j) return; + j.checkpoint = { ...j.checkpoint, ...patch }; + void this.persist(j); + } + /** Flip an interrupted job back to running so a resume can continue its log/id. */ + resume(id: string) { + const j = this.mem.get(id); + if (!j) return; + j.status = "running"; + j.error = undefined; + j.finishedAt = undefined; + void this.persist(j); + } finish(id: string, status: JobStatus, result: unknown, error?: string) { const j = this.mem.get(id); if (!j) return; diff --git a/server/keystore.ts b/server/keystore.ts new file mode 100644 index 0000000..fab054b --- /dev/null +++ b/server/keystore.ts @@ -0,0 +1,98 @@ +// Encrypted-at-rest keystore for wallet secret keys. +// +// Default (no passphrase): reads/writes PLAINTEXT wallets.json exactly as before — +// fully backward compatible, with a one-time warning so the risk is visible. +// +// With LILY_KEYSTORE_PASSPHRASE set: secret keys are encrypted at rest with +// AES-256-GCM under a scrypt-derived key. The file becomes an opaque envelope; a +// stolen wallets.json is useless without the passphrase. Reading still accepts a +// legacy plaintext file (so an existing install isn't locked out) — the next WRITE +// (or `npx tsx scripts/encrypt-keystore.ts`) migrates it to the encrypted form. +// +// Server-only (uses node:crypto). Never imported by the client bundle. + +import { mkdir, readFile, writeFile, rename } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { randomBytes, randomUUID, scryptSync, createCipheriv, createDecipheriv } from "node:crypto"; + +export type EncryptedEnvelope = { + encrypted: true; + kdf: "scrypt"; + n: number; r: number; p: number; + salt: string; iv: string; authTag: string; ciphertext: string; + updatedAt?: string; +}; +type PlainFile = { wallets?: T[]; updatedAt?: string }; + +const SCRYPT = { N: 1 << 14, r: 8, p: 1 }; // ~16MB — under node's 32MB default maxmem +let warnedPlaintext = false; + +function passphrase(): string { + return process.env.LILY_KEYSTORE_PASSPHRASE?.trim() ?? ""; +} +function deriveKey(pass: string, salt: Buffer, n = SCRYPT.N, r = SCRYPT.r, p = SCRYPT.p): Buffer { + return scryptSync(pass, salt, 32, { N: n, r, p }); +} +function isEncrypted(v: unknown): v is EncryptedEnvelope { + return !!v && typeof v === "object" && (v as EncryptedEnvelope).encrypted === true; +} + +export function encryptWallets(wallets: T[], pass: string): EncryptedEnvelope { + if (!pass) throw new Error("Cannot encrypt without a passphrase"); + const salt = randomBytes(16); + const iv = randomBytes(12); + const key = deriveKey(pass, salt); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ct = Buffer.concat([cipher.update(Buffer.from(JSON.stringify(wallets), "utf8")), cipher.final()]); + return { + encrypted: true, kdf: "scrypt", n: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p, + salt: salt.toString("base64"), iv: iv.toString("base64"), + authTag: cipher.getAuthTag().toString("base64"), ciphertext: ct.toString("base64"), + updatedAt: new Date().toISOString(), + }; +} + +export function decryptWallets(env: EncryptedEnvelope, pass: string): T[] { + if (!pass) throw new Error("Keystore is encrypted but LILY_KEYSTORE_PASSPHRASE is not set"); + const key = deriveKey(pass, Buffer.from(env.salt, "base64"), env.n, env.r, env.p); + const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(env.iv, "base64")); + decipher.setAuthTag(Buffer.from(env.authTag, "base64")); + let out: Buffer; + try { + out = Buffer.concat([decipher.update(Buffer.from(env.ciphertext, "base64")), decipher.final()]); + } catch { + throw new Error("Keystore decryption failed — wrong LILY_KEYSTORE_PASSPHRASE?"); + } + return JSON.parse(out.toString("utf8")) as T[]; +} + +/** Read wallet records from a file, transparently handling plaintext or encrypted. */ +export async function readWalletsFile(filePath: string): Promise { + if (!existsSync(filePath)) return []; + let parsed: unknown; + try { parsed = JSON.parse(await readFile(filePath, "utf8")); } catch { return []; } + if (isEncrypted(parsed)) return decryptWallets(parsed, passphrase()); + const plain = parsed as PlainFile; + if (!passphrase() && (plain.wallets?.length ?? 0) > 0 && !warnedPlaintext) { + warnedPlaintext = true; + console.warn("[keystore] wallets.json is PLAINTEXT. Set LILY_KEYSTORE_PASSPHRASE and run `npx tsx scripts/encrypt-keystore.ts` to encrypt keys at rest."); + } + return plain.wallets ?? []; +} + +/** Write wallet records — encrypted when a passphrase is set, else plaintext (compat). */ +export async function writeWalletsFile(filePath: string, wallets: T[]): Promise { + await mkdir(path.dirname(filePath), { recursive: true }); + const pass = passphrase(); + const body = pass + ? JSON.stringify(encryptWallets(wallets, pass), null, 2) + : JSON.stringify({ updatedAt: new Date().toISOString(), wallets }, null, 2); + // Atomic write: a crash mid-write must NOT corrupt the only copy of the keys. Write a + // sibling temp file, then rename (atomic on the same filesystem). + const tmp = `${filePath}.${randomUUID()}.tmp`; + await writeFile(tmp, body, { mode: 0o600 }); + await rename(tmp, filePath); +} + +export function keystoreEncrypted(): boolean { return passphrase() !== ""; } diff --git a/server/local-server.ts b/server/local-server.ts index 0b02d86..e48d0b0 100644 --- a/server/local-server.ts +++ b/server/local-server.ts @@ -6,15 +6,18 @@ // Run: npm run server (tsx server/local-server.ts, port 8899) import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { randomUUID } from "node:crypto"; import { Connection, Keypair, PublicKey, LAMPORTS_PER_SOL, SystemProgram, TransactionMessage, VersionedTransaction } from "@solana/web3.js"; +import { OnlinePumpSdk } from "@pump-fun/pump-sdk"; import bs58 from "bs58"; import { audit, executeLive, type ExecutePlan } from "./execute"; import { JobStore } from "./jobs"; +import { readWalletsFile, writeWalletsFile } from "./keystore"; +import { recoverToMain } from "./recover"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const secretsDir = path.join(root, ".secrets"); @@ -31,18 +34,11 @@ type WalletRec = { }; async function readWallets(): Promise { - if (!existsSync(walletsPath)) return []; - try { - const parsed = JSON.parse(await readFile(walletsPath, "utf8")) as { wallets?: WalletRec[] }; - return parsed.wallets ?? []; - } catch { - return []; - } + return readWalletsFile(walletsPath); } async function writeWallets(wallets: WalletRec[]) { - await mkdir(secretsDir, { recursive: true }); - await writeFile(walletsPath, JSON.stringify({ updatedAt: new Date().toISOString(), wallets }, null, 2)); + await writeWalletsFile(walletsPath, wallets); } async function rpcUrl(): Promise { @@ -59,6 +55,23 @@ function publicWallet(w: WalletRec) { return { id: w.id, label: w.label, publicKey: w.publicKey, createdAt: w.createdAt }; } +// Live, on-chain pump.fun fee tiers for the simulator (read-only, no keys). Cached +// 5 min — the schedule changes rarely. The sim looks up bps per trade by market cap; +// without this (demo build) it falls back to the verified genesis tier. +let feeScheduleCache: { v: unknown; at: number } | null = null; +async function feeSchedule() { + if (feeScheduleCache && Date.now() - feeScheduleCache.at < 5 * 60_000) return feeScheduleCache.v; + const sdk = new OnlinePumpSdk(new Connection(await rpcUrl(), "confirmed")); + const [global, feeConfig] = await Promise.all([sdk.fetchGlobal(), sdk.fetchFeeConfig().catch(() => null)]); + const bn = (x: unknown) => Number((x as { toString(): string }).toString()); + const tiers = ((feeConfig?.feeTiers ?? []) as Array<{ marketCapLamportsThreshold: unknown; fees: { protocolFeeBps: unknown; creatorFeeBps: unknown } }>) + .map((t) => ({ thresholdSol: bn(t.marketCapLamportsThreshold) / LAMPORTS_PER_SOL, protocolBps: bn(t.fees.protocolFeeBps), creatorBps: bn(t.fees.creatorFeeBps) })) + .sort((a, b) => a.thresholdSol - b.thresholdSol); + const v = { tiers, fallbackProtocolBps: bn(global.feeBasisPoints), fallbackCreatorBps: bn(global.creatorFeeBasisPoints) }; + feeScheduleCache = { v, at: Date.now() }; + return v; +} + async function status() { const url = await rpcUrl(); const wallets = await readWallets(); @@ -239,6 +252,7 @@ createServer(async (req, res) => { const url = new URL(req.url || "/", `http://127.0.0.1:${port}`); try { if (url.pathname === "/api/status" && req.method === "GET") return sendJson(res, 200, await status(), origin); + if (url.pathname === "/api/fee-schedule" && req.method === "GET") return sendJson(res, 200, await feeSchedule(), origin); if (url.pathname === "/api/wallets" && req.method === "GET") return sendJson(res, 200, await listWallets(), origin); if (url.pathname === "/api/wallets/create" && req.method === "POST") return sendJson(res, 200, await createWallets(await readBody(req)), origin); if (url.pathname === "/api/wallets/import" && req.method === "POST") { @@ -252,6 +266,10 @@ createServer(async (req, res) => { catch (e) { return sendJson(res, 400, { error: e instanceof Error ? e.message : "fund failed" }, origin); } } if (url.pathname === "/api/wallets/export" && req.method === "POST") return sendJson(res, 200, await exportWallets(), origin); + if (url.pathname === "/api/wallets/recover" && req.method === "POST") { + try { const b = await readBody<{ mainId?: string }>(req); return sendJson(res, 200, await recoverToMain(new Connection(await rpcUrl(), "confirmed"), await readWallets(), b.mainId), origin); } + catch (e) { return sendJson(res, 400, { error: e instanceof Error ? e.message : "recover failed" }, origin); } + } // poll a live job: GET /api/execute/ if (url.pathname.startsWith("/api/execute/") && req.method === "GET") { const id = url.pathname.slice("/api/execute/".length); @@ -278,22 +296,38 @@ createServer(async (req, res) => { return sendJson(res, 200, { launches }, origin); } if (url.pathname === "/api/execute" && req.method === "POST") { - const body = await readBody<{ plan?: ExecutePlan; mode?: "audit" | "live"; idempotencyKey?: string }>(req); + const body = await readBody<{ plan?: ExecutePlan; mode?: "audit" | "live"; idempotencyKey?: string; resume?: boolean }>(req); if (!body.plan) return sendJson(res, 400, { error: "missing plan" }, origin); // audit (dry-run) is fast + spends nothing → stay synchronous if (body.mode !== "live") return sendJson(res, 200, await audit(body.plan), origin); - // live → idempotent async job (survives a dropped socket; same key never double-launches) + // live → idempotent async job (survives a dropped socket AND a restart; same key never double-launches) + await jobs.hydrate(); // ensure disk-persisted keys are loaded before we dedupe (no-op after first call) + const plan = body.plan; if (body.idempotencyKey) { const existing = jobs.findByKey(body.idempotencyKey); + // RESUME: an interrupted run can be continued (same key + resume:true). It skips + // the launch (coin already created) and re-does only what's not yet on-chain. + if (existing && existing.status === "interrupted" && body.resume) { + if (!existing.checkpoint?.mint) return sendJson(res, 409, { error: "cannot resume: no mint checkpoint (launch may not have landed) — verify on-chain, then start a fresh run" }, origin); + if (liveBusy) return sendJson(res, 409, { error: "a live execution is already running" }, origin); + liveBusy = true; + const rid = existing.id; + const resumeMint = existing.checkpoint.mint; + jobs.resume(rid); + void executeLive(plan, (e) => jobs.append(rid, e), { resume: { mint: resumeMint, done: existing.checkpoint.done ?? [], spentSol: existing.checkpoint.spentSol }, onCheckpoint: (p) => jobs.checkpoint(rid, p) }) + .then((r) => jobs.finish(rid, "done", r)) + .catch((e) => jobs.finish(rid, "failed", undefined, e instanceof Error ? e.message : "failed")) + .finally(() => { liveBusy = false; }); + return sendJson(res, 200, { jobId: rid, status: "running", resumed: true }, origin); + } if (existing) return sendJson(res, 200, { jobId: existing.id, status: existing.status, deduped: true }, origin); } if (liveBusy) return sendJson(res, 409, { error: "a live execution is already running" }, origin); liveBusy = true; const id = randomUUID(); - const plan = body.plan; jobs.create(id, body.idempotencyKey); // fire-and-forget: progress + result persist to disk; client polls GET /api/execute/:id - void executeLive(plan, (e) => jobs.append(id, e)) + void executeLive(plan, (e) => jobs.append(id, e), { onCheckpoint: (p) => jobs.checkpoint(id, p) }) .then((r) => jobs.finish(id, "done", r)) .catch((e) => jobs.finish(id, "failed", undefined, e instanceof Error ? e.message : "failed")) .finally(() => { liveBusy = false; }); @@ -307,4 +341,9 @@ createServer(async (req, res) => { } }).listen(port, "127.0.0.1", () => { console.log(`Lily Algo Launcher backend: http://127.0.0.1:${port} (local-only, CSRF-guarded)`); + // Rehydrate the durable job store so idempotency + dedupe survive this restart, + // and flag any run orphaned by the previous process as "interrupted". + void jobs.hydrate().then(({ loaded, interrupted }) => { + if (loaded) console.log(` job store: ${loaded} jobs loaded${interrupted ? `, ${interrupted} interrupted (reconcile on-chain before retry)` : ""}`); + }); }); diff --git a/server/recover.ts b/server/recover.ts new file mode 100644 index 0000000..7d7f0d8 --- /dev/null +++ b/server/recover.ts @@ -0,0 +1,69 @@ +// Recover-to-Main: for every non-main wallet, sell any pump token bag back to its +// curve, CLOSE the emptied token account (reclaims ~0.00204◎ rent), then sweep all SOL +// to Main. Mirrors scripts/recover.ts so the UI button and the CLI behave identically. +import { Connection, Keypair, LAMPORTS_PER_SOL, PublicKey, SystemProgram, TransactionMessage, VersionedTransaction, ComputeBudgetProgram } from "@solana/web3.js"; +import { createCloseAccountInstruction, getAssociatedTokenAddressSync, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { OnlinePumpSdk, PUMP_SDK, getSellSolAmountFromTokenAmount } from "@pump-fun/pump-sdk"; +import BN from "bn.js"; +import bs58 from "bs58"; + +type W = { id: string; label: string; publicKey: string; secretKeyBase58: string }; +export type RecoverRow = { wallet: string; soldBags: number; sweptSol: number; error?: string }; + +export async function recoverToMain(connection: Connection, wallets: W[], mainId?: string) { + const sdk = new OnlinePumpSdk(connection); + const main = wallets.find((w) => w.id === mainId) ?? wallets[0]; + if (!main) throw new Error("No main wallet"); + const mainPk = new PublicKey(main.publicKey); + const kp = (w: W) => Keypair.fromSecretKey(bs58.decode(w.secretKeyBase58)); + const global = await sdk.fetchGlobal(); + const feeConfig = await sdk.fetchFeeConfig().catch(() => null); + const mainBefore = (await connection.getBalance(mainPk, "confirmed")) / LAMPORTS_PER_SOL; + const rows: RecoverRow[] = []; + let totalSwept = 0; + + for (const w of wallets.filter((x) => x.id !== main.id)) { + const row: RecoverRow = { wallet: w.label, soldBags: 0, sweptSol: 0 }; + const signer = kp(w); + try { + // sell + close every Token-2022 bag this wallet holds + const accts = await connection.getParsedTokenAccountsByOwner(signer.publicKey, { programId: TOKEN_2022_PROGRAM_ID }, "confirmed").catch(() => ({ value: [] as never[] })); + for (const a of accts.value) { + const info = (a as { account: { data: { parsed: { info: { mint: string; tokenAmount: { amount: string } } } } } }).account.data.parsed.info; + const amount = new BN(info.tokenAmount.amount || "0"); + if (amount.isZero()) continue; + try { + const mint = new PublicKey(info.mint); + const ata = getAssociatedTokenAddressSync(mint, signer.publicKey, true, TOKEN_2022_PROGRAM_ID); + const state = await sdk.fetchSellState(mint, signer.publicKey, TOKEN_2022_PROGRAM_ID); + const quoteAmount = getSellSolAmountFromTokenAmount({ global, feeConfig, mintSupply: state.bondingCurve.tokenTotalSupply, bondingCurve: state.bondingCurve, amount }); + const ixs = await PUMP_SDK.sellV2Instructions({ global, ...state, mint, user: signer.publicKey, amount, quoteAmount, slippage: 50, tokenProgram: TOKEN_2022_PROGRAM_ID, quoteTokenProgram: TOKEN_PROGRAM_ID }); + const closeIx = createCloseAccountInstruction(ata, signer.publicKey, signer.publicKey, [], TOKEN_2022_PROGRAM_ID); + const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash("confirmed"); + const tx = new VersionedTransaction(new TransactionMessage({ payerKey: signer.publicKey, recentBlockhash: blockhash, instructions: [ComputeBudgetProgram.setComputeUnitLimit({ units: 230_000 }), ...ixs, closeIx] }).compileToV0Message()); + tx.sign([signer]); + const sig = bs58.encode(tx.signatures[0]); + await connection.sendRawTransaction(tx.serialize(), { skipPreflight: true, maxRetries: 3 }); + await connection.confirmTransaction({ signature: sig, blockhash, lastValidBlockHeight }, "confirmed"); + row.soldBags += 1; + } catch { /* skip a bag that won't sell (e.g. graduated) */ } + } + // sweep remaining SOL to Main (keep one tx fee) + const bal = await connection.getBalance(signer.publicKey, "confirmed"); + const send = bal - 5000; + if (send > 5000) { + const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash("confirmed"); + const tx = new VersionedTransaction(new TransactionMessage({ payerKey: signer.publicKey, recentBlockhash: blockhash, instructions: [SystemProgram.transfer({ fromPubkey: signer.publicKey, toPubkey: mainPk, lamports: send })] }).compileToV0Message()); + tx.sign([signer]); + const sig = bs58.encode(tx.signatures[0]); + await connection.sendRawTransaction(tx.serialize(), { skipPreflight: true, maxRetries: 3 }); + await connection.confirmTransaction({ signature: sig, blockhash, lastValidBlockHeight }, "confirmed"); + row.sweptSol = send / LAMPORTS_PER_SOL; + totalSwept += row.sweptSol; + } + } catch (e) { row.error = e instanceof Error ? e.message : "failed"; } + rows.push(row); + } + const mainAfter = (await connection.getBalance(mainPk, "confirmed")) / LAMPORTS_PER_SOL; + return { rows, totalSwept: Number(totalSwept.toFixed(6)), mainBefore: Number(mainBefore.toFixed(6)), mainAfter: Number(mainAfter.toFixed(6)), bagsSold: rows.reduce((n, r) => n + r.soldBags, 0) }; +} diff --git a/src/App.tsx b/src/App.tsx index 897bab7..62998b1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { IntroModal } from "@/components/IntroModal"; import { LiveTape } from "@/components/LiveTape"; import { uid, type WalletLedger } from "@/lib/choreography"; import { makeSampleGraph, normalizeLoadedGraph, runGraph, type FlowGraph } from "@/lib/graph"; +import { fetchFeeSchedule, type FeeSchedule } from "@/lib/pumpFees"; import { useBackend } from "@/lib/useBackend"; import { validateGraph } from "@/lib/validate"; import { walletFundNeeds } from "@/lib/fundNeeds"; @@ -40,8 +41,10 @@ export function App() { } return next; }); - const undo = () => { const prev = histPast.current.pop(); if (prev === undefined) return; histFuture.current.push(graph); setGraph(prev); }; - const redo = () => { const nxt = histFuture.current.pop(); if (nxt === undefined) return; histPast.current.push(graph); setGraph(nxt); }; + // Read the LATEST committed state via the functional updater (not a stale `graph` + // closure) so rapid ⌘Z/⌘Y and background edits can't corrupt the history stack. + const undo = () => { const prev = histPast.current.pop(); if (prev === undefined) return; setGraph((cur) => { histFuture.current.push(cur); return prev; }); }; + const redo = () => { const nxt = histFuture.current.pop(); if (nxt === undefined) return; setGraph((cur) => { histPast.current.push(cur); return nxt; }); }; const backend = useBackend(update); @@ -80,6 +83,16 @@ export function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Live, on-chain fee tiers for the sim (alpha only — needs the local backend). In + // the demo build the fetch returns null and the sim falls back to the genesis tier. + const [feeSchedule, setFeeSchedule] = useState(null); + useEffect(() => { + if (!import.meta.env.DEV || mode !== "alpha") return; + let alive = true; + void fetchFeeSchedule().then((s) => { if (alive) setFeeSchedule(s); }); + return () => { alive = false; }; + }, [mode]); + // On the Alpha page, load the REAL backend wallets (replace the demo/sim sample) // so what you see + copy are real keypairs/addresses, not placeholders. const alphaSyncedRef = useRef(false); @@ -99,15 +112,15 @@ export function App() { const needs = walletFundNeeds(prev); // SAME formula the dry-run audit uses let changed = false; const wallets = prev.wallets.map((w) => { - const need = Math.round((needs.get(w.id) ?? 0) * 1000) / 1000; + const need = Math.round((needs.get(w.id) ?? 0) * 1000) / 1000; // ALWAYS auto-track the need if (Math.abs((w.fundingSol ?? 0) - need) > 1e-9) { changed = true; return { ...w, fundingSol: need }; } return w; }); return changed ? { ...prev, wallets } : prev; }); - }, [graph.edges, graph.devBuySol, graph.launch.devWalletId, graph.wallets.length]); + }, [graph.edges, graph.devBuySol, graph.launch.devWalletId, graph.launch.priorityFee, graph.wallets.length]); - const sim = useMemo(() => runGraph(graph), [graph]); + const sim = useMemo(() => runGraph(graph, feeSchedule), [graph, feeSchedule]); const validation = useMemo(() => validateGraph(graph), [graph]); const blocks = validation.issues.filter((i) => i.severity === "block").length; @@ -133,10 +146,14 @@ export function App() { a.click(); URL.revokeObjectURL(a.href); } + // Replace the whole graph (load/reset) — clear undo history so ⌘Z can't jump back to + // the previous, now-discarded graph. + const replaceGraph = (g: FlowGraph) => { histPast.current = []; histFuture.current = []; setGraph(g); }; + function load(file: File) { file.text().then((t) => { try { - setGraph(normalizeLoadedGraph(JSON.parse(t), graph.solUsd)); + replaceGraph(normalizeLoadedGraph(JSON.parse(t), graph.solUsd)); } catch { alert("That file isn't a valid Lily score."); } }).catch(() => alert("Couldn't read that file.")); } @@ -182,7 +199,7 @@ export function App() { - + { const f = e.target.files?.[0]; if (f) load(f); e.target.value = ""; }} /> diff --git a/src/components/LaunchChecklist.tsx b/src/components/LaunchChecklist.tsx index 173671a..49a09a3 100644 --- a/src/components/LaunchChecklist.tsx +++ b/src/components/LaunchChecklist.tsx @@ -17,23 +17,33 @@ function rowsFromLog(log: LaunchLogEntry[], running: boolean): Row[] { } else if (running) { rows.push({ ok: null, label: "Creating token…" }); } + if (log.some((e) => e.kind === "resume-start")) rows.push({ ok: true, label: "Resumed — skipping work already on-chain" }); for (const e of log) { if (e.kind === "buy") rows.push({ ok: true, label: `${e.wallet} bought ${e.sol} SOL`, href: tx(e.signature) }); + else if (e.kind === "buy-skipped") rows.push({ ok: true, label: `${e.wallet} already filled`, detail: "resume — skipped" }); else if (e.kind === "rotate") rows.push({ ok: true, label: `${e.from} → ${e.to} rotated`, href: tx(e.buySig) ?? tx(e.sellSig) }); - else if (e.kind === "rotate-skipped") rows.push({ ok: false, label: `${e.from} → ${e.to} rotation skipped`, detail: String(e.reason ?? "") }); + else if (e.kind === "rotate-skipped") rows.push({ ok: false, label: `${e.from ? `${e.from} → ` : ""}${e.to} rotation skipped`, detail: String(e.reason ?? "") }); else if (e.kind === "timed-sell") rows.push({ ok: true, label: `${e.wallet} sold ${e.pct}%${e.value ? ` @ ${e.value}s` : ""}`, href: tx(e.signature), detail: e.solOut ? `+${Number(e.solOut).toFixed(4)} SOL` : undefined }); else if (e.kind === "sell-skipped") rows.push({ ok: false, label: `${e.wallet} sell skipped`, detail: String(e.reason ?? "") }); else if (e.kind === "error") rows.push({ ok: false, label: `${e.action}${e.wallet ? ` · ${e.wallet}` : ""} failed`, detail: String(e.message ?? "") }); } + // On-chain P&L reconciliation (the ground-truth summary the executor reads from chain) + const rec = log.find((e) => e.kind === "reconciliation"); + if (rec) rows.push({ ok: true, label: "On-chain reconciliation", detail: `net ${Number(rec.netSolDelta ?? 0).toFixed(4)} SOL · ${rec.walletsWithTokens ?? 0} wallets hold tokens` }); + else if (log.some((e) => e.kind === "reconcile-failed")) rows.push({ ok: null, label: "On-chain reconciliation unavailable", detail: "could not read closing balances" }); return rows; } export function LaunchChecklist({ log, mint, running, ok }: { log: LaunchLogEntry[]; mint?: string; running?: boolean; ok?: boolean | null }) { const rows = rowsFromLog(log, !!running); + // a run can finish ok:true yet have failed buys/sells → surface PARTIAL, don't call it clean + const term = log.find((e) => e.kind === "partial" || e.kind === "halted" || e.kind === "done"); + const partial = !running && (term?.kind === "partial" || Number(term?.buyFailures ?? 0) > 0 || Number(term?.sellFailures ?? 0) > 0); + const label = running ? "RUNNING" : ok === false || term?.kind === "halted" ? "HALTED" : partial ? "PARTIAL" : ok ? "LAUNCHED" : "—"; return (
- {running ? "RUNNING" : ok === false ? "HALTED" : ok ? "LAUNCHED" : "—"} + {label} {mint && {mint.slice(0, 8)}… } {rows.filter((r) => r.ok).length}/{rows.length} on-chain
diff --git a/src/components/LaunchPanel.tsx b/src/components/LaunchPanel.tsx index bf59ed6..8f08396 100644 --- a/src/components/LaunchPanel.tsx +++ b/src/components/LaunchPanel.tsx @@ -121,6 +121,9 @@ export function LaunchPanel({
patch((l) => { l.slippage = Math.max(0, Math.min(100, Number(e.target.value) || 0)); })} />
patch((l) => { l.priorityFee = Math.max(0, Number(e.target.value) || 0); })} />
+ {graph.edges.some((e) => e.bundleId) && ( +
⛓ Bundled launch — {graph.wallets.find((w) => w.id === (L.devWalletId || graph.wallets[0]?.id))?.label ?? "the dev wallet"} pays the Jito tip (no separate tip wallet). Works with a 0-SOL dev buy.
+ )} {(blockIssues.length > 0 || warnIssues.length > 0) && (
diff --git a/src/components/Playground.tsx b/src/components/Playground.tsx index 0a8bbb7..23dd6f2 100644 --- a/src/components/Playground.tsx +++ b/src/components/Playground.tsx @@ -1,10 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ReactFlow, ReactFlowProvider, Background, BackgroundVariant, - Controls, MiniMap, Handle, Position, @@ -20,11 +19,11 @@ import { type FinalConnectionState, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import { Boxes, Plus, Rocket, Trash2, Ungroup, X } from "lucide-react"; +import { Boxes, Hand, HelpCircle, Maximize, Minus, MousePointer2, Plus, Rocket, Trash2, Ungroup, X, ZoomIn, ZoomOut } from "lucide-react"; import { remainingPct, uid, type WalletLedger } from "@/lib/choreography"; import { - LAUNCH_NODE_ID, defaultEdge, defaultSell, edgeLabel, walletLabels, - type EdgeKind, type FlowGraph, type GraphEdge, type SellNode, + LAUNCH_NODE_ID, defaultEdge, defaultSell, edgeLabel, spineEdgeIds, walletLabels, + type EdgeKind, type FlowGraph, type GraphEdge, type SellNode, type SellTrigger, } from "@/lib/graph"; import { canConnect, canSell, type Severity, type Validation } from "@/lib/validate"; import { resolveDevId } from "@/lib/fundNeeds"; @@ -32,11 +31,13 @@ import { resolveDevId } from "@/lib/fundNeeds"; const BUY_COLOR = "#25a06a"; // green = buy (solid spine) const ROTATE_COLOR = "#2f6fed"; // blue = rotate (sell into another wallet, dotted) const SELL_COLOR = "#d9544a"; // red = sell (dotted, to a sell node) +const BUNDLE_COLOR = "#8b5cf6"; // violet = Jito bundle (spine-only, atomic ≤5 tx slot) const SEV_COLOR: Record = { ok: "", warn: "#d9a24a", block: "#d9544a" }; const LAUNCH_POS = { x: -260, y: 60 }; type WalletNodeData = { label: string; group: string; enabled: boolean; inRotation: boolean; orderLabel?: string; ledger?: WalletLedger }; -type SellNodeData = { value: number; pct: number; severity: Severity }; +type SellNodeData = { value: number; pct: number; trigger: SellTrigger; severity: Severity }; +const sellWhen = (t: SellTrigger, v: number) => (t === "now" ? "now" : t === "pct" ? `@ +${v}%` : `${v}s`); function WalletNode({ data, selected }: NodeProps>) { const l = data.ledger; @@ -81,7 +82,7 @@ function SellNodeView({ data, selected }: NodeProps>) {
{data.pct}% sell - {data.value}s wait + {sellWhen(data.trigger, data.value)}{data.trigger === "pct" ? " (sim)" : ""}
); @@ -89,18 +90,20 @@ function SellNodeView({ data, selected }: NodeProps>) { const nodeTypes = { wallet: WalletNode, launch: LaunchNode, sell: SellNodeView }; -function edgeToRf(e: GraphEdge, severity: Severity, selected: boolean): Edge { - const base = e.kind === "buy" ? BUY_COLOR : ROTATE_COLOR; +function edgeToRf(e: GraphEdge, severity: Severity, selected: boolean, bundleNo?: number): Edge { + // a bundled (spine) edge gets the violet bundle color + a per-bundle index badge (⛓1, ⛓2) + // so each atomic Jito slot reads as its own group; severity (warn/block) still overrides. + const base = e.bundleId ? BUNDLE_COLOR : e.kind === "buy" ? BUY_COLOR : ROTATE_COLOR; const stroke = SEV_COLOR[severity] || base; const dotted = e.kind === "rotate"; return { id: e.id, source: e.source, target: e.target, sourceHandle: e.kind === "rotate" ? "sell" : "buy", targetHandle: "in", selected, animated: severity === "block", - label: `${severity === "block" ? "⛔ " : severity === "warn" ? "⚠ " : ""}${edgeLabel(e)}${e.bundleId ? " ⛓" : ""}`, + label: `${severity === "block" ? "⛔ " : severity === "warn" ? "⚠ " : ""}${edgeLabel(e)}${e.bundleId ? ` ⛓${bundleNo ?? ""}` : ""}`, labelStyle: { fill: severity === "ok" ? "#2a1f08" : "#fff", fontSize: 10, fontWeight: 800 }, - labelBgStyle: { fill: severity === "ok" ? "#fbf9f3" : stroke, stroke, strokeWidth: 1 }, + labelBgStyle: { fill: severity === "ok" ? (e.bundleId ? "#efe9ff" : "#fbf9f3") : stroke, stroke, strokeWidth: 1 }, labelBgPadding: [5, 3] as [number, number], labelBgBorderRadius: 6, - style: { stroke, strokeWidth: e.bundleId ? 2.8 : e.kind === "buy" ? 2.4 : 1.8, strokeDasharray: dotted || severity === "block" ? "6 4" : undefined }, + style: { stroke, strokeWidth: e.bundleId ? 3.2 : e.kind === "buy" ? 2.4 : 1.8, strokeDasharray: dotted || severity === "block" ? "6 4" : undefined }, }; } @@ -135,7 +138,18 @@ function FlowInner({ const [connectErr, setConnectErr] = useState(""); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); - const { screenToFlowPosition } = useReactFlow(); + const { screenToFlowPosition, fitView, zoomIn, zoomOut } = useReactFlow(); + const [tool, setTool] = useState<"hand" | "select">("hand"); // default = pan/navigate; toggle to box-select + const [helpOpen, setHelpOpen] = useState(true); + // one-time bundling hint (dismissal persisted) — shown only when it's relevant + const [bundleHint, setBundleHint] = useState(() => { try { return localStorage.getItem("lily-bundle-hint") !== "off"; } catch { return true; } }); + const dismissBundleHint = () => { setBundleHint(false); try { localStorage.setItem("lily-bundle-hint", "off"); } catch { /* */ } }; + const paneRef = useRef(null); + const addWalletCentered = () => { + const r = paneRef.current?.getBoundingClientRect(); + const c = screenToFlowPosition({ x: (r?.left ?? 0) + (r?.width ?? 800) / 2, y: (r?.top ?? 0) + (r?.height ?? 500) / 2 }); + onCreateWallet(c.x, c.y); + }; const blocks = validation.issues.filter((i) => i.severity === "block").length; const warns = validation.issues.filter((i) => i.severity === "warn").length; @@ -145,6 +159,7 @@ function FlowInner({ useEffect(() => { setNodes((cur) => { const pos = new Map(cur.map((n) => [n.id, n.position])); + const wasSel = new Map(cur.map((n) => [n.id, n.selected])); // preserve selection across rebuilds (see edges effect) // The creator/dev wallet IS the launch node — render it as the black DEV box, // not a duplicate card. const creatorId = resolveDevId(graph); @@ -156,21 +171,30 @@ function FlowInner({ const rotTargets = new Set(graph.edges.filter((e) => e.kind === "rotate").map((e) => e.target)); const labels = walletLabels(graph); const walletNodes: Node[] = graph.wallets.filter((w) => w.id !== creatorId).map((w) => ({ - id: w.id, type: "wallet", position: pos.get(w.id) ?? { x: w.x, y: w.y }, + id: w.id, type: "wallet", position: pos.get(w.id) ?? { x: w.x, y: w.y }, selected: wasSel.get(w.id) ?? false, data: { label: w.label, group: w.group, enabled: w.enabled, inRotation: rotTargets.has(w.id), orderLabel: labels.get(w.id), ledger: ledgers.get(w.id) } satisfies WalletNodeData, })); const sellNodes: Node[] = graph.sells.map((s) => ({ - id: s.id, type: "sell", position: pos.get(s.id) ?? { x: s.x, y: s.y }, - data: { value: s.value, pct: s.pct, severity: validation.sell.get(s.id) ?? "ok" } satisfies SellNodeData, + id: s.id, type: "sell", position: pos.get(s.id) ?? { x: s.x, y: s.y }, selected: wasSel.get(s.id) ?? false, + data: { value: s.value, pct: s.pct, trigger: s.trigger, severity: validation.sell.get(s.id) ?? "ok" } satisfies SellNodeData, })); return [launch, ...walletNodes, ...sellNodes]; }); }, [graph.wallets, graph.edges, graph.sells, graph.launch.devWalletId, graph.devBuySol, ledgers, validation, setNodes]); useEffect(() => { - const main = graph.edges.map((e) => edgeToRf(e, validation.edge.get(e.id) ?? "ok", e.id === inspectorId)); - const sells = graph.sells.map((s) => sellEdge(s, validation.sell.get(s.id) ?? "ok")); - setEdges([...main, ...sells]); + // number bundles in spine order so each renders as its own group (⛓1, ⛓2, …) + const bundleNos = new Map(); + let bn = 0; + for (const e of [...graph.edges].sort((a, b) => a.order - b.order)) if (e.bundleId && !bundleNos.has(e.bundleId)) bundleNos.set(e.bundleId, ++bn); + setEdges((cur) => { + // PRESERVE live selection across rebuilds — otherwise a re-render mid box-select + // wipes the user's multi-selection, so e.g. "select 4 → Bundle" only bundles some. + const wasSel = new Map(cur.map((e) => [e.id, e.selected])); + const main = graph.edges.map((e) => edgeToRf(e, validation.edge.get(e.id) ?? "ok", wasSel.get(e.id) ?? (e.id === inspectorId), e.bundleId ? bundleNos.get(e.bundleId) : undefined)); + const sells = graph.sells.map((s) => sellEdge(s, validation.sell.get(s.id) ?? "ok")); + return [...main, ...sells]; + }); }, [graph.edges, graph.sells, validation, inspectorId, setEdges]); const onConnect = useCallback((c: Connection) => { @@ -220,7 +244,10 @@ function FlowInner({ if (!e0) return; const check = canConnect({ ...graph, edges: graph.edges.filter((x) => x.id !== oldEdge.id) }, c.source, c.target, e0.kind); if (!check.ok) return warn(check.reason || "Can't move it there"); - update((d) => { const e = d.edges.find((x) => x.id === oldEdge.id); if (e) { e.source = c.source!; e.target = c.target!; } }); + // Reconnecting restructures the chain — drop any bundle membership so a moved edge + // can't stay in a bundle it's no longer contiguous/on-spine with (would become an + // illegal off-spine bundle). + update((d) => { const e = d.edges.find((x) => x.id === oldEdge.id); if (e) { e.source = c.source!; e.target = c.target!; delete e.bundleId; } }); }, [update, graph]); const onNodeDragStop = useCallback((_: unknown, node: Node) => { @@ -238,15 +265,32 @@ function FlowInner({ setSelectedNodes(p.nodes.map((n) => n.id).filter((id) => id !== LAUNCH_NODE_ID)); }, []); + // SINGLE source of truth for "can this selection be bundled" — used by BOTH the button + // (proactive reason) and the click (no silent no-op). Consecutiveness is checked by + // SPINE POSITION, not raw `order` (which gets gaps after deletes → would wrongly reject + // a topologically-contiguous spine). + const bundleSel = useMemo(() => { + const es = selectedEdges.map((id) => graph.edges.find((e) => e.id === id)).filter(Boolean) as GraphEdge[]; + if (es.length < 2) return { ok: false, reason: "Select 2+ main-spine buys to bundle them into one Jito slot", ids: [] as string[] }; + if (es.some((e) => e.kind !== "buy")) return { ok: false, reason: "Only buys can be bundled — not rotates or sells", ids: [] as string[] }; + const spineOrder = [...spineEdgeIds(graph)]; // Set preserves spine (launch→…) insertion order + const posOf = new Map(spineOrder.map((id, i) => [id, i])); + if (es.some((e) => !posOf.has(e.id))) return { ok: false, reason: "Only main-spine buys can be Jito-bundled (sub-branch buys run separately)", ids: [] as string[] }; + const pos = es.map((e) => posOf.get(e.id)!).sort((a, b) => a - b); + if (!pos.every((v, k) => k === 0 || v === pos[k - 1] + 1)) return { ok: false, reason: "Bundle only consecutive spine steps", ids: [] as string[] }; + return { ok: true, reason: es.length > 5 ? `Bundle ${es.length} buys (over 5/slot — auto-splits)` : `Bundle ${es.length} buys into one slot`, ids: es.map((e) => e.id) }; + }, [selectedEdges, graph]); + function bundleSelected() { - const sel = selectedEdges.filter((id) => graph.edges.some((e) => e.id === id)); - const idx = sel.map((id) => graph.edges.find((e) => e.id === id)?.order ?? -1).filter((o) => o >= 0).sort((a, b) => a - b); - if (idx.length < 2) return; - if (!idx.every((v, k) => k === 0 || v === idx[k - 1] + 1)) return warn("Bundle only consecutive steps"); - const bid = `bundle-${idx[0]}`; - update((d) => d.edges.forEach((e) => { if (sel.includes(e.id)) e.bundleId = bid; })); + if (!bundleSel.ok) return warn(bundleSel.reason); + const bid = `bundle-${Math.min(...bundleSel.ids.map((id) => graph.edges.find((e) => e.id === id)!.order))}`; + update((d) => d.edges.forEach((e) => { if (bundleSel.ids.includes(e.id)) e.bundleId = bid; })); } - const ungroup = () => update((d) => d.edges.forEach((e) => { if (selectedEdges.includes(e.id)) delete e.bundleId; })); + // Ungroup the WHOLE bundle of any selected member (never split a bundleId across a gap). + const ungroup = () => update((d) => { + const bids = new Set(d.edges.filter((e) => selectedEdges.includes(e.id) && e.bundleId).map((e) => e.bundleId)); + d.edges.forEach((e) => { if (e.bundleId && bids.has(e.bundleId)) delete e.bundleId; }); + }); // delete everything selected: wallet cards (+ their edges & sells), sell cards, and edges const deleteSelection = useCallback(() => { @@ -269,7 +313,13 @@ function FlowInner({ if (!src.length) return; update((d) => { for (const w of src) d.wallets.push({ ...w, id: uid("w"), label: `${w.label} copy`, x: (w.x ?? 0) + 40, y: (w.y ?? 0) + 40 }); }); }, [graph, update]); - const selectAll = useCallback(() => setNodes((ns) => ns.map((n) => (n.id === LAUNCH_NODE_ID ? n : { ...n, selected: true }))), [setNodes]); + const selectAll = useCallback(() => { + setNodes((ns) => ns.map((n) => (n.id === LAUNCH_NODE_ID ? n : { ...n, selected: true }))); + setEdges((es) => es.map((e) => ({ ...e, selected: true }))); + // update the mirror state synchronously too, so an immediate Del/Bundle isn't a race + setSelectedNodes(graph.wallets.filter((w) => w.id !== LAUNCH_NODE_ID).map((w) => w.id).concat(graph.sells.map((s) => s.id))); + setSelectedEdges(graph.edges.map((e) => e.id)); + }, [setNodes, setEdges, graph]); const del = deleteSelection; @@ -287,6 +337,8 @@ function FlowInner({ else if (meta && k === "c") copySelection(); else if (meta && k === "v") { e.preventDefault(); pasteSelection(); } else if (meta && k === "d") { e.preventDefault(); copySelection(); pasteSelection(); } // duplicate in place + else if (!meta && k === "h") setTool("hand"); // Figma-style: H = hand/pan + else if (!meta && k === "v") setTool("select"); // V = move/select }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); @@ -294,6 +346,9 @@ function FlowInner({ const selEdge = inspectorId ? graph.edges.find((e) => e.id === inspectorId) ?? null : null; const selSell = inspectorId ? graph.sells.find((s) => s.id === inspectorId) ?? null : null; + // Canonical bundle numbering (first-seen by order, keyed by bundleId) — the SAME scheme + // the edge ⛓ badges use, so the toolbar chips and the canvas always agree. + const bundleNos = (() => { const m = new Map(); let n = 0; for (const e of [...graph.edges].sort((a, b) => a.order - b.order)) if (e.bundleId && !m.has(e.bundleId)) m.set(e.bundleId, ++n); return m; })(); return (
@@ -310,12 +365,24 @@ function FlowInner({ {blocks > 0 && ⛔ {blocks}} {blocks === 0 && warns > 0 && ⚠ {warns}} {graph.edges.length > 0 && blocks === 0 && warns === 0 && ✓ valid} - - + {validation.bundles.filter((b, i, arr) => arr.findIndex((x) => x.bundleId === b.bundleId) === i).map((b) => { + const reserved = 1 + (b.isLaunch && (graph.devBuySol || 0) > 0 ? 1 : 0); // create + dev buy + const maxBuyers = (b.limit - reserved) * 2; // 2 buyers per tx + const full = b.txCount >= b.limit; + return ( + 0 ? " + dev buy" : ""} + ${b.buyers} buyers, batched 2/tx). Max ${maxBuyers} buyers per bundle.${b.atomic ? "" : " OVER the 5-tx limit — remove buyers."}`} + style={{ color: !b.atomic ? "#c2410c" : full ? "#9a7b34" : "#6b46c1", background: !b.atomic ? "rgba(217,162,74,0.18)" : "rgba(139,92,246,0.14)" }}> + ⛓{bundleNos.get(b.bundleId) ?? "?"} · {b.txCount}/{b.limit} tx{!b.atomic ? " ⚠ over" : full ? " · MAX" : ""} + + ); + })} + +
-
+
- (n.id === LAUNCH_NODE_ID ? "#1a140a" : n.type === "sell" ? "#d9544a" : "#25a06a")} nodeStrokeWidth={2} maskColor="rgba(40,30,15,0.06)" /> + {/* left tool rail — default Hand (pan); toggle Select (box-select) */} +
+ + +
+ +
+ + + +
+ {menu && ( <>
setMenu(null)} onContextMenu={(e) => { e.preventDefault(); setMenu(null); }} />
+ {bundleSel.ok && } + {selectedEdges.some((id) => graph.edges.find((e) => e.id === id)?.bundleId) && }
)} {connectErr &&
⛔ {connectErr}
} + {bundleHint && graph.edges.filter((e) => e.kind === "buy").length >= 2 && validation.bundles.length === 0 && ( +
+ Tip: press V to Select, drag a box over consecutive buys, then Bundle them into one Jito slot. + +
+ )} {selEdge && setInspectorId(null)} onDelete={() => deleteEdge(selEdge.id)} />} {selSell && setInspectorId(null)} onDelete={() => deleteSell(selSell.id)} />} - {graph.edges.length === 0 && ( -
Drag a handle Launch → wallet to start, then wallet → wallet to chain. Drag to empty space for a sell. Drag on canvas to box-select · Del removes · ⌘Z undo · ⌘C/⌘V copy/paste · middle-drag or scroll to pan.
+ {helpOpen ? ( +
+
+ Quick guide + +
+
+

ToolsHand (H) drag to pan · Select (V) drag to box-select.

+

Build — drag a handle Launch → wallet, then wallet → wallet to chain; drag to empty space for a sell.

+

EditDel remove · ⌘Z undo · ⌘C/⌘V copy/paste · ⌘D duplicate.

+

Navigate — scroll or space-drag to pan · pinch or ⌘-scroll to zoom.

+
+
+ ) : ( + )}
buy rotate sell + ⛓ bundle (spine only)
@@ -455,18 +557,27 @@ function SellInspector({
{messages.length > 0 &&
{messages.map((m, i) =>
⛔ {m}
)}
} - + +
-
- - patch((s) => { s.trigger = "time"; s.value = Math.max(0, Number(e.target.value) || 0); })} /> -
+ {sell.trigger !== "now" && ( +
+ + patch((s) => { s.value = Math.max(0, Number(e.target.value) || 0); })} /> +
+ )}
patch((s) => { s.pct = Math.max(0, Math.min(100, Number(e.target.value) || 0)); })} />
+ {sell.trigger === "pct" &&
⚠ Gain-target sells are sim-only — they shape the chart preview but need real market volume to fire, so live execution skips them.
}
diff --git a/src/components/WalletsButton.tsx b/src/components/WalletsButton.tsx index 8748767..16b4708 100644 --- a/src/components/WalletsButton.tsx +++ b/src/components/WalletsButton.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { Copy, Download, Plus, Power, RefreshCw, Trash2, Wallet as WalletIcon } from "lucide-react"; import { remainingPct, uid, type WalletLedger } from "@/lib/choreography"; import { walletLayoutPos, type FlowGraph, type GraphWallet } from "@/lib/graph"; -import { resolveDevId } from "@/lib/fundNeeds"; +import { resolveDevId, walletFundNeeds } from "@/lib/fundNeeds"; import type { Backend } from "@/lib/useBackend"; import type { RealBalance } from "@/lib/api"; @@ -60,28 +60,51 @@ export function WalletsButton({ try { await backend.importReal(key); setPk(""); } catch (e) { setErr(e instanceof Error ? e.message : "import failed"); } finally { setBusy(false); } } // ---- funding (live) ---- + // ONE live source of truth for "how much each wallet needs" — the exact dry-run need, + // recomputed from the graph (never a stale stored field), so the Wallets tab and the + // launch always agree. Each wallet shows ready/short against its LIVE balance. + const needs = walletFundNeeds(graph); + const balOf = (id: string) => backend.balances.get(id)?.sol ?? 0; const devWallet = graph.wallets.find((w) => w.id === resolveDevId(graph)) ?? graph.wallets[0]; - const mainBal = devWallet ? backend.balances.get(devWallet.id)?.sol ?? 0 : 0; - const fundTargets = graph.wallets.filter((w) => devWallet && w.id !== devWallet.id && w.fundingSol > 0); - const fundedCount = fundTargets.filter((w) => (backend.balances.get(w.id)?.sol ?? 0) + 1e-9 >= w.fundingSol).length; + const mainBal = devWallet ? balOf(devWallet.id) : 0; + const need = (w: GraphWallet) => Math.round((needs.get(w.id) ?? 0) * 1000) / 1000; + const shortOf = (w: GraphWallet) => Math.max(0, need(w) - balOf(w.id)); + const fundTargets = graph.wallets.filter((w) => devWallet && w.id !== devWallet.id && need(w) > 0); + const fundedCount = fundTargets.filter((w) => shortOf(w) <= 0.0005).length; + const totalToAdd = fundTargets.reduce((t, w) => t + shortOf(w), 0); + const mainNeed = devWallet ? need(devWallet) : 0; // dev's own create+devbuy+tip need + const mainShort = Math.max(0, mainNeed + totalToAdd - mainBal); // does Main cover its role + funding everyone const shortMain = devWallet ? shortId(devWallet.publicKey || devWallet.id) : "—"; async function fundAll() { if (!import.meta.env.DEV || !live || !devWallet) return; // live-only; excluded from public bundle setErr(""); - if (!fundTargets.length) { setErr("Set a Fund SOL amount (e.g. 0.02) on the wallets you want to fund first."); return; } + if (!fundTargets.length) { setErr("No wallets need funding — draw some buys/rotations first."); return; } setBusy(true); try { const transfers = fundTargets - .map((w) => ({ toId: w.id, sol: Math.max(0, w.fundingSol - (backend.balances.get(w.id)?.sol ?? 0)) })) + .map((w) => ({ toId: w.id, sol: shortOf(w) })) .filter((t) => t.sol > 0.0005); - if (!transfers.length) { setErr("All wallets already at their target."); return; } + if (!transfers.length) { setErr("All wallets already funded for this launch. ✓"); return; } const { apiLive } = await import("@/lib/apiLive"); const r = await apiLive.fund(devWallet.id, transfers); backend.refresh(); setErr(r.results.some((x) => x.error) ? `Funded ${r.funded}/${transfers.length} — some failed.` : ""); } catch (e) { setErr(e instanceof Error ? e.message : "fund failed"); } finally { setBusy(false); } } + async function recoverAll() { + if (!import.meta.env.DEV || !live || !devWallet) return; + if (!confirm("Recover everything to Main? This SELLS every token bag in the other wallets and sweeps all their SOL back to Main.")) return; + setErr(""); + setBusy(true); + try { + const { apiLive } = await import("@/lib/apiLive"); + const r = await apiLive.recover(devWallet.id); + backend.refresh(); + const failed = r.rows.filter((x) => x.error).length; + setErr(`Recovered +${r.totalSwept.toFixed(3)}◎ to Main (sold ${r.bagsSold} bags)${failed ? ` · ${failed} wallet(s) errored` : ""}. Main: ${r.mainBefore.toFixed(3)} → ${r.mainAfter.toFixed(3)}◎`); + } catch (e) { setErr(e instanceof Error ? e.message : "recover failed"); } finally { setBusy(false); } + } async function downloadWallets() { if (!import.meta.env.DEV) return; // live-only; excluded from public bundle try { @@ -140,11 +163,19 @@ export function WalletsButton({ {shortMain} - funded {fundedCount}/{fundTargets.length} + 0.0005 ? { color: "#9a7b34" } : { color: "#25a06a" }}> + {fundedCount}/{fundTargets.length} ready{totalToAdd > 0.0005 ? ` · +${totalToAdd.toFixed(3)}◎ to add` : " ✓"} +
- +
+ + +
+ {mainShort > 0.0005 &&
⚠ Main needs ~{(mainNeed + totalToAdd).toFixed(3)}◎ total (its launch role + funding the others) — has {mainBal.toFixed(3)}. Top up Main first.
}
)} {err &&
{err}
} @@ -152,7 +183,7 @@ export function WalletsButton({
{graph.wallets.length === 0 &&
No wallets yet — Create one, or paste a key and Add.
} {graph.wallets.map((w) => ( - remove(w.id)} /> + remove(w.id)} /> ))}
@@ -162,18 +193,22 @@ export function WalletsButton({ } function WalletRow({ - w, ledger, real, live, update, onRemove, + w, ledger, real, live, need, isMain, update, onRemove, }: { w: GraphWallet; ledger?: WalletLedger; real?: RealBalance; live: boolean; + need: number; // live launch need for this wallet (matches the dry-run) + isMain: boolean; // the dev/main wallet — it funds the others update: (fn: (draft: FlowGraph) => void) => void; onRemove: () => void; }) { const rem = ledger ? remainingPct(ledger) : 0; const addr = w.publicKey || w.id; const bal = live ? real?.sol ?? 0 : ledger?.cashSol ?? w.fundingSol; + const short = Math.max(0, need - bal); + const ready = short <= 0.0005; function patch(fn: (x: GraphWallet) => void) { update((d) => { const x = d.wallets.find((y) => y.id === w.id); if (x) fn(x); }); } return ( @@ -186,10 +221,17 @@ function WalletRow({ {shortId(addr)} - patch((x) => { x.fundingSol = Number(e.target.value) || 0; })} /> + {/* live funding status — needs N, has the balance, ready or +X to add. Auto-tracked. */} + {live ? ( + + {ready ? "✓ ready" : `+${short.toFixed(3)} to add`} + + ) : ( + need {need.toFixed(3)} + )} {bal.toFixed(3)} SOL{live ? "" : ` · ${rem.toFixed(0)}%`} - + ); } diff --git a/src/lib/apiLive.ts b/src/lib/apiLive.ts index 9d193cd..6585510 100644 --- a/src/lib/apiLive.ts +++ b/src/lib/apiLive.ts @@ -15,6 +15,7 @@ export const apiLive = { fund: (fromId: string, transfers: { toId: string; sol: number }[]) => j("/api/wallets/fund", jsonPost({ fromId, transfers })), exportWallets: () => j<{ updatedAt: string; wallets: unknown[] }>("/api/wallets/export", jsonPost({})), + recover: (mainId: string) => j<{ rows: { wallet: string; soldBags: number; sweptSol: number; error?: string }[]; totalSwept: number; mainBefore: number; mainAfter: number; bagsSold: number }>("/api/wallets/recover", jsonPost({ mainId })), importKey: async (secretKey: string): Promise<{ created: RealWallet[]; duplicate?: boolean }> => { const res = await fetch("/api/wallets/import", jsonPost({ secretKey })); const body = await res.json().catch(() => ({ error: "import failed" })); diff --git a/src/lib/choreography.ts b/src/lib/choreography.ts index a8ae885..4391016 100644 --- a/src/lib/choreography.ts +++ b/src/lib/choreography.ts @@ -23,7 +23,10 @@ import { type SimCandle, } from "@/lib/pumpSimulationMath"; import type { SimChartMarker } from "@/components/SimChartPanel"; +import { feeBpsForMarketCapSol, type FeeSchedule } from "@/lib/pumpFees"; +// Genesis-tier defaults (verified bit-exact with the SDK). Used when no live fee +// schedule is supplied; otherwise fees are resolved per-trade by market cap. export const PROTOCOL_FEE_BPS = 95; export const CREATOR_FEE_BPS = 30; export const DEFAULT_GAP_MS = 1000; @@ -138,6 +141,7 @@ type Ctx = { solUsd: number; totalIn: number; totalOut: number; + feeSchedule: FeeSchedule | null; }; function recordPrint( @@ -168,12 +172,13 @@ function doBuy(ctx: Ctx, walletId: string, sol: number, ts: number, label: strin const ledger = ctx.ledgers.get(walletId); if (!ledger) return; if (!(sol > 0)) return; + const fee = feeBpsForMarketCapSol(ctx.feeSchedule, marketCapSolFromState(ctx.state)); const exec = executePumpTradeIntent(ctx.state, { side: "buy", sol, buySolMode: "wallet-gross", - protocolFeeBps: PROTOCOL_FEE_BPS, - creatorFeeBps: CREATOR_FEE_BPS, + protocolFeeBps: fee.protocolBps, + creatorFeeBps: fee.creatorBps, }); const walletSol = lamportsToSol(exec.walletSolLamports); const curveSol = lamportsToSol(exec.curveSolLamports); @@ -204,11 +209,12 @@ function doSell(ctx: Ctx, walletId: string, pct: number, ts: number, label: stri ctx.errors.push(`${label}: ${shortId(walletId)} has nothing to sell`); return; } + const fee = feeBpsForMarketCapSol(ctx.feeSchedule, marketCapSolFromState(ctx.state)); const exec = executePumpTradeIntent(ctx.state, { side: "sell", tokenAmount: tokensToSell, - protocolFeeBps: PROTOCOL_FEE_BPS, - creatorFeeBps: CREATOR_FEE_BPS, + protocolFeeBps: fee.protocolBps, + creatorFeeBps: fee.creatorBps, }); const walletSol = lamportsToSol(exec.walletSolLamports); const curveSol = lamportsToSol(exec.curveSolLamports); @@ -231,7 +237,7 @@ function shortId(id: string): string { * Deterministically simulate a whole choreography. Pure: same input -> same * output. Re-run on every edit to drive the live chart + ledgers. */ -export function simulate(choreo: Choreography): SimResult { +export function simulate(choreo: Choreography, feeSchedule: FeeSchedule | null = null): SimResult { const ctx: Ctx = { state: createInitialPumpState(), ledgers: new Map(choreo.wallets.map((w) => [w.id, emptyLedger(w)])), @@ -241,6 +247,7 @@ export function simulate(choreo: Choreography): SimResult { solUsd: choreo.solUsd > 0 ? choreo.solUsd : 0, totalIn: 0, totalOut: 0, + feeSchedule, }; // Dev buy lives in a synthetic "creator" ledger so it has somewhere to land. diff --git a/src/lib/fundNeeds.ts b/src/lib/fundNeeds.ts index 888e034..4e36953 100644 --- a/src/lib/fundNeeds.ts +++ b/src/lib/fundNeeds.ts @@ -3,8 +3,14 @@ // exact constants + formula, so "Fund all wallets" always satisfies the dry run. import { LAUNCH_NODE_ID, type FlowGraph } from "@/lib/graph"; -export const CREATE_BUFFER_SOL = 0.02; // rent + create-tx headroom for the creator -export const FEE_BUFFER_SOL = 0.003; // per-action fee + priority headroom (≥ executor's rotate sweep reserve) +// Reconciled with the executor: the bundle launch path requires devBuy + 0.03 on the +// dev wallet (server/engine/launch.ts), so CREATE_BUFFER must be ≥ that or a run that +// passes the dry-run can still throw "Dev wallet needs at least 0.03" (audit HIGH). +export const CREATE_BUFFER_SOL = 0.14; // mint/curve/metadata rent (~0.02) + create-tx fee + the 0.1◎ Jito tip the dev pays + margin +// Per-action headroom must cover: Token-2022 ATA rent (~0.00204) + base fee + the +// priority fee + the executor's 0.003 rotate-sweep reserve. 0.003 only cleared a +// single buy by ~0.0009 and ignored priority fees entirely (audit HIGH). +export const FEE_BUFFER_SOL = 0.006; /** THE one dev/creator rule, used by client + server so funding lands on the * wallet that signs the launch. Stable: an explicit devWalletId, else the FIRST @@ -20,13 +26,30 @@ export function resolveDevId(graph: FlowGraph): string | undefined { export function walletFundNeeds(graph: FlowGraph): Map { const need = new Map(); const add = (id: string, sol: number) => { if (id && id !== LAUNCH_NODE_ID) need.set(id, (need.get(id) ?? 0) + sol); }; + // Only count ENABLED wallets/edges — the executor (graphToChoreography) drops disabled + // ones, so counting them here makes the funding meter disagree with what actually runs. + const on = new Set(graph.wallets.filter((w) => w.enabled).map((w) => w.id)); + // Mirror the server audit (execute.ts) EXACTLY, incl. the per-tx priority fee, so the + // Wallets-tab target == the dry-run need (no more "funded" but "short"). + const pri = Math.max(0, Number(graph.launch?.priorityFee) || 0); + // A pump buy can spend up to sol*(1+slippage) — the SDK caps at the slippage tolerance, + // not the nominal amount. Funding only `sol` makes any post-bundle price move blow the + // budget ("insufficient lamports, need X" — the exact failure that halted a live run). + // Provision the slippage-MAX so a buy can't run out of SOL; idle headroom is recoverable. + const slip = Math.max(0, Number(graph.launch?.slippage) || 0) / 100; + const buyCost = (sol: number) => (sol || 0) * (1 + slip) + FEE_BUFFER_SOL + pri; const devId = resolveDevId(graph); - if (devId) add(devId, CREATE_BUFFER_SOL + (graph.devBuySol || 0) + FEE_BUFFER_SOL); + if (devId) add(devId, CREATE_BUFFER_SOL + buyCost(graph.devBuySol || 0)); for (const e of graph.edges) { - if (e.kind === "buy") add(e.target, (e.sol || 0) + FEE_BUFFER_SOL); - else if (e.kind === "rotate") add(e.source, FEE_BUFFER_SOL); + if (e.kind === "buy" && on.has(e.target)) add(e.target, buyCost(e.sol || 0)); + else if (e.kind === "rotate" && on.has(e.source) && on.has(e.target)) { + add(e.source, FEE_BUFFER_SOL + pri); + // rotation target re-buys with the swept proceeds; pre-fund a rent+fee buffer so the + // rebuy's ATA-create + fee don't eat into the swept amount it buys with. + add(e.target, FEE_BUFFER_SOL + pri); + } } - for (const s of graph.sells) add(s.walletId, FEE_BUFFER_SOL); + for (const s of graph.sells) if (on.has(s.walletId)) add(s.walletId, FEE_BUFFER_SOL + pri); return need; } diff --git a/src/lib/graph.ts b/src/lib/graph.ts index 4b3c4af..7d5604d 100644 --- a/src/lib/graph.ts +++ b/src/lib/graph.ts @@ -15,6 +15,7 @@ import { type TapeAction, type Wallet, } from "@/lib/choreography"; +import type { FeeSchedule } from "@/lib/pumpFees"; export type EdgeKind = "buy" | "rotate"; @@ -47,6 +48,7 @@ export type GraphWallet = Wallet & { x: number; y: number; publicKey?: string; + fundingManual?: boolean; // user hand-edited Fund SOL → auto-fill must not clobber it }; // mirror the engine (server/engine/launch.ts) @@ -133,6 +135,29 @@ export function edgeLabel(e: GraphEdge): string { return e.kind === "buy" ? `buy ${e.sol} SOL` : "rotate"; // rotate is always instant 100% — no config } +/** Edge ids on the MAIN buy spine — the buy chain descending from Launch via each + * node's single buy-out, never through a rotate. ONLY these edges may be Jito-bundled: + * a bundle lands atomically in one slot (≤5 tx), and a sub-branch off a rotate runs as + * its own step, so it can't share that slot. Used by validate (block off-spine bundles) + * and the canvas (color the spine's bundle list). */ +export function spineEdgeIds(graph: FlowGraph): Set { + const buyFrom = new Map(); + for (const e of [...graph.edges].sort((a, b) => a.order - b.order)) { + if (e.kind === "buy" && !buyFrom.has(e.source)) buyFrom.set(e.source, e); + } + const ids = new Set(); + const seen = new Set([LAUNCH_NODE_ID]); + let cur: string | undefined = LAUNCH_NODE_ID; + while (cur !== undefined) { + const e = buyFrom.get(cur); + if (!e || seen.has(e.target)) break; + ids.add(e.id); + seen.add(e.target); + cur = e.target; + } + return ids; +} + const ROTATE_GAP_MS = 2000; // rotate fires this long after its source bought (instant sell→buy, no config) /** Build the LIVE TAPE from the playground: every trade with an ABSOLUTE timestamp. @@ -232,8 +257,8 @@ function toAction(e: GraphEdge): Action { return { id: e.id, kind: "rotate", walletId: e.source, toWalletId: e.target, sellPct: e.sellPct }; } -export function runGraph(graph: FlowGraph): SimResult { - return simulate(graphToChoreography(graph)); +export function runGraph(graph: FlowGraph, feeSchedule: FeeSchedule | null = null): SimResult { + return simulate(graphToChoreography(graph), feeSchedule); } const fin = (v: unknown, d = 0) => (Number.isFinite(Number(v)) ? Number(v) : d); @@ -249,7 +274,7 @@ export function normalizeLoadedGraph(raw: unknown, liveSolUsd: number): FlowGrap const wallets: GraphWallet[] = (Array.isArray(p.wallets) ? p.wallets : []).flatMap((w: any) => { if (!w || typeof w.id !== "string" || wIds.has(w.id)) return []; wIds.add(w.id); - return [{ id: w.id, label: str(w.label, "Wallet"), group: str(w.group, "core"), fundingSol: fin(w.fundingSol), enabled: w.enabled !== false, x: fin(w.x), y: fin(w.y), publicKey: typeof w.publicKey === "string" ? w.publicKey : undefined }]; + return [{ id: w.id, label: str(w.label, "Wallet"), group: str(w.group, "core"), fundingSol: fin(w.fundingSol), enabled: w.enabled !== false, x: fin(w.x), y: fin(w.y), publicKey: typeof w.publicKey === "string" ? w.publicKey : undefined, fundingManual: w.fundingManual === true }]; }); const eIds = new Set(); const edges: GraphEdge[] = (Array.isArray(p.edges) ? p.edges : []).flatMap((e: any, i: number) => { diff --git a/src/lib/plan.ts b/src/lib/plan.ts index 032093b..f39b84c 100644 --- a/src/lib/plan.ts +++ b/src/lib/plan.ts @@ -37,7 +37,12 @@ export function buildPlan(graph: FlowGraph): ExecutePlan { const sells: PlanSell[] = graph.sells .filter((s) => enabled.has(s.walletId) && s.pct > 0) .map((s) => ({ walletId: s.walletId, trigger: s.trigger, value: s.value, pct: s.pct })); + const devId = resolveDevId(graph); + // The ⛓ launch bundle should ACTUALLY Jito-bundle (atomic, one slot) — drive useJito + // from whether the first step is a bundle, not a never-set flag. Without this the + // "bundle" silently ran in normal/sequential mode (txns across 6 slots). + const firstIsBundle = steps[0]?.sendMode === "bundle" && steps[0].actions.some((a) => a.kind === "buy" && a.walletId !== devId); // pin the resolved dev wallet so the server signs the launch with the exact // wallet the client funded (no "first wallet" vs "first buyer" divergence) - return { launch: { ...graph.launch, devWalletId: resolveDevId(graph), devBuySol: graph.devBuySol }, steps, sells }; + return { launch: { ...graph.launch, useJito: graph.launch.useJito || firstIsBundle, devWalletId: devId, devBuySol: graph.devBuySol }, steps, sells }; } diff --git a/src/lib/pumpFees.ts b/src/lib/pumpFees.ts new file mode 100644 index 0000000..4c1793e --- /dev/null +++ b/src/lib/pumpFees.ts @@ -0,0 +1,40 @@ +// Market-cap-tiered pump.fun fee resolution for the simulator. +// +// pump.fun's bonding-curve fees step DOWN as market cap rises; the real tier table +// lives in the on-chain feeConfig (the SDK ships none). So we DON'T hardcode a guessed +// table — instead the local server fetches the live tiers (GET /api/fee-schedule, alpha +// only) and the sim looks up the bps for each trade's current market cap. Offline / in +// the demo build there's no server, so we fall back to the GENESIS tier (95/30 bps), +// which is verified bit-exact with the SDK at launch market caps. + +import { j } from "@/lib/api"; + +export type FeeTier = { thresholdSol: number; protocolBps: number; creatorBps: number }; +export type FeeSchedule = { tiers: FeeTier[]; fallbackProtocolBps: number; fallbackCreatorBps: number }; + +// Genesis/launch-tier fees — verified bit-exact against @pump-fun/pump-sdk. +export const GENESIS_FEE = { protocolBps: 95, creatorBps: 30 } as const; + +/** Resolve {protocolBps, creatorBps} for a given market cap (SOL). Mirrors the SDK's + * calculateFeeTier: below the first threshold → first tier; else the last tier whose + * threshold ≤ marketCap. With no schedule (offline/demo) → the genesis tier. */ +export function feeBpsForMarketCapSol( + schedule: FeeSchedule | null | undefined, + marketCapSol: number, +): { protocolBps: number; creatorBps: number } { + if (!schedule) return { ...GENESIS_FEE }; + const tiers = schedule.tiers; + if (!tiers.length) return { protocolBps: schedule.fallbackProtocolBps, creatorBps: schedule.fallbackCreatorBps }; + if (marketCapSol < tiers[0].thresholdSol) return { protocolBps: tiers[0].protocolBps, creatorBps: tiers[0].creatorBps }; + for (let i = tiers.length - 1; i >= 0; i -= 1) { + if (marketCapSol >= tiers[i].thresholdSol) return { protocolBps: tiers[i].protocolBps, creatorBps: tiers[i].creatorBps }; + } + return { protocolBps: tiers[0].protocolBps, creatorBps: tiers[0].creatorBps }; +} + +/** Fetch the live on-chain fee schedule from the local backend (alpha only). Returns + * null when no backend is reachable (demo build) — caller falls back to GENESIS_FEE. */ +export async function fetchFeeSchedule(): Promise { + try { return await j("/api/fee-schedule"); } + catch { return null; } +} diff --git a/src/lib/validate.ts b/src/lib/validate.ts index f4f08cd..fecf2cc 100644 --- a/src/lib/validate.ts +++ b/src/lib/validate.ts @@ -2,12 +2,12 @@ // Model: a GREEN buy spine (one buy-out per node) plus optional DOTTED branches // per wallet (one rotate OR sell exit). Structural rules only — funding is on-chain. -import { LAUNCH_NODE_ID, type EdgeKind, type FlowGraph, type GraphEdge, type SellNode } from "@/lib/graph"; +import { LAUNCH_NODE_ID, spineEdgeIds, type EdgeKind, type FlowGraph, type GraphEdge, type SellNode } from "@/lib/graph"; import { resolveDevId } from "@/lib/fundNeeds"; export type Severity = "ok" | "warn" | "block"; export type Issue = { severity: "warn" | "block"; code: string; message: string; edgeId?: string; sellId?: string; bundleId?: string }; -export type BundleInfo = { bundleId: string; txCount: number; limit: number; atomic: boolean; splitInto: number; isLaunch: boolean }; +export type BundleInfo = { bundleId: string; txCount: number; limit: number; atomic: boolean; splitInto: number; isLaunch: boolean; buyers: number }; export type Validation = { severity: Severity; issues: Issue[]; @@ -20,8 +20,7 @@ export type Validation = { const worse = (a: Severity, b: Severity): Severity => a === "block" || b === "block" ? "block" : a === "warn" || b === "warn" ? "warn" : "ok"; -const BUNDLE_LIMIT = 5; -const LAUNCH_BUYER_LIMIT = 4; +const BUNDLE_LIMIT = 5; // Jito's hard cap: max 5 transactions per bundle /** Constrain how you draw a connection. A node may have ONE buy-out (green spine) * and ONE dotted branch (rotate). Sells are created separately (not via connect). */ @@ -61,6 +60,8 @@ export function validateGraph(graph: FlowGraph): Validation { const byId = new Map(graph.wallets.map((w) => [w.id, w])); const isWallet = (id: string) => byId.has(id); const isEnabled = (id: string) => byId.get(id)?.enabled === true; + // Only the main buy spine can be a Jito bundle (one atomic slot, ≤5 tx). + const spine = spineEdgeIds(graph); const flag = (e: GraphEdge | null, severity: "warn" | "block", code: string, message: string, bundleId?: string) => { issues.push({ severity, code, message, edgeId: e?.id, bundleId }); @@ -86,6 +87,7 @@ export function validateGraph(graph: FlowGraph): Validation { else if (!isEnabled(e.target)) flag(e, "block", "target-off", "Target wallet is turned off"); if (e.kind === "buy" && !(Number(e.sol) > 0)) flag(e, "block", "buy-amount", "Buy amount must be greater than 0"); + if (e.kind === "buy" && e.bundleId && !spine.has(e.id)) flag(e, "block", "bundle-offspine", "Only the main spine can be a Jito bundle — a sub-branch buy runs as its own step", e.bundleId); if (e.kind === "rotate") { if (e.source === LAUNCH_NODE_ID) flag(e, "block", "rotate-from-launch", "Rotate needs a source wallet (not Launch)"); else if (!isWallet(e.source)) flag(e, "block", "rotate-source-missing", "Rotate source doesn't exist"); @@ -155,13 +157,19 @@ export function validateGraph(graph: FlowGraph): Validation { if (seenBundles.has(bid)) for (const x of run) flag(x, "warn", "bundle-split", "This bundle isn't contiguous — it runs as separate slots, not one atomic bundle", bid); seenBundles.add(bid); const isLaunch = startIndex === 0; - const txCount = run.reduce((n, x) => n + (x.kind === "rotate" ? 3 : 1), 0); - const limit = isLaunch ? LAUNCH_BUYER_LIMIT : BUNDLE_LIMIT; + // A Jito bundle holds max 5 TRANSACTIONS. The launch bundle spends them on: + // create + dev buy (when >0) + buyers batched 2/tx (the tip rides an existing tx). + // Non-launch bundles count each buy=1 tx, rotate=3 tx. + const buyers = run.filter((x) => x.kind === "buy").length; + const txCount = isLaunch + ? 1 + ((Number(graph.devBuySol) || 0) > 0 ? 1 : 0) + Math.ceil(buyers / 2) + : run.reduce((n, x) => n + (x.kind === "rotate" ? 3 : 1), 0); + const limit = BUNDLE_LIMIT; // 5 — Jito's hard per-bundle transaction cap const atomic = txCount <= limit; const splitInto = Math.max(1, Math.ceil(txCount / limit)); - bundles.push({ bundleId: bid, txCount, limit, atomic, splitInto, isLaunch }); + bundles.push({ bundleId: bid, txCount, limit, atomic, splitInto, isLaunch, buyers }); if (!atomic) for (const x of run) flag(x, "warn", "bundle-oversize", - `Bundle has ${txCount} tx (max ${limit}/slot) — auto-splits into ${splitInto}; only the first is atomic`, bid); + `Bundle needs ${txCount} tx — over Jito's ${limit}-tx limit. Remove ${isLaunch ? "buyers" : "steps"} so it fits one atomic bundle.`, bid); } // ---- creator double-buy guard ---- diff --git a/src/styles.css b/src/styles.css index 7c90970..3146ff3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -395,13 +395,45 @@ label.lbl { .pg-step-flag { font-size: 12px; } .pg-inspector { position: absolute; left: 12px; bottom: 12px; width: 320px; z-index: 20; border-radius: 14px; } -.pg-hint { - position: absolute; top: 14px; left: 50%; transform: translateX(-50%); - pointer-events: none; z-index: 5; white-space: nowrap; - background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow-sm); - border-radius: 999px; padding: 7px 16px; color: var(--muted); font-size: 12px; font-weight: 600; -} -.pg-hint b { color: var(--gold-2); font-weight: 800; } +/* left tool rail (Figma-style) */ +.pg-tools { + position: absolute; left: 14px; top: 50%; transform: translateY(-50%); z-index: 6; + display: flex; flex-direction: column; gap: 2px; padding: 5px; + background: var(--panel); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow-sm); +} +.pg-tools button { + width: 34px; height: 34px; display: grid; place-items: center; cursor: pointer; + background: transparent; border: none; border-radius: 9px; color: var(--muted); +} +.pg-tools button:hover { background: rgba(40, 30, 15, 0.07); color: var(--ink); } +.pg-tools button.on { background: var(--ink); color: #f7f2e7; } +.pg-tools-sep { height: 1px; margin: 3px 5px; background: var(--line); } + +/* tool-driven cursors on the canvas */ +.tool-hand .react-flow__pane { cursor: grab; } +.tool-hand .react-flow__pane:active { cursor: grabbing; } +.tool-select .react-flow__pane { cursor: crosshair; } + +/* collapsible help panel */ +.pg-help { + position: absolute; left: 14px; bottom: 14px; z-index: 6; width: 290px; + background: var(--panel); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow-sm); overflow: hidden; +} +.pg-help-head { + display: flex; align-items: center; justify-content: space-between; padding: 8px 10px 8px 14px; + border-bottom: 1px solid var(--line); font-size: 12px; font-weight: 800; color: var(--ink); +} +.pg-help-body { padding: 10px 14px; } +.pg-help-body p { margin: 0 0 7px; font-size: 12px; line-height: 1.5; color: var(--muted); } +.pg-help-body p:last-child { margin-bottom: 0; } +.pg-help-body b { color: var(--gold-2); font-weight: 800; } +.pg-help-fab { + position: absolute; left: 14px; bottom: 14px; z-index: 6; cursor: pointer; + display: inline-flex; align-items: center; gap: 7px; padding: 9px 14px; + background: var(--panel); border: 1px solid var(--line); border-radius: 999px; box-shadow: var(--shadow-sm); + font-size: 12px; font-weight: 800; color: var(--ink); +} +.pg-help-fab:hover { background: rgba(40, 30, 15, 0.04); } .connect-toast { position: absolute; top: 12px; left: 50%; transform: translateX(-50%); z-index: 30; background: #d9544a; color: #fff; font-size: 12px; font-weight: 700; diff --git a/tests/concurrency.test.ts b/tests/concurrency.test.ts new file mode 100644 index 0000000..861593c --- /dev/null +++ b/tests/concurrency.test.ts @@ -0,0 +1,36 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mapLimit } from "../server/execute"; + +test("mapLimit caps in-flight count and preserves output order", async () => { + let inFlight = 0, maxInFlight = 0; + const out = await mapLimit([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 3, 0, async (n) => { + inFlight += 1; maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((r) => setTimeout(r, 5)); + inFlight -= 1; return n * 2; + }); + assert.ok(maxInFlight <= 3, `max in-flight ${maxInFlight} must be ≤ 3`); + assert.deepEqual(out.map((r) => (r.status === "fulfilled" ? r.value : null)), [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]); +}); + +test("mapLimit captures per-item rejections without throwing the batch", async () => { + const out = await mapLimit([1, 2, 3], 2, 0, async (n) => { if (n === 2) throw new Error("boom"); return n; }); + assert.equal(out[0].status, "fulfilled"); + assert.equal(out[1].status, "rejected"); + assert.equal(out[2].status, "fulfilled"); +}); + +test("mapLimit handles empty input", async () => { + assert.deepEqual(await mapLimit([], 4, 0, async () => 1), []); +}); + +test("mapLimit with stagger still caps concurrency + preserves order", async () => { + let inFlight = 0, maxInFlight = 0; + const out = await mapLimit([1, 2, 3, 4, 5, 6], 2, 30, async (n) => { + inFlight += 1; maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((r) => setTimeout(r, 3)); + inFlight -= 1; return n; + }); + assert.ok(maxInFlight <= 2, `staggered max in-flight ${maxInFlight} must be ≤ 2`); + assert.deepEqual(out.map((r) => (r.status === "fulfilled" ? r.value : null)), [1, 2, 3, 4, 5, 6]); +}); diff --git a/tests/fundNeeds.test.ts b/tests/fundNeeds.test.ts new file mode 100644 index 0000000..d9f7c74 --- /dev/null +++ b/tests/fundNeeds.test.ts @@ -0,0 +1,35 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { walletFundNeeds, totalFundNeed, resolveDevId, CREATE_BUFFER_SOL, FEE_BUFFER_SOL } from "@/lib/fundNeeds"; +import { W, E, G, LAUNCH_NODE_ID } from "./helpers"; + +test("buy need provisions the slippage-MAX (sol*(1+slip)) + fee + priority; dev too", () => { + const g = G([W("dev"), W("a")], [E("buy", LAUNCH_NODE_ID, "a", 0, { sol: 1 })], 0.1, "dev"); + const pri = g.launch.priorityFee; // need mirrors the audit, incl. per-tx priority fee + const slip = g.launch.slippage / 100; // a buy can spend up to sol*(1+slippage) + const needs = walletFundNeeds(g); + assert.ok(Math.abs((needs.get("dev") ?? 0) - (CREATE_BUFFER_SOL + 0.1 * (1 + slip) + FEE_BUFFER_SOL + pri)) < 1e-9); + assert.ok(Math.abs((needs.get("a") ?? 0) - (1 * (1 + slip) + FEE_BUFFER_SOL + pri)) < 1e-9); +}); + +test("rotate source AND target each get a fee buffer + priority; total sums all wallets", () => { + const g = G( + [W("dev"), W("a"), W("b")], + [E("buy", LAUNCH_NODE_ID, "a", 0, { sol: 1 }), E("rotate", "a", "b", 1, { sellPct: 100 })], + 0, "dev", + ); + const pri = g.launch.priorityFee; + const slip = g.launch.slippage / 100; + const needs = walletFundNeeds(g); + // a buys (1*(1+slip)+FEE+pri) AND is a rotate source (+FEE+pri) + assert.ok(Math.abs((needs.get("a") ?? 0) - (1 * (1 + slip) + 2 * FEE_BUFFER_SOL + 2 * pri)) < 1e-9); + // b is the rotate target — pre-funded a rent/fee buffer for its re-buy + assert.ok(Math.abs((needs.get("b") ?? 0) - (FEE_BUFFER_SOL + pri)) < 1e-9); + const total = totalFundNeed(g); + assert.ok(Math.abs(total - [...needs.values()].reduce((s, v) => s + v, 0)) < 1e-9); +}); + +test("resolveDevId prefers explicit devWalletId, else first wallet", () => { + assert.equal(resolveDevId(G([W("x"), W("y")], [], 0, "y")), "y"); + assert.equal(resolveDevId(G([W("x"), W("y")], [])), "x"); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..305e7dc --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,13 @@ +// Shared graph fixtures for the test suite (mirrors scripts/audit.ts conventions). +import { defaultEdge, defaultLaunchCfg, LAUNCH_NODE_ID, type EdgeKind, type FlowGraph, type GraphEdge, type GraphWallet } from "@/lib/graph"; + +export const W = (id: string, o: Partial = {}): GraphWallet => + ({ id, label: id.toUpperCase(), group: "core", fundingSol: 5, enabled: true, x: 0, y: 0, ...o }); + +export const E = (kind: EdgeKind, source: string, target: string, order: number, o: Partial = {}): GraphEdge => + ({ ...defaultEdge(kind, source, target, order), ...o }); + +export const G = (wallets: GraphWallet[], edges: GraphEdge[], devBuySol = 0, devWalletId?: string): FlowGraph => + ({ name: "t", devBuySol, solUsd: 75, bucketMs: 1000, launch: { ...defaultLaunchCfg(), devWalletId }, wallets, edges, sells: [] }); + +export { LAUNCH_NODE_ID }; diff --git a/tests/keystore.test.ts b/tests/keystore.test.ts new file mode 100644 index 0000000..9d13ca0 --- /dev/null +++ b/tests/keystore.test.ts @@ -0,0 +1,66 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readFileSync, rmSync } from "node:fs"; +import { encryptWallets, decryptWallets, readWalletsFile, writeWalletsFile } from "../server/keystore"; + +const sample = [{ id: "w1", label: "Wallet 1", publicKey: "PUB", secretKeyBase58: "SUPERSECRET_KEY_abcdefghijklmnopqrstuvwxyz", createdAt: "t" }]; + +test("encrypt → decrypt round-trips and never leaks plaintext", () => { + const env = encryptWallets(sample, "correct horse battery staple"); + assert.equal(env.encrypted, true); + assert.ok(!JSON.stringify(env).includes("SUPERSECRET"), "ciphertext must not contain the key"); + const back = decryptWallets(env, "correct horse battery staple"); + assert.deepEqual(back, sample); +}); + +test("wrong passphrase is rejected", () => { + const env = encryptWallets(sample, "right-pass"); + assert.throws(() => decryptWallets(env, "wrong-pass"), /decryption failed/i); +}); + +test("tampered ciphertext is rejected (GCM auth)", () => { + const env = encryptWallets(sample, "p"); + const flip = env.ciphertext[0] === "A" ? "B" : "A"; + const tampered = { ...env, ciphertext: flip + env.ciphertext.slice(1) }; + assert.throws(() => decryptWallets(tampered, "p"), /decryption failed/i); +}); + +test("file write encrypts when a passphrase is set; read round-trips", async () => { + const f = join(tmpdir(), `lily-ks-${process.pid}.json`); + process.env.LILY_KEYSTORE_PASSPHRASE = "file-pass"; + try { + await writeWalletsFile(f, sample); + const raw = readFileSync(f, "utf8"); + assert.ok(raw.includes("encrypted") && !raw.includes("SUPERSECRET"), "on-disk file must be encrypted"); + assert.deepEqual(await readWalletsFile(f), sample); + } finally { + delete process.env.LILY_KEYSTORE_PASSPHRASE; + rmSync(f, { force: true }); + } +}); + +test("legacy plaintext file still reads when no passphrase is set", async () => { + const f = join(tmpdir(), `lily-ks-plain-${process.pid}.json`); + delete process.env.LILY_KEYSTORE_PASSPHRASE; + try { + const { writeFileSync } = await import("node:fs"); + writeFileSync(f, JSON.stringify({ wallets: sample })); + assert.deepEqual(await readWalletsFile(f), sample); + } finally { + rmSync(f, { force: true }); + } +}); + +test("encrypted file without passphrase throws (not silently empty)", async () => { + const f = join(tmpdir(), `lily-ks-enc-${process.pid}.json`); + process.env.LILY_KEYSTORE_PASSPHRASE = "enc-pass"; + await writeWalletsFile(f, sample); + delete process.env.LILY_KEYSTORE_PASSPHRASE; + try { + await assert.rejects(() => readWalletsFile(f), /passphrase is not set/i); + } finally { + rmSync(f, { force: true }); + } +}); diff --git a/tests/pumpFees.test.ts b/tests/pumpFees.test.ts new file mode 100644 index 0000000..8db4398 --- /dev/null +++ b/tests/pumpFees.test.ts @@ -0,0 +1,36 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { feeBpsForMarketCapSol, GENESIS_FEE, type FeeSchedule } from "@/lib/pumpFees"; + +const sched: FeeSchedule = { + tiers: [ + { thresholdSol: 0, protocolBps: 95, creatorBps: 5 }, + { thresholdSol: 30, protocolBps: 90, creatorBps: 5 }, + { thresholdSol: 60, protocolBps: 60, creatorBps: 5 }, + ], + fallbackProtocolBps: 95, + fallbackCreatorBps: 30, +}; + +test("null schedule falls back to the verified genesis tier", () => { + assert.deepEqual(feeBpsForMarketCapSol(null, 50), { ...GENESIS_FEE }); +}); + +test("empty tiers use the schedule fallback bps", () => { + assert.deepEqual( + feeBpsForMarketCapSol({ tiers: [], fallbackProtocolBps: 95, fallbackCreatorBps: 30 }, 50), + { protocolBps: 95, creatorBps: 30 }, + ); +}); + +test("below the first threshold uses the first tier", () => { + assert.deepEqual(feeBpsForMarketCapSol(sched, 5), { protocolBps: 95, creatorBps: 5 }); +}); + +test("picks the last tier whose threshold ≤ market cap", () => { + assert.deepEqual(feeBpsForMarketCapSol(sched, 15), { protocolBps: 95, creatorBps: 5 }); + assert.deepEqual(feeBpsForMarketCapSol(sched, 30), { protocolBps: 90, creatorBps: 5 }); + assert.deepEqual(feeBpsForMarketCapSol(sched, 45), { protocolBps: 90, creatorBps: 5 }); + assert.deepEqual(feeBpsForMarketCapSol(sched, 60), { protocolBps: 60, creatorBps: 5 }); + assert.deepEqual(feeBpsForMarketCapSol(sched, 999), { protocolBps: 60, creatorBps: 5 }); +}); diff --git a/tests/reconcile.test.ts b/tests/reconcile.test.ts new file mode 100644 index 0000000..ecb04e0 --- /dev/null +++ b/tests/reconcile.test.ts @@ -0,0 +1,24 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { summarizeReconciliation } from "../server/execute"; + +test("computes per-wallet SOL deltas + token aggregates from on-chain snapshots", () => { + const opening = new Map([["a", 1.0], ["b", 2.0], ["c", 0.5]]); + const closing = [ + { id: "a", label: "A", sol: 0.5, tokens: 1000 }, // spent 0.5, holds tokens + { id: "b", label: "B", sol: 2.0, tokens: 0 }, // untouched + { id: "c", label: "C", sol: 0.9, tokens: 500 }, // received 0.4 (rotation target), holds tokens + ]; + const r = summarizeReconciliation(opening, closing); + assert.equal(r.wallets.find((w) => w.label === "A")!.solDelta, -0.5); + assert.equal(r.wallets.find((w) => w.label === "C")!.solDelta, 0.4); + assert.equal(r.netSolDelta, -0.1); // -0.5 + 0 + 0.4 + assert.equal(r.walletsWithTokens, 2); + assert.equal(r.totalTokens, 1500); +}); + +test("missing opening balance for a wallet → zero delta (no false P&L)", () => { + const r = summarizeReconciliation(new Map(), [{ id: "x", label: "X", sol: 1.23, tokens: 0 }]); + assert.equal(r.wallets[0].solDelta, 0); + assert.equal(r.netSolDelta, 0); +}); diff --git a/tests/resume.test.ts b/tests/resume.test.ts new file mode 100644 index 0000000..6cf27f3 --- /dev/null +++ b/tests/resume.test.ts @@ -0,0 +1,41 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resumeSkipSets } from "../server/execute"; + +const base = { buyWalletIds: [] as string[], rotateTargets: [] as (string | undefined)[], done: new Set(), heldIds: new Set(), singleFill: new Set() }; + +test("skips a buy recorded done in the checkpoint", () => { + const r = resumeSkipSets({ ...base, buyWalletIds: ["a", "b"], done: new Set(["buy:a"]) }); + assert.deepEqual([...r.buysToSkip], ["a"]); +}); + +test("chain backstop skips a single-fill buy that already holds tokens (crash before checkpoint flush)", () => { + const r = resumeSkipSets({ ...base, buyWalletIds: ["a", "b"], heldIds: new Set(["a"]), singleFill: new Set(["a", "b"]) }); + assert.deepEqual([...r.buysToSkip], ["a"]); +}); + +test("does NOT chain-skip a MULTI-fill wallet that holds tokens (dev launch-buy + follow buy)", () => { + // dev holds tokens from the launch buy but is NOT single-fill → its follow buy must still run + const r = resumeSkipSets({ ...base, buyWalletIds: ["dev"], heldIds: new Set(["dev"]), singleFill: new Set() }); + assert.equal(r.buysToSkip.size, 0); +}); + +test("does NOT chain-skip a spine-buyer that is also a rotation target", () => { + // w holds tokens but has 2 fills (buy + rotation) → only `done` may skip it + const r = resumeSkipSets({ ...base, buyWalletIds: ["w"], rotateTargets: ["w"], heldIds: new Set(["w"]), singleFill: new Set() }); + assert.equal(r.buysToSkip.size, 0); + assert.equal(r.rotateIdxToSkip.size, 0); +}); + +test("skips a rotation recorded done, and a single-fill held target", () => { + const r = resumeSkipSets({ ...base, rotateTargets: ["x", "y", "z"], done: new Set(["rotate:0"]), heldIds: new Set(["z"]), singleFill: new Set(["z"]) }); + assert.ok(r.rotateIdxToSkip.has(0)); // by checkpoint + assert.ok(r.rotateIdxToSkip.has(2)); // by chain (single-fill) + assert.ok(!r.rotateIdxToSkip.has(1)); +}); + +test("nothing done / nothing held → nothing skipped (full plan re-runs)", () => { + const r = resumeSkipSets({ ...base, buyWalletIds: ["a"], rotateTargets: ["b"] }); + assert.equal(r.buysToSkip.size, 0); + assert.equal(r.rotateIdxToSkip.size, 0); +}); diff --git a/tests/rpc.test.ts b/tests/rpc.test.ts new file mode 100644 index 0000000..14b4a3b --- /dev/null +++ b/tests/rpc.test.ts @@ -0,0 +1,18 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseRpcUrls } from "../server/execute"; + +test("merges env CSV + file lines, trims, dedupes", () => { + assert.deepEqual( + parseRpcUrls("https://a , https://b", "https://b\nhttps://c\n"), + ["https://a", "https://b", "https://c"], + ); +}); + +test("ignores blank lines and whitespace", () => { + assert.deepEqual(parseRpcUrls("", " \n https://x \n\n"), ["https://x"]); +}); + +test("defaults to public mainnet when nothing is configured", () => { + assert.deepEqual(parseRpcUrls("", ""), ["https://api.mainnet-beta.solana.com"]); +}); diff --git a/tests/sim.test.ts b/tests/sim.test.ts new file mode 100644 index 0000000..e574eae --- /dev/null +++ b/tests/sim.test.ts @@ -0,0 +1,49 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createInitialPumpState, executePumpTradeIntent, rawToTokenUi, lamportsToSol } from "@/lib/pumpSimulationMath"; +import { runGraph } from "@/lib/graph"; +import type { FeeSchedule } from "@/lib/pumpFees"; +import { W, E, G, LAUNCH_NODE_ID } from "./helpers"; + +const buy = (sol: number) => ({ side: "buy" as const, sol, buySolMode: "wallet-gross" as const, protocolFeeBps: 95, creatorFeeBps: 30 }); + +test("a buy mints tokens and reduces real token reserves", () => { + const s0 = createInitialPumpState(); + const ex = executePumpTradeIntent(s0, buy(1)); + assert.ok(ex.tokenRaw > 0n); + assert.ok(ex.next.realTokenRaw < s0.realTokenRaw); +}); + +test("higher fees → fewer tokens for the same SOL (fee wiring is real)", () => { + const s0 = createInitialPumpState(); + const lo = executePumpTradeIntent(s0, { ...buy(1), protocolFeeBps: 95 }); + const hi = executePumpTradeIntent(s0, { ...buy(1), protocolFeeBps: 2000 }); + assert.ok(hi.tokenRaw < lo.tokenRaw); +}); + +test("a graduation-crossing buy is clamped: caps tokens + charges only actual SOL", () => { + const s0 = createInitialPumpState(); + const ex = executePumpTradeIntent(s0, buy(1000)); // way past ~85 SOL graduation + assert.ok(ex.tokenRaw <= s0.realTokenRaw, "cannot buy more than the real reserves"); + assert.equal(ex.next.complete, true); + assert.ok(lamportsToSol(ex.walletSolLamports) < 100, "wallet charged ~graduation cost, not 1000 SOL"); +}); + +test("buy then sell-all restores the curve's real token reserves exactly (reversible)", () => { + const s0 = createInitialPumpState(); + const b = executePumpTradeIntent(s0, buy(1)); + const s1 = b.next; + const sell = executePumpTradeIntent(s1, { side: "sell", tokenAmount: rawToTokenUi(b.tokenRaw), protocolFeeBps: 95, creatorFeeBps: 30 }); + assert.equal(sell.next.realTokenRaw, s0.realTokenRaw); +}); + +test("runGraph is deterministic (same input → identical candles)", () => { + const g = G([W("dev"), W("a"), W("b")], [E("buy", LAUNCH_NODE_ID, "a", 0, { sol: 1 }), E("buy", "a", "b", 1, { sol: 1 })], 0.5, "dev"); + assert.deepEqual(runGraph(g).candles, runGraph(g).candles); +}); + +test("a fee schedule threads through runGraph (changes the curve path)", () => { + const g = G([W("dev"), W("a"), W("b")], [E("buy", LAUNCH_NODE_ID, "a", 0, { sol: 1 }), E("buy", "a", "b", 1, { sol: 1 })], 0.5, "dev"); + const hi: FeeSchedule = { tiers: [{ thresholdSol: 0, protocolBps: 2000, creatorBps: 100 }], fallbackProtocolBps: 95, fallbackCreatorBps: 30 }; + assert.notDeepEqual(runGraph(g, null).candles, runGraph(g, hi).candles); +}); diff --git a/tests/spendcap.test.ts b/tests/spendcap.test.ts new file mode 100644 index 0000000..50017a8 --- /dev/null +++ b/tests/spendcap.test.ts @@ -0,0 +1,20 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { planSpendEstimate } from "../server/execute"; + +test("sums dev buy + all follow buys", () => { + assert.equal(planSpendEstimate(1, [{ walletId: "a", sol: 0.5 }, { walletId: "b", sol: 0.5 }], []), 2); +}); + +test("counts rotation re-buys as an upper bound (so the cap can't be bypassed via relays)", () => { + // a buys 1, then rotates 100% → +1 upper-bound spend through the curve + assert.equal(planSpendEstimate(0, [{ walletId: "a", sol: 1 }], [{ walletId: "a", sellPct: 100 }]), 2); +}); + +test("partial rotation scales the estimate by sellPct", () => { + assert.equal(planSpendEstimate(0, [{ walletId: "a", sol: 1 }], [{ walletId: "a", sellPct: 50 }]), 1.5); +}); + +test("defaults sellPct to 100 and coerces junk to 0", () => { + assert.equal(planSpendEstimate("x", [{ walletId: "a", sol: 2 }], [{ walletId: "a" }]), 4); +}); diff --git a/tests/validate.test.ts b/tests/validate.test.ts new file mode 100644 index 0000000..238ec6d --- /dev/null +++ b/tests/validate.test.ts @@ -0,0 +1,55 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { validateGraph } from "@/lib/validate"; +import { spineEdgeIds } from "@/lib/graph"; +import { W, E, G, LAUNCH_NODE_ID } from "./helpers"; + +// spine: launch→a→b ; sub-branch off a rotate: a→c (rotate), c→d (buy) +function branchy() { + return G( + [W("a"), W("b"), W("c"), W("d")], + [ + E("buy", LAUNCH_NODE_ID, "a", 0, { sol: 1 }), + E("buy", "a", "b", 1, { sol: 1 }), + E("rotate", "a", "c", 2, { sellPct: 100 }), + E("buy", "c", "d", 3, { sol: 1 }), + ], + ); +} + +test("spineEdgeIds = the buy chain from Launch, excluding sub-branch buys", () => { + const g = branchy(); + const spine = spineEdgeIds(g); + const launchA = g.edges.find((e) => e.source === LAUNCH_NODE_ID)!; + const ab = g.edges.find((e) => e.source === "a" && e.kind === "buy")!; + const cd = g.edges.find((e) => e.source === "c")!; + assert.ok(spine.has(launchA.id) && spine.has(ab.id)); + assert.ok(!spine.has(cd.id), "sub-branch buy is NOT on the spine"); +}); + +test("bundling a sub-branch buy is blocked (Jito bundles are spine-only)", () => { + const g = branchy(); + const cd = g.edges.find((e) => e.source === "c")!; + cd.bundleId = "bx"; + const v = validateGraph(g); + assert.ok(v.issues.some((i) => i.code === "bundle-offspine" && i.edgeId === cd.id)); + assert.equal(v.edge.get(cd.id), "block"); +}); + +test("bundling consecutive spine buys is allowed (no offspine block)", () => { + const g = branchy(); + const launchA = g.edges.find((e) => e.source === LAUNCH_NODE_ID)!; + const ab = g.edges.find((e) => e.source === "a" && e.kind === "buy")!; + launchA.bundleId = "bx"; + ab.bundleId = "bx"; + const v = validateGraph(g); + assert.ok(!v.issues.some((i) => i.code === "bundle-offspine")); +}); + +test("bundling a rotate is blocked", () => { + const g = branchy(); + const rot = g.edges.find((e) => e.kind === "rotate")!; + rot.bundleId = "bx"; + const v = validateGraph(g); + assert.ok(v.issues.some((i) => i.code === "rotate-bundled")); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..b4398eb --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.server.json", + "include": ["tests"] +}