If you have tried to watch Solana DEX trades directly, you know the shape of the problem. Every venue is its own program with its own instruction layout. A swap routed through an aggregator shows up as an inner instruction three levels deep. pump.fun, PumpSwap, Raydium (three different AMMs), Orca, Meteora (four programs) and the launchpads all describe a trade differently, and some report amounts only in logs that the runtime truncates on busy transactions. Before you can ask "what was just bought, how much, at what price", you need a decoder per program.
This post skips all of that. We'll connect to a WebSocket that already does the decoding and print every swap on 12 Solana venues as one normalized JSON object.
What you need
- Python 3.9+ and
pip install "websockets>=13" - An API key for the Lunar Shadow Labs feed (3-day free trial, then $49/month). Your key is on your account page.
export LSL_KEY=<your API key>
The code
import asyncio, json, os
from websockets.asyncio.client import connect
URL = "wss://feed.lunarshadowlabs.com/ws?kinds=swap"
def ui(amount, decimals):
"""Amounts are strings of raw integer units; scale them with the matching *_decimals field."""
return int(amount) / 10**decimals if decimals is not None else None
async def main():
headers = {"Authorization": f"Bearer {os.environ['LSL_KEY']}"}
async with connect(URL, additional_headers=headers, compression=None, max_size=None) as ws:
async for raw in ws:
m = json.loads(raw)
if m["kind"] == "swap":
side = "buy " if m["is_buy"] else "sell"
size = ui(m["base_amount"], m["base_decimals"])
print(f"{m['venue']:<15} {side} {size} {m['base_mint']} @ {m['price']}")
elif m["kind"] == "gap": # the only indication that data was missed
print("gap:", m["reason"], m.get("missed"))
asyncio.run(main())
Run it and trades start scrolling:
pumpswap buy 915407.038388 FKB4cff48QyEyKDkPdmqV3AAUEZKZJPWnAGCEcxmpump @ 5.462051077086785e-07
raydium_cpmm sell 42678.171662 CCVYpFakBd4kkwn7G4rvbpaiB5pGJwUpiNwNYrkC5ygQ @ 0.0005660472569285295
orca buy 70.27478926 J3NKxxXZcnNiMjKw9hYb2K4LUxgwB6t1FtPtQVsv3KFr @ 0.003511809620473275
What's in a swap
Here is one PumpSwap buy, trimmed:
{"v":1,"seq":1918,"kind":"swap","venue":"pumpswap",
"keys":["FKB4…pump","So111…112","HWkc…yPUn","AYt7…8pT3"],
"slot":453800890,"sig":"5Zd4…bQwgo","commitment":"processed",
"pool":"HWkc…yPUn","user":"AYt7…8pT3",
"base_mint":"FKB4…pump","quote_mint":"So11111111111111111111111111111111111111112",
"is_buy":true,"base_amount":"915407038388","quote_amount":"500000000",
"base_decimals":6,"quote_decimals":9,"price":0.0000005462051077086785,
"base_flags":{"program":"token2022","decimals":6,"mint_authority":null,"freeze_authority":null, …},
"x":{"pool_base_token_reserves":"182354455676307","pool_quote_token_reserves":"80346014470", …}}
A few design choices worth knowing:
-
Base and quote are normalized. On launchpads the launched token is the base. On AMMs the quote is the first of USDC, USDT or SOL present in the pool. So
is_buyalways means "the user received the base token", whichever way round the pool stores its mints. -
Amounts are strings. Raw token amounts overflow JavaScript's safe integers and lose precision as floats, so they arrive as integer strings. Scale with
base_decimals/quote_decimals.price(quote per whole base token) is a float, and it isnullwhen an input is unknown, never a guess. -
keysis[base, quote, pool, actor]on every data event, so one lookup works for swaps, launches and liquidity changes alike. -
Token-2022 flags ride along.
base_flags/quote_flagssay whether the mint has a freeze authority, a transfer fee, a transfer hook, a permanent delegate and so on. Useful when a token's transfers can be taxed or frozen.
Filter on the server
The full stream is a few hundred messages per second. Filters run server-side, so you only pay bandwidth for what you ask for:
?kinds=swap,launch # trades and new tokens only
?venues=pump,pumpswap # just the pump.fun curve and its AMM
?watch=<mint, pool or wallet> # follow specific addresses; a wallet matches every event it is the actor of
Other kinds: curve_complete, migration, pool_create, liquidity, mint_update.
Don't lose data on reconnect
Every message has a seq. If your connection drops, reconnect with ?since=<epoch>:<last seq> (the epoch comes from the hello message on connect) and the server replays what you missed from its buffer. If it can't, you get a gap message saying exactly which range is gone. Data never goes missing silently: anything the server can't replay is announced.
You can write that loop yourself, or use the client package, which does it for you:
pip install sol-feed
import asyncio, os
from sol_feed import Feed, ui_amount
async def main():
async for m in Feed(os.environ["LSL_KEY"], kinds=["swap"], venues=["pump", "pumpswap"]):
if m["kind"] == "gap":
print("gap:", m["reason"], m["missed"])
continue
print(m["venue"], "buy" if m["is_buy"] else "sell",
ui_amount(m["base_amount"], m["base_decimals"]), m["base_mint"], m["price"])
asyncio.run(main())
It reconnects with backoff, resumes from the last message it gave you, and raises a clear error for a bad key instead of retrying forever. There's an npm package too (npm install sol-feed), and TypeScript and Rust versions of the 20-line example are in the examples repo.
Where to go next
- Full message reference: https://lunarshadowlabs.com/docs/
- Guides (pump.fun migrations, Raydium LaunchLab launches, Meteora DLMM pools, Token-2022 flags): https://lunarshadowlabs.com/guides/
- Examples and client source (MIT): https://github.com/lunarshadowlabs/sol-feed-examples
The feed is market data only: it tells you what happened on chain, decoded. What you build on top of it is up to you.
Top comments (0)