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
JavaScript
;
/**
* ============================================================================
* 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}`);
}