1. Guides

Index swaps across DEXs

Turn Raydium CPMM, PumpSwap and Meteora DLMM swaps into one record with the amounts that executed, read from each program's events, in one pipeline.

On this page

Every DEX describes a swap in its own types, but each one also records the amounts that executed in an event. This guide indexes swaps from Raydium CPMM, PumpSwap and Meteora DLMM into one Swap record in a single pipeline, using each program's events. The same pattern extends to any program in the DEX and AMM decoders.

You need: Rust 1.96.1 or newer. The example uses Solana's public RPC, so no account is needed.

Where each DEX records a swap#

DEXEventHow it is emittedPoolAmount inAmount out
Raydium CPMMSwapEventemit!, a log linepool_idinput_amountoutput_amount
PumpSwapBuyEvent, SellEventemit_cpi!, a self-CPIpoolquote_amount_in (buy), base_amount_in (sell)base_amount_out (buy), quote_amount_out (sell)
Meteora DLMMSwapemit_cpi!, a self-CPIlb_pairamount_inamount_out

Read events rather than instruction arguments. The arguments are limits the trader set: Raydium CPMM's SwapBaseInput carries amount_in and minimum_amount_out, and SwapBaseOutput carries max_amount_in and amount_out. The events hold what actually moved.

Anchor programs emit events in one of two ways, and you read them differently:

  • emit_cpi! makes the program call itself with the event as data. The decoder returns it as the CpiEvent instruction, so it reaches your processor like any other instruction. PumpSwap and Meteora DLMM do this.
  • emit! writes the event to the transaction's log as a Program data: line. Nothing calls your processor for it: decode it from the swap instruction's logs with decode_log_events. Raydium CPMM does this, so its CpiEvent never arrives.

If a program's CpiEvent never shows up, look for Program data: lines in one of its transactions: it logs its events.

Create a project#

Terminal
cargo new dex-swaps
cd dex-swaps
cargo add carbon-core@2.0.0 carbon-rpc-transaction-crawler-datasource@2.0.0 carbon-raydium-cpmm-decoder@2.0.0 carbon-pump-swap-decoder@2.0.0 carbon-meteora-dlmm-decoder@2.0.0 carbon-log-metrics@2.0.0 solana-pubkey@4.2.1
cargo add tokio --features full
cargo add env_logger borsh

borsh is for the small wrapper that lets decode_log_events read Raydium CPMM's logged event.

Write the indexer#

Three RPC transaction crawlers, one per program, feed one pipeline. One SwapIndexer implements a processor for each program's instructions and turns that program's swap event into the shared Swap record.

src/main.rs
use {
    borsh::BorshDeserialize,
    carbon_core::{
        deserialize::CarbonDeserialize,
        error::CarbonResult,
        filter::{DeduplicationFilter, Filter},
        instruction::InstructionProcessorInputType,
        pipeline::Pipeline,
        processor::Processor,
    },
    carbon_log_metrics::LogMetrics,
    carbon_meteora_dlmm_decoder::{
        MeteoraDlmmDecoder,
        instructions::{CpiEvent as DlmmEvent, MeteoraDlmmInstruction},
    },
    carbon_pump_swap_decoder::{
        PumpSwapDecoder,
        instructions::{CpiEvent as PumpSwapEvent, PumpSwapInstruction},
    },
    carbon_raydium_cpmm_decoder::{
        RaydiumCpmmDecoder, events::swap_event::SwapEventEvent,
        instructions::RaydiumCpmmInstruction,
    },
    carbon_rpc_transaction_crawler_datasource::{ConnectionConfig, Filters, RpcTransactionCrawler},
    solana_pubkey::Pubkey,
    std::{sync::Arc, time::Duration},
};

#[tokio::main]
async fn main() -> CarbonResult<()> {
    env_logger::init();

    let rpc_url = std::env::var("RPC_URL")
        .unwrap_or_else(|_| "https://api.mainnet-beta.solana.com".to_string());
    // One crawler per program, all feeding the same pipeline. One request in flight each
    // keeps the three of them under the public endpoint's per-IP rate limit.
    let crawler = |program: Pubkey| {
        RpcTransactionCrawler::new(
            rpc_url.clone(),
            program,
            ConnectionConfig {
                max_concurrent_requests: 1,
                ..ConnectionConfig::default()
            },
            Filters::new(None, None, None),
            None,
        )
    };

    Pipeline::builder()
        .datasource(crawler(carbon_raydium_cpmm_decoder::PROGRAM_ID))
        .datasource(crawler(carbon_pump_swap_decoder::PROGRAM_ID))
        .datasource(crawler(carbon_meteora_dlmm_decoder::PROGRAM_ID))
        .metrics(Arc::new(LogMetrics::new()))
        .instruction_with_filters(RaydiumCpmmDecoder, SwapIndexer, dedupe())
        .instruction_with_filters(PumpSwapDecoder, SwapIndexer, dedupe())
        .instruction_with_filters(MeteoraDlmmDecoder, SwapIndexer, dedupe())
        .build()?
        .run()
        .await
}

