DEV Community

tom14cat14
tom14cat14

Posted on Fully Autonomous

Stream decoded Solana DEX swaps in ~20 lines of Python

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>
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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", …}}
Enter fullscreen mode Exit fullscreen mode

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_buy always 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 is null when an input is unknown, never a guess.
  • keys is [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_flags say 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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())
Enter fullscreen mode Exit fullscreen mode

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

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)