UNPKG

solana-market-id

Version:

Find Serum v1/v3 market IDs for a base/quote mint pair and inspect market metadata/L2 on Solana.

496 lines (495 loc) 21.4 kB
#!/usr/bin/env node "use strict"; /** * ============================================================================ * solana-market-id — CLI (TypeScript, Node ≥ 18) * ============================================================================ * * Find Serum v1/v3 market IDs for a base/quote token mint pair and inspect * market metadata (mints, bids/asks/event queue/vaults) including aggregated * top-of-book (L2) and optional full-book totals. * * Fast path: RPC-only using Serum’s official account layout (dataSize + memcmp). * Optional: Helius-assisted discovery with robust paging + per-pair RPC fallback. * * Tip jar: 88888kfJ3SRayhz1dHj9msNm99G5iVZ7FBN8MRjhEdK8 * * ---------------------------------------------------------------------------- * USAGE * # RPC-only (fast exact filters) * solana-market-id <BASE_MINT> --quotes <QUOTE1,QUOTE2,...> --program <serum1|serum3|all> [--rpc "<URL>"] [--json] [--no-fallback] * * # Helius-assisted (optionally, with RPC fallback per pair) * solana-market-id <BASE_MINT> --quotes <QUOTE1,QUOTE2,...> --program serum1 --use-helius --api-key <KEY> [--pages 40] [--rpc "<URL>"] * * # Inspect a known market * solana-market-id --known-market <MARKET_ID> [--depth 10] [--totals|--total-buy|--total-sell] [--rpc "<URL>"] [--no-fallback] * * # Tests * solana-market-id --test1 # base=9kt93A... vs WSOL * solana-market-id --test2 # base=9kt93A... vs USDC * ============================================================================ */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const web3_js_1 = require("@solana/web3.js"); const serum_1 = require("@project-serum/serum"); const node_fetch_1 = __importDefault(require("node-fetch")); require("dotenv/config"); /* ========= Program IDs ========= */ const SERUM_V1 = envPk("SERUM_V1_PROGRAM_ID", "srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX"); const SERUM_V3 = envPk("SERUM_V3_PROGRAM_ID", "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZbG8SxwP3E"); const OPENBOOK_V2 = envPk("OPENBOOK_V2_PROGRAM_ID", "opnb2LAfJYbRMAHHvqjCwQxanZn7ReEHp1k81EohpZb"); // inspector stub /* ========= RPC list ========= */ const RPCS = [ (process.env.RPC_URL || "").trim(), ...(process.env.RPC_URLS ? process.env.RPC_URLS.split(",").map((s) => s.trim()) : []), "https://api.mainnet-beta.solana.com", ].filter(Boolean); /* ========= Utils ========= */ function envPk(name, fallback) { const v = process.env[name]; try { return new web3_js_1.PublicKey(v || fallback); } catch { return new web3_js_1.PublicKey(fallback); } } function die(m) { console.error(`\n❌ ${m}`); process.exit(1); } function log(s = "") { console.log(s); } function toPk(s, label) { try { return new web3_js_1.PublicKey(String(s).trim()); } catch (e) { die(`Invalid ${label}: ${e?.message || e}`); } } function contains(hay, needle) { for (let i = 0; i <= hay.length - needle.length; i++) { let ok = true; for (let j = 0; j < needle.length; j++) { if (hay[i + j] !== needle[j]) { ok = false; break; } } if (ok) return true; } return false; } /* Accurate per-price-level key + counts from raw orderbook */ function priceKey(n) { return Number(n).toPrecision(12); } function levelCountsFromBook(bookIter) { const map = new Map(); for (const o of bookIter) { const p = Number(o.price); const s = Number(o.size); if (!Number.isFinite(p) || !Number.isFinite(s)) continue; const k = priceKey(p); const cur = map.get(k) || { count: 0, size: 0 }; cur.count += 1; cur.size += s; map.set(k, cur); } return map; } /** Create a connection honoring --no-fallback. */ async function getConn(override, opts = {}) { const { noFallback = false } = opts; if (noFallback) { const url = override || RPCS[0]; if (!url) die("No RPC configured. Use --rpc or set RPC_URL."); try { const c = new web3_js_1.Connection(url, "confirmed"); await c.getVersion(); return { connection: c, rpcUrl: url }; } catch (e) { die(`Failed RPC ${url} (no-fallback): ${e?.message || e}`); } } const list = override ? [override, ...RPCS] : RPCS; if (!list.length) die("No RPC configured. Use --rpc or set RPC_URL."); for (const url of list) { try { const c = new web3_js_1.Connection(url, "confirmed"); await c.getVersion(); return { connection: c, rpcUrl: url }; } catch (e) { console.error(`❌ Failed RPC ${url}: ${e?.message || e}`); } } die("No healthy RPC found."); } /* ========= Entry ========= */ (async function main() { const [, , ...args] = process.argv; const has = (...n) => args.some((a) => n.includes(a)); const val = (...n) => { const i = args.findIndex((a) => n.includes(a)); return i >= 0 ? args[i + 1] : undefined; }; if (has("--help", "-h") || args.length === 0) { console.log(String.raw ` Usage: solana-market-id <BASE_MINT> --quotes <M1,M2,...> --program <serum1|serum3|all> [--rpc "<URL>"] [--json] [--no-fallback] solana-market-id <BASE_MINT> --quotes <M1,M2,...> --program serum1 --use-helius --api-key <KEY> [--pages 40] [--rpc "<URL>"] solana-market-id --known-market <MARKET_ID> [--depth 10] [--totals|--total-buy|--total-sell] [--rpc "<URL>"] [--no-fallback] solana-market-id --test1 | --test2 `); return; } const rpcOverride = val("--rpc", "-r"); const jsonOut = has("--json"); const programSel = (val("--program", "-P") || "all").toLowerCase(); // all|serum1|serum3 const useHelius = has("--use-helius", "-H"); const apiKey = val("--api-key", "-k") || process.env.HELIUS_API_KEY; const pages = Math.max(1, parseInt(val("--pages", "-pg") || "20", 10)); const depth = Math.max(1, parseInt(val("--depth", "-d") || "10", 10)); const totalsBoth = has("--totals", "-T"); const totalsBuy = has("--total-buy", "-tb"); const totalsSell = has("--total-sell", "-ts"); const noFallback = has("--no-fallback"); /* Known market inspector */ const knownMarketArg = val("--known-market", "-km"); if (knownMarketArg) { const { connection, rpcUrl } = await getConn(rpcOverride, { noFallback }); log(`✅ RPC: ${rpcUrl}`); await inspectKnownMarket(connection, toPk(knownMarketArg, "MARKET_ID"), depth, { totalsBuy: totalsBoth || totalsBuy, totalsSell: totalsBoth || totalsSell, }); return; } /* Inputs for discovery */ let tokenArg = args.find((a, i) => !a.startsWith("-") && args[i - 1] !== "--rpc" && args[i - 1] !== "-r"); let quotesCsv = val("--quotes", "-q"); if (has("--test1")) { tokenArg = "9kt93AW5QMjFL6ZxomnSq3FbWiU5ibNeTSgBz9UDFSB6"; // your token quotesCsv = "So11111111111111111111111111111111111111112"; // WSOL console.log("Running Test #1 (your token vs WSOL)..."); } else if (has("--test2")) { tokenArg = "9kt93AW5QMjFL6ZxomnSq3FbWiU5ibNeTSgBz9UDFSB6"; // your token quotesCsv = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; // USDC console.log("Running Test #2 (your token vs USDC)..."); } if (!tokenArg) die("Missing <BASE_MINT>. Pass a base mint or use --test1/--test2."); const TOKEN = toPk(tokenArg, "TOKEN_MINT"); const QUOTES = quotesCsv ? quotesCsv.split(",").map((s) => toPk(s, "QUOTE_MINT")) : [toPk("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "USDC"), toPk("So11111111111111111111111111111111111111112", "WSOL")]; const { connection, rpcUrl } = await getConn(rpcOverride, { noFallback }); log(`✅ Connected RPC: ${rpcUrl}`); log(`Token: ${TOKEN.toBase58()}`); log(`Quotes: ${QUOTES.map((q) => q.toBase58()).join(", ")}`); log(`Method: ${useHelius ? "Helius-assisted (+RPC fallback)" : "RPC-only"}`); log("-----------------------------------------"); const want1 = programSel === "all" || programSel === "serum1"; const want3 = programSel === "all" || programSel === "serum3"; const results = new Map(); const push = (pid, name, market, pair) => results.set(`${pid.toBase58()}:${market}`, { program: name, programId: pid.toBase58(), market, pair }); for (const q of QUOTES) { const fwd = `${TOKEN.toBase58()}/${q.toBase58()}`; const rev = `${q.toBase58()}/${TOKEN.toBase58()}`; const before = results.size; if (useHelius) { if (!apiKey) die("Missing Helius key. Use --api-key <KEY> or set HELIUS_API_KEY."); if (want1) { for (const mk of await heliusDiscover(connection, SERUM_V1, TOKEN, q, apiKey, pages)) push(SERUM_V1, "Serum v1", mk, fwd); for (const mk of await heliusDiscover(connection, SERUM_V1, q, TOKEN, apiKey, pages)) push(SERUM_V1, "Serum v1", mk, rev); } if (want3) { for (const mk of await heliusDiscover(connection, SERUM_V3, TOKEN, q, apiKey, pages)) push(SERUM_V3, "Serum v3", mk, fwd); for (const mk of await heliusDiscover(connection, SERUM_V3, q, TOKEN, apiKey, pages)) push(SERUM_V3, "Serum v3", mk, rev); } if (results.size === before) console.log("(info) Helius returned no matches; falling back to RPC for this pair…"); } if (want1) { for (const mk of await findSerumByLayout(connection, SERUM_V1, TOKEN, q)) push(SERUM_V1, "Serum v1", mk, fwd); for (const mk of await findSerumByLayout(connection, SERUM_V1, q, TOKEN)) push(SERUM_V1, "Serum v1", mk, rev); } if (want3) { for (const mk of await findSerumByLayout(connection, SERUM_V3, TOKEN, q)) push(SERUM_V3, "Serum v3", mk, fwd); for (const mk of await findSerumByLayout(connection, SERUM_V3, q, TOKEN)) push(SERUM_V3, "Serum v3", mk, rev); } } const out = Array.from(results.values()); if (jsonOut) console.log(JSON.stringify({ results: out }, null, 2)); else { log("\n--- Results ---"); if (!out.length) log("No markets found."); for (const r of out) log(`- ${r.program} (${r.programId}) Market: ${r.market} Pair: ${r.pair}`); } })().catch((e) => die(e?.message || String(e))); /* ========= Helius program scan ========= */ async function heliusDiscover(connection, programId, base, quote, apiKey, maxPages = 20) { let before; const base58 = base.toBase58(), quote58 = quote.toBase58(); for (let page = 0; page < maxPages; page++) { const url = new URL(`https://api.helius.xyz/v0/addresses/${programId.toBase58()}/transactions`); url.searchParams.set("api-key", apiKey); url.searchParams.set("source", "SERUM"); url.searchParams.set("limit", "100"); if (before) url.searchParams.set("before", String(before)); const resp = await (0, node_fetch_1.default)(url.toString()); if (!resp.ok) { // Some 404 bodies include a usable "before" token; try to harvest it let nextBefore; try { const txt = await resp.text(); const cand = txt.match(/[1-9A-HJ-NP-Za-km-z]{43,88}/g); if (cand && cand.length) nextBefore = cand[cand.length - 1]; } catch { } if (resp.status === 404 && nextBefore) { before = nextBefore; continue; } return []; // let caller fallback to RPC } const txs = (await resp.json()); if (!Array.isArray(txs) || txs.length === 0) break; for (const tx of txs) { const top = Array.isArray(tx.instructions) ? tx.instructions : []; const inner = []; for (const ins of top) if (Array.isArray(ins.innerInstructions)) inner.push(...ins.innerInstructions); const all = [...top, ...inner]; // Hints from token transfers / swap events const mints = new Set(); for (const t of tx.tokenTransfers ?? []) if (t?.mint) mints.add(String(t.mint)); const swap = tx?.events?.swap; for (const ti of swap?.tokenInputs ?? []) if (ti?.mint) mints.add(String(ti.mint)); // <-- fixed for (const to of swap?.tokenOutputs ?? []) if (to?.mint) mints.add(String(to.mint)); // <-- fixed const maybePair = mints.has(base58) && mints.has(quote58); for (const ins of all) { if (!ins?.programId || ins.programId !== programId.toBase58()) continue; const accs = Array.isArray(ins.accounts) ? ins.accounts.map(String) : []; const marketCand = accs[0]; if (!marketCand) continue; try { const info = await connection.getAccountInfo(new web3_js_1.PublicKey(marketCand)); if (!info || !info.owner.equals(programId)) continue; const buf = info.data; if (contains(buf, base.toBytes()) && contains(buf, quote.toBytes())) { return [marketCand]; } if (!maybePair) continue; } catch { } } } const last = txs[txs.length - 1]; before = last?.signature || last?.transaction?.signatures?.[0] || before; } return []; } /* ========= Serum RPC exact (official layout) ========= */ async function findSerumByLayout(connection, programId, baseMint, quoteMint) { const layout = serum_1.Market.getLayout(programId); const baseOff = layout.offsetOf("baseMint"); const quoteOff = layout.offsetOf("quoteMint"); const span = layout.span; const accts = await connection.getProgramAccounts(programId, { filters: [ { dataSize: span }, { memcmp: { offset: baseOff, bytes: baseMint.toBase58() } }, { memcmp: { offset: quoteOff, bytes: quoteMint.toBase58() } } ] }); return accts.map(({ pubkey }) => pubkey.toBase58()); } /* ========= Known-market inspector (Serum v1/v3) ========= */ async function inspectKnownMarket(connection, marketPk, depth = 10, totals) { const info = await connection.getAccountInfo(marketPk); if (!info) { log("❌ No account for that pubkey."); return; } const owner = info.owner; log(`Market: ${marketPk.toBase58()}`); log(`Owner Program: ${owner.toBase58()}`); if (owner.equals(SERUM_V1) || owner.equals(SERUM_V3)) { try { const market = await serum_1.Market.load(connection, marketPk, {}, owner); log("\n--- Metadata ---"); log(`Base Mint: ${market.baseMintAddress.toBase58()}`); log(`Quote Mint: ${market.quoteMintAddress.toBase58()}`); const bidsPk = market.bidsAddress || market._decoded?.bids; const asksPk = market.asksAddress || market._decoded?.asks; const eventQPk = market.eventQueueAddress || market._decoded?.eventQueue; const reqQPk = market._decoded?.requestQueue; const baseVault = market._decoded?.baseVault; const quoteVault = market._decoded?.quoteVault; if (bidsPk) log(`Bids: ${bidsPk.toBase58()}`); if (asksPk) log(`Asks: ${asksPk.toBase58()}`); if (eventQPk) log(`Event Queue: ${eventQPk.toBase58()}`); if (baseVault) log(`Base Vault: ${baseVault.toBase58()}`); if (quoteVault) log(`Quote Vault: ${quoteVault.toBase58()}`); const bidsBook = await market.loadBids(connection); const asksBook = await market.loadAsks(connection); const bidLevelCounts = levelCountsFromBook(bidsBook); const askLevelCounts = levelCountsFromBook(asksBook); let bidsCount = 0; for (const _ of bidsBook) bidsCount++; let asksCount = 0; for (const _ of asksBook) asksCount++; // Try to read queue sizes (best-effort) let eventCount; try { if (typeof market.loadEventQueue === "function") { const events = await market.loadEventQueue(connection); if (Array.isArray(events)) eventCount = events.length; } } catch { } if (eventCount === undefined && eventQPk) { const eqInfo = await connection.getAccountInfo(eventQPk); if (eqInfo) { const d = eqInfo.data; try { eventCount = d.readUInt32LE(4); } catch { } log(`Event Queue Bytes: ${d.length}`); } } let reqCount; if (reqQPk) { const rqInfo = await connection.getAccountInfo(reqQPk); if (rqInfo) { const d = rqInfo.data; try { reqCount = d.readUInt32LE(4); } catch { } log(`Request Queue Bytes: ${d.length}`); } } log("\n--- Lengths (live) ---"); if (eventCount !== undefined) log(`Event Queue Length: ${eventCount}`); if (reqCount !== undefined) log(`Request Queue Length: ${reqCount}`); log(`Bids Length: ${bidsCount}`); log(`Asks Length: ${asksCount}`); // Aggregated L2 with TRUE order counts per price level const l2b = bidsBook.getL2(depth) || []; const l2a = asksBook.getL2(depth) || []; const printL2 = (levels, label, counts) => { log(`\n--- Top ${depth} ${label} [price, size, orders] ---`); if (!levels.length) { log("(no orders)"); return; } for (const row of levels) { const price = Number(row[0]); const size = Number(row[1]); const key = priceKey(price); const count = counts.get(key)?.count ?? 1; log(`${price}\t${size}\t(${count})`); } }; printL2(l2b, "Bids", bidLevelCounts); printL2(l2a, "Asks", askLevelCounts); // Full-book totals const fmt = (n, d = 6) => (Number.isFinite(n) ? n.toFixed(d) : String(n)); if (totals?.totalsBuy) { let totSize = 0, totNotional = 0; for (const o of bidsBook) { const price = Number(o.price); const size = Number(o.size); if (Number.isFinite(price) && Number.isFinite(size)) { totSize += size; totNotional += price * size; } } log(`\n--- Bids Totals (full book) ---`); log(`Total Size: ${fmt(totSize)}`); log(`Total Notional: ${fmt(totNotional)}`); } if (totals?.totalsSell) { let totSize = 0, totNotional = 0; for (const o of asksBook) { const price = Number(o.price); const size = Number(o.size); if (Number.isFinite(price) && Number.isFinite(size)) { totSize += size; totNotional += price * size; } } log(`\n--- Asks Totals (full book) ---`); log(`Total Size: ${fmt(totSize)}`); log(`Total Notional: ${fmt(totNotional)}`); } } catch (e) { log(`\n(warn) Serum decode failed: ${e?.message || e}`); log(`Data length: ${info.data.length}`); } return; } if (owner.equals(OPENBOOK_V2)) { log("\n(OpenBook v2) Depth decoding not implemented yet; printing raw basics:"); log(`Data length: ${info.data.length}`); return; } log("\nUnknown DEX program. Printing raw basics:"); log(`Data length: ${info.data.length}`); }