/// A transaction that calls two of the programs is fetched by both crawlers. This drops the
/// second copy of each instruction, keyed by signature and position in the transaction.
fn dedupe() -> Vec<Box<dyn Filter>> {
    vec![Box::new(DeduplicationFilter::new(Duration::from_secs(600)))]
}

/// One executed swap, the same shape for every DEX. Amounts are raw token units.
struct Swap {
    dex: &'static str,
    pool: Pubkey,
    amount_in: u64,
    amount_out: u64,
}

struct SwapIndexer;

impl SwapIndexer {
    fn record(&self, signature: impl std::fmt::Display, swap: Swap) {
        // Replace with a database write; see /guides/postgres.
        println!(
            "{:<12} pool={} in={} out={} tx={signature}",
            swap.dex, swap.pool, swap.amount_in, swap.amount_out,
        );
    }
}

/// Raydium CPMM publishes `SwapEvent` with Anchor's `emit!`, as a `Program data:` log line,
/// not as a self-CPI. Wrapping the generated type lets `decode_log_events` read it.
#[derive(BorshDeserialize)]
struct LoggedCpmmSwap(SwapEventEvent);

impl CarbonDeserialize for LoggedCpmmSwap {
    const DISCRIMINATOR: &'static [u8] = &[64, 198, 205, 232, 38, 8, 113, 226];

    fn deserialize(data: &[u8]) -> Option<Self> {
        SwapEventEvent::decode(data).map(LoggedCpmmSwap)
    }
}

impl Processor<InstructionProcessorInputType<'_, RaydiumCpmmInstruction>> for SwapIndexer {
    async fn process(
        &mut self,
        input: &InstructionProcessorInputType<'_, RaydiumCpmmInstruction>,
    ) -> CarbonResult<()> {
        if !matches!(
            input.decoded_instruction,
            RaydiumCpmmInstruction::SwapBaseInput { .. }
                | RaydiumCpmmInstruction::SwapBaseOutput { .. }
        ) {
            return Ok(());
        }
        // The swap instruction's own log lines hold its SwapEvent.
        for LoggedCpmmSwap(e) in input.metadata.decode_log_events::<LoggedCpmmSwap>() {
            let swap = Swap {
                dex: "raydium-cpmm",
                pool: e.pool_id,
                amount_in: e.input_amount,
                amount_out: e.output_amount,
            };
            self.record(input.metadata.transaction_metadata.signature, swap);
        }
        Ok(())
    }
}

impl Processor<InstructionProcessorInputType<'_, PumpSwapInstruction>> for SwapIndexer {
    async fn process(
        &mut self,
        input: &InstructionProcessorInputType<'_, PumpSwapInstruction>,
    ) -> CarbonResult<()> {
        let PumpSwapInstruction::CpiEvent { data, .. } = input.decoded_instruction else {
            return Ok(());
        };
        // A buy pays the quote token (usually SOL) for the base token; a sell is the reverse.
        let swap = match data {
            PumpSwapEvent::BuyEvent(e) => Swap {
                dex: "pump-swap",
                pool: e.pool,
                amount_in: e.quote_amount_in,
                amount_out: e.base_amount_out,
            },
            PumpSwapEvent::SellEvent(e) => Swap {
                dex: "pump-swap",
                pool: e.pool,
                amount_in: e.base_amount_in,
                amount_out: e.quote_amount_out,
            },
            _ => return Ok(()),
        };
        self.record(input.metadata.transaction_metadata.signature, swap);
        Ok(())
    }
}

impl Processor<InstructionProcessorInputType<'_, MeteoraDlmmInstruction>> for SwapIndexer {
    async fn process(
        &mut self,
        input: &InstructionProcessorInputType<'_, MeteoraDlmmInstruction>,
    ) -> CarbonResult<()> {
        let MeteoraDlmmInstruction::CpiEvent { data, .. } = input.decoded_instruction else {
            return Ok(());
        };
        // DLMM emits both `Swap` and `Swap2Evt` for each swap, with the same amounts: read one.
        let DlmmEvent::Swap(e) = data else {
            return Ok(());
        };
        let swap = Swap {
            dex: "meteora-dlmm",
            pool: e.lb_pair,
            amount_in: e.amount_in,
            amount_out: e.amount_out,
        };
        self.record(input.metadata.transaction_metadata.signature, swap);
        Ok(())
    }
}

Run it#

Terminal
cargo run

The first build takes one to three minutes. Then swaps start printing, newest first, from whichever crawler gets a response. On the public endpoint the first line can take a minute or more.

Output
raydium-cpmm pool=hzZaxBF4J6sKNhGtDt2TK1sgEDY31YUzfnkthWA5LpB in=8963567785194 out=196216404 tx=2hx82t435iDNNV6SzYCmh6Hn6MKSdpCKeM69Kxvv6zDm3Dg1Z3pCg6MscmyLmZpNQNB3iDPfZvLjxxmDRraB9GrR
pump-swap    pool=47MMNWgNHKUZFJGKqECZRCpzFKSLMq5YaFXxrieiJQv1 in=7049236 out=15289158360 tx=2Yand83ERTbY9qGjkYKUCrTJCxQnsJeJDkJX4u4LcjDQSPh8jfLBeHAPJig5j9fTYdFfvRraG11KxmfrT2yjL3q7
meteora-dlmm pool=GMgh4NtWrGaUf1RR2kcXD7LY1jou1qFAuSsQeKp5ow4a in=3404263 out=38912302 tx=5Tv7HWwmTxw2avMdHwJGapG932BuYB6ARiJsqxhBc4ejPooMaR7bGfJeNTXtt8wJYtZkwRgwgu6VLtFkCN5VN79W
pump-swap    pool=8zVwLfWw8KpbKoTGdufn6BwTg6Z12SFREHBuZM6UUrVs in=698486361 out=277606557301 tx=2rA17BYRFf4NLP3uGyuxb91bnchdW1rzZfgJLK6M2n5BaoZH1gJoaGjQuEBJ1GNYn1fNyhDQJewX4hFdmZeWZWqk
meteora-dlmm pool=8BD8x5Ms3f1unL9YiDGG4XQYxEcot1UqCkqkyTjniUNZ in=274040325 out=165191368923 tx=2rA17BYRFf4NLP3uGyuxb91bnchdW1rzZfgJLK6M2n5BaoZH1gJoaGjQuEBJ1GNYn1fNyhDQJewX4hFdmZeWZWqk
meteora-dlmm pool=6e7V9eegCHw997T72MxgwwJipZ6GJyZF8NvjkzT1rvpN in=465609675 out=721187169 tx=2rA17BYRFf4NLP3uGyuxb91bnchdW1rzZfgJLK6M2n5BaoZH1gJoaGjQuEBJ1GNYn1fNyhDQJewX4hFdmZeWZWqk
…

The last three lines are one transaction that routed through a PumpSwap pool and two Meteora DLMM pools. Each hop is its own swap, so a multi-hop route prints one line per pool. Amounts are raw token units. Press Ctrl+C to stop.

Why each pipe has a deduplication filter#

Each crawler fetches every transaction that mentions its program. A transaction that routes through two of the three DEXs is fetched by both crawlers, so without a filter its swaps print twice. DeduplicationFilter keys each instruction by transaction signature and position in the transaction, and drops the second copy.

With a single datasource that follows all three programs, such as a Yellowstone gRPC subscription with the three program IDs in account_include, every transaction arrives once. The filter is then harmless, and it still protects you if you add a second datasource later.

Meteora DLMM emits two events per swap#

Recent DLMM swaps emit both Swap and Swap2Evt, with the same pool and amounts. Matching both prints every swap twice, so the indexer reads Swap only. Swap2Evt adds amount_left and a breakdown of the fees, including limit-order fees; read it instead if you need those.

Add another DEX#

  1. Find the program on the DEX and AMM decoders page, add its crate at 2.0.0, and add a crawler for its PROGRAM_ID.
  2. Find its swap event in the decoder page's Events section. For example, Orca Whirlpools has Traded, with whirlpool, input_amount and output_amount.
  3. Implement Processor for its instruction input on SwapIndexer, read the event as a CpiEvent or from the logs (see above), and build a Swap.
  4. Register it with .instruction_with_filters(Decoder, SwapIndexer, dedupe()).

Some programs have no events at all: the Raydium AMM v4 decoder lists none, and its SwapBaseIn carries only amount_in and minimum_amount_out. For those, compute the executed amounts from the transaction's token balance changes in input.metadata.transaction_metadata.meta.

Turn raw amounts into token amounts#

The events report raw units of each mint. To show token amounts, divide by 10 to the power of the mint's decimals. Look up each mint's decimals once and cache them: a mint's decimals never change. PumpSwap's quote token is usually wrapped SOL, which has 9 decimals.

Common problems#

Nothing prints for a minute or more#

The public endpoint rate-limits getTransaction per IP, and the three crawlers share that limit. This is why each crawler keeps one request in flight. Run with RUST_LOG=info to see the crawler counters and 429 retries, and set RPC_URL to your own endpoint for more than a test.

The same swap prints twice#

Either a pipe has no deduplication filter, or you matched both of DLMM's events. See why each pipe has a deduplication filter and Meteora DLMM emits two events per swap.

Raydium CPMM swaps never print#

You matched RaydiumCpmmInstruction::CpiEvent. Raydium CPMM logs its events instead, so read them from SwapBaseInput and SwapBaseOutput with decode_log_events, as in the example.

Next steps#

Your endpoints

Code samples use these instead of the placeholders, so you can copy and run them as they are. They’re stored only in this browser and never sent anywhere.