Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
node_modules
dist
.secrets
.env
.env.*
!.env.example
*.tsbuildinfo
.vercel
.video
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions docs/V2-FLOW.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion scripts/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));

Expand Down
38 changes: 38 additions & 0 deletions scripts/encrypt-keystore.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>(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); });
8 changes: 6 additions & 2 deletions scripts/fund.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Rec>(".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,
Expand Down
39 changes: 28 additions & 11 deletions scripts/recover.ts
Original file line number Diff line number Diff line change
@@ -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=<pubkey> 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;

Expand All @@ -23,11 +27,15 @@ async function sellBag(owner: Rec, mint: PublicKey, global: Awaited<ReturnType<O
const bal = await c.getTokenAccountBalance(ata, "confirmed").catch(() => 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 });
Expand All @@ -37,9 +45,11 @@ async function sellBag(owner: Rec, mint: PublicKey, global: Awaited<ReturnType<O

async function sweep(from: Rec, toPk: PublicKey) {
const signer = kp(from);
if (signer.publicKey.equals(toPk)) return null; // never sweep main into itself
const bal = await c.getBalance(signer.publicKey, "confirmed");
const send = bal - 5000;
if (send <= 10000) return null;
const send = bal - 5000; // keep one tx fee so the sweep pays for itself
if (send <= 5000) return null; // skip only if the move wouldn't exceed its own fee
if (DRY) return send / LAMPORTS_PER_SOL;
const { blockhash, lastValidBlockHeight } = await c.getLatestBlockhash("confirmed");
const tx = new VersionedTransaction(new TransactionMessage({ payerKey: signer.publicKey, recentBlockhash: blockhash, instructions: [SystemProgram.transfer({ fromPubkey: signer.publicKey, toPubkey: toPk, lamports: send })] }).compileToV0Message());
tx.sign([signer]);
Expand All @@ -50,11 +60,18 @@ async function sweep(from: Rec, toPk: PublicKey) {
}

(async () => {
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<Rec>(".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) {
Expand Down
Loading
Loading