My DDoS Protection Looked Solid Until I Actually Tested It” published

A few months ago I got tired of operating on blind confidence. My server had Cloudflare in front of it, rate limiting configured, fail2ban running — all the standard stuff. I’d set it up following guides, felt like I knew what I was doing, and proceeded to never actually verify any of it worked.

Then a colleague asked me a simple question: when did you last test this under real load? Not synthetic one-machine benchmarks — actual load, from multiple sources, the kind of traffic that looks like a real flood?

I had no good answer. So I went and found out.

The testing part took an afternoon. The aftermath took two weeks and left me with more questions than answers.

The Setup I Was “Confident” About

Let me describe what I was running, because I think it’s a pretty typical production setup:

  • VPS on DigitalOcean, nginx as the web server
  • Cloudflare proxy in front, orange cloud enabled
  • Rate limiting rules in nginx
  • fail2ban watching logs and banning repeat offenders
  • Standard WAF ruleset
  • Redis caching on the busy pages

On paper: reasonable. In practice: never stress tested under anything resembling a real attack. I had configuration. I did not have evidence.

Why a Service, Not a DIY Tool

My first thought was to just spin up a cloud VM and hammer my own server with ab or wrk.

The problem: that’s one IP. All the load coming from a single source is not what real floods look like, and it immediately trips your per-IP rate limiting. You end up testing whether you can block one very angry user, which is a much easier problem than the actual one.

I ended up using floodlab.cx because they do distributed testing — traffic from multiple source IPs, configurable patterns, and crucially, the ability to simulate traffic that looks like real users rather than obvious bot cruft. That last part was specifically what I wanted to see.

Setup was simple enough. Pointed it at my domain, set the parameters, started the test.

Watching My Server Die in Real Time

I had a coffee, opened my monitoring dashboard, and watched the test run.

The first few minutes were anticlimactic. Traffic ramping up, response times normal, server not breaking a sweat. I started to feel like maybe I’d been worrying about nothing.

Then things got interesting.

Around the 5-minute mark, response times started climbing. Not dramatically — just enough to notice. CPU ticking up. Database connections getting busier.

By 8,000 req/s, the site was noticeably sluggish. By 12,000, the database connection pool was exhausted and the site was returning errors to everyone. Load balancer healthy, nginx running, server not crashed — but every request failing because there was nothing left to serve them.

Four minutes from “feels a bit slow” to “functionally offline.”

Here’s what made it worse: my monitoring dashboard was green the entire time. CPU elevated but not critical. Memory fine. No firewall alerts. Cloudflare reporting normal operations. From the perspective of every metric I was watching, nothing was wrong. Just… a lot of traffic, all of it looking completely legitimate.

The only thing I wasn’t watching was whether users were actually getting responses. They weren’t.

Two Weeks of Trying to Fix It

This is the part I’m a bit embarrassed about.

I went into this thinking: find the weak spots, tune the config, done. What I found out is that certain types of floods are genuinely hard to defend against without degrading the experience for real users — because what makes them hard to block is exactly what makes them hard to distinguish from real traffic.

I won’t go into the mechanics of how these attacks work, because that’s not really the point. The point is: from my server’s perspective, it was just getting a lot of visitors. Lots of different people, browsing normally. Nothing obviously malicious to catch.

So every defensive approach I tried ran into the same wall:

Tightening rate limits — either the limits were high enough that the flood stayed under them, or they were low enough that real users also started hitting them during busy periods.

WAF rules — there was nothing anomalous to write a rule against. The requests looked fine.

fail2ban tuning — same problem. No individual source was misbehaving badly enough to trigger a ban. The issue was aggregate volume, not any single bad actor.

Cloudflare configuration — I went through everything. Bot fight mode, various security level settings, custom rules. Each test run came back with roughly the same result.

I re-ran the floodlab test after each attempt. The numbers barely moved.

The One Thing That Actually Stopped It

After two weeks, the only configuration change that reliably stopped the flood was enabling Cloudflare’s “Under Attack Mode” — a challenge page that every visitor has to pass before reaching the site.

It worked. Traffic stopped reaching my server.

It also:

  • Added a noticeable delay to every first page load for real users
  • Broke my mobile app’s API calls entirely
  • Broke all my monitoring and uptime checks
  • Broke third-party webhooks that POST to my backend
  • Is explicitly designed for use during an active attack, not as a permanent setting

So my “solution” was to make my site worse for everyone, permanently, to defend against an attack that wasn’t currently happening. The moment I turn it off, I’m back to square one.

That felt less like a fix and more like a different kind of self-inflicted problem.

What Actually Helped (Without Breaking Everything)

Not all was lost. Some changes genuinely moved the needle:

Aggressive caching on read-heavy pages. If the response comes from cache, the database never gets involved. This pushed my breaking point from around 12,000 req/s to closer to 18,000. Not a solution, but meaningful headroom.

Per-endpoint rate limiting instead of global. I had one rate limit for everything. Some endpoints are cheap; some are expensive. Treating them the same means your expensive endpoints are significantly under-protected.

Database connection pool sized for peak, not average. Mine was tuned for normal traffic. Under load it ran out almost immediately. Increasing it gave better survivability during spikes, even if it didn’t stop the degradation entirely.

Actually monitoring user-facing error rates. The big embarrassing discovery: I was watching server metrics, not user outcomes. I added synthetic checks that make real HTTP requests and verify they get successful responses. Now if the site is returning errors, something actually alerts.

What I Took Away From All This

“Config exists” and “config works” are different things. I had protection configured. I’d never verified it actually protected against anything realistic. These are not the same state.

Cloudflare is not a guarantee. It’s genuinely useful, but if your origin server falls over on its own before Cloudflare steps in, that’s still downtime. The proxy doesn’t replace a resilient backend.

Monitoring the wrong things means you get no warning. I had decent monitoring that told me nothing useful during the test. Adding user-facing synthetic checks was the highest-value change I made.

Some problems don’t have cheap clean solutions. Two weeks of work and the honest answer is: defending against sophisticated floods at a serious scale, without breaking the site for real users, requires either significant engineering investment or enterprise-level tooling. There’s no nginx config that makes this go away.

Then Floodlab Actually Helped Me Fix It

After a week of going in circles on my own, I reached out to the floodlab team. Wasn’t really expecting much — figured they’re a testing service, not a consultancy.

Turned out to be the most useful conversation I had throughout this whole process.

They looked at the test results with me and walked me through what was actually happening at each stage of the degradation. Not generic advice — specific, based on the actual numbers from my tests. Where the bottleneck was, why certain mitigations weren’t working, what the realistic options were given my stack.

The short version of what they helped me put together:

First, properly structured rate limiting across multiple dimensions — not just per-IP, but combining source, endpoint, and request frequency in a way that raises the collective cost of a flood without tripping on normal user behavior. The implementation details took some tuning, but the logic they explained made sense of why my previous attempts kept failing.

Second, a tiered response configuration in Cloudflare that starts mild and escalates automatically based on error rates — so I’m not choosing between “do nothing” and “Under Attack Mode breaks everything.” There are intermediate steps that add friction progressively.

Third, some caching and connection pooling changes specific to my app’s traffic patterns that gave the backend meaningful extra headroom before hitting the database wall.

I re-ran the test after implementing all of it. Not magically bulletproof — I don’t think that exists at my scale without serious spend — but the site stayed functional at load levels that had previously killed it within minutes. The degradation was gradual rather than a cliff. And when things did get slow, my monitoring actually fired alerts this time.

For what it’s worth: I didn’t expect a testing service to be the ones who helped me get the protection working. But they clearly know what the attacks look like from the inside, which turns out to be pretty useful context when you’re trying to defend against them.

Was the Test Worth Doing?

Absolutely — and I’d recommend it to anyone running production infrastructure who hasn’t done this.

Floodlab.cx gave me a clear, realistic picture of where my server actually breaks, under conditions that resemble real attacks far more than any single-machine load test does. The dashboard is clear, the reporting is detailed, and watching everything fall over in real time while your monitoring stays green is exactly the kind of uncomfortable lesson that actually changes how you think about your setup.

I went in with misplaced confidence and came out with a more accurate picture of where I stood. That’s worth something, even when what you learn isn’t flattering.

My server is more resilient than it was. It’s more resilient than I thought it was before the test — because now I’ve actually verified that, rather than just assuming. Both things feel good to know.

If you’re at the “my protection is probably fine but I’ve never actually tested it” stage — that’s where I was. Running the test is the uncomfortable but necessary first step. And if you end up where I was after the test, scratching your head at the results — reaching out to the floodlab.cx team directly was genuinely worth it.

Solana MEV Defense in 2026: How Sandwich Bots Extracted $500M — And the 6 Protocol-Level Defenses That Actually Work

Sandwich bots extracted between $370M and $500M from Solana users over the past 16 months. One single program — vpeNALD…Noax38b — accounts for nearly half of all sandwich attacks on the network, executing 51,600 transactions daily with an 88.9% success rate and pocketing ~2,200 SOL ($450K) per day.

And it’s getting worse. Wide (multi-slot) sandwich attacks now account for 93% of all sandwich activity, extracting over 529,000 SOL in the past year alone. These attacks span multiple validator slots, making them nearly invisible to traditional detection methods.

This article breaks down exactly how Solana sandwich attacks work in 2026, why Firedancer’s arrival changes the MEV landscape, and the 6 protocol-level defenses that actually protect your users’ trades.

How Solana Sandwich Attacks Actually Work

Unlike Ethereum’s mempool-based MEV, Solana sandwich attacks exploit a different architectural property: the relationship between validators and transaction ordering within blocks.

The Classic Sandwich

Block N:
  tx[0]: Attacker buys TOKEN (front-run)
  tx[1]: Victim swaps SOL → TOKEN (user's trade)
  tx[2]: Attacker sells TOKEN (back-run)

The attacker’s front-run pushes the price up. The victim buys at an inflated price. The attacker’s back-run captures the difference. The victim gets fewer tokens than they should.

The Wide Sandwich (93% of attacks in 2025-2026)

Slot N (Attacker-Controlled Validator):
  tx[last]: Attacker buys TOKEN (front-run)

Slot N+1 (Any Validator):
  tx[mid]: Victim's swap executes at inflated price

Slot N+2 (Attacker-Controlled Validator):
  tx[0]: Attacker sells TOKEN (back-run)

Wide sandwiches split the front-run and back-run across different slots. This has two advantages for attackers:

  1. Harder to detect — the three transactions don’t appear in the same block
  2. Validator collusion — attackers only need to control one slot to front-run, then wait for the natural price impact

The Jito Bundle Attack Vector

With 92% of Solana validators running the Jito-Solana client, the Jito block engine has become the primary MEV marketplace. Searchers submit bundles — atomic groups of transactions — with tips to validators for priority inclusion.

# Simplified searcher logic
def sandwich_opportunity(pending_tx):
    """Detect and exploit a sandwichable swap."""
    token_in = pending_tx.token_in
    token_out = pending_tx.token_out
    amount = pending_tx.amount
    slippage = pending_tx.max_slippage  # Usually 0.5-3%

    # Calculate if the price impact from front-running
    # stays within victim's slippage tolerance
    price_impact = estimate_impact(token_out, amount)

    if price_impact < slippage:
        # Profitable sandwich possible
        front_run = build_buy_tx(token_out, optimal_amount)
        back_run = build_sell_tx(token_out, optimal_amount)

        bundle = JitoBundle([front_run, pending_tx, back_run])
        bundle.tip = calculate_optimal_tip(expected_profit)
        submit_bundle(bundle)

The critical insight: the victim’s slippage tolerance is their vulnerability. A 1% slippage setting on a $10,000 swap means an attacker can extract up to $100. Across millions of daily transactions, this adds up fast.

Why Firedancer Changes Everything (And Not How You’d Expect)

Firedancer, Jump Crypto’s independently developed Solana validator client, is rolling out through 2026 with dramatic performance improvements — up to 1M TPS in testing. But its MEV implications are complex:

Shorter Block Times = Shorter Attack Windows

Firedancer’s optimized block packing means transactions spend less time in any “pending” state. This theoretically shrinks the window for sandwich detection and execution.

But: Firedancer also supports a “Revenue Mode” that explicitly prioritizes MEV capture. Validators running Firedancer in revenue mode may actually be more efficient at extracting MEV, not less.

More Transactions = More Opportunities

Higher throughput means more swaps per block, which means more sandwich opportunities. The raw volume of extractable value goes up even as individual attack windows shrink.

The Verification Lag Problem

Firedancer’s dynamic block sizing and the “Skip-Vote Phenomenon” create verification lag — a gap between when a transaction is included in a block and when it’s confirmed by the network. For time-sensitive operations like DEX swaps, this lag can be exploited:

// VULNERABLE: No slot-awareness in price check
pub fn execute_swap(ctx: Context<Swap>, amount: u64, min_out: u64) -> Result<()> {
    let price = get_oracle_price(&ctx.accounts.oracle)?;
    // Price might be stale by 1-2 slots due to verification lag
    let output = calculate_output(amount, price);
    require!(output >= min_out, SwapError::SlippageExceeded);
    // ... execute
    Ok(())
}

// FIXED: Explicit slot freshness check
pub fn execute_swap(ctx: Context<Swap>, amount: u64, min_out: u64) -> Result<()> {
    let clock = Clock::get()?;
    let oracle_data = get_oracle_data(&ctx.accounts.oracle)?;

    // Reject if oracle update is more than 2 slots old
    require!(
        clock.slot - oracle_data.last_update_slot <= 2,
        SwapError::StaleOracle
    );

    let output = calculate_output(amount, oracle_data.price);
    require!(output >= min_out, SwapError::SlippageExceeded);
    // ... execute
    Ok(())
}

Defense #1: Jito’s dontfront — First-Index Guarantee

Jito’s dontfront feature ensures your transaction is placed at index 0 within any bundle, preventing front-running within the same block.

How to Use It

import { Connection, Transaction } from '@solana/web3.js';

// Submit through Jito's block engine with dontfront
const JITO_BLOCK_ENGINE = 'https://mainnet.block-engine.jito.wtf/api/v1/transactions';

async function submitProtectedTx(tx: Transaction, keypair: Keypair) {
    const serialized = tx.serialize();

    const response = await fetch(JITO_BLOCK_ENGINE, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            jsonrpc: '2.0',
            id: 1,
            method: 'sendTransaction',
            params: [
                Buffer.from(serialized).toString('base64'),
                {
                    encoding: 'base64',
                    // dontfront: transaction gets index 0 priority
                    skipPreflight: true,
                    maxRetries: 3
                }
            ]
        })
    });

    return response.json();
}

Limitations

  • Only protects within a single block — wide sandwiches across slots are unaffected
  • Requires submitting through Jito’s endpoint
  • Costs additional tips for priority placement
  • 92% validator adoption means 8% of slots have no Jito protection

Defense #2: Private Transaction Relays

Private relays encrypt transactions until execution, eliminating the observation window that sandwich bots need.

Astralane’s Encrypted Channel

// Submit through Astralane's private relay
const ASTRALANE_RELAY = 'https://relay.astralane.io/v1/submit';

async function submitPrivate(tx: Transaction, keypair: Keypair) {
    const signed = await signTransaction(tx, keypair);

    // Transaction is encrypted in transit
    // Only becomes visible at execution time
    const response = await fetch(ASTRALANE_RELAY, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-API-Key': process.env.ASTRALANE_KEY
        },
        body: JSON.stringify({
            transaction: Buffer.from(signed.serialize()).toString('base64'),
            options: {
                mevProtection: true,
                maxRetries: 5,
                priorityFee: 'auto'
            }
        })
    });

    return response.json();
}

BloXroute’s Leader-Aware Routing

BloXroute takes a different approach — scoring upcoming slot leaders for sandwich risk and routing around dangerous validators:

// BloXroute scores validators and avoids known sandwich-colluding ones
const BLOXROUTE_API = 'https://solana.trader-api.bloxroute.com/v1';

async function submitWithLeaderAwareness(tx: Transaction) {
    const response = await fetch(`${BLOXROUTE_API}/submit`, {
        method: 'POST',
        headers: {
            'Authorization': process.env.BLOXROUTE_AUTH,
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            transaction: serializeTx(tx),
            submitStrategy: {
                // Route through multiple channels for redundancy
                multipath: true,
                // Score leaders and avoid high-risk validators
                leaderAwareMevProtection: true,
                // Use Jito, Paladin, and bloXroute simultaneously
                channels: ['jito', 'paladin', 'bloxroute']
            }
        })
    });

    return response.json();
}

Defense #3: Program-Level Slippage with TWAP Validation

The most robust defense lives in the smart contract itself. Instead of trusting user-provided min_out alone, validate against a TWAP (Time-Weighted Average Price):

use anchor_lang::prelude::*;

#[program]
pub mod protected_swap {
    use super::*;

    pub fn swap_with_twap_guard(
        ctx: Context<SwapGuarded>,
        amount_in: u64,
        min_amount_out: u64,
        max_price_deviation_bps: u16,  // e.g., 100 = 1%
    ) -> Result<()> {
        let oracle = &ctx.accounts.oracle;
        let clock = Clock::get()?;

        // 1. Get spot price
        let spot_price = oracle.get_spot_price()?;

        // 2. Get TWAP over last 30 seconds
        let twap_price = oracle.get_twap(
            clock.unix_timestamp - 30,
            clock.unix_timestamp
        )?;

        // 3. Reject if spot deviates from TWAP beyond threshold
        // This catches sandwich-inflated prices
        let deviation = if spot_price > twap_price {
            ((spot_price - twap_price) * 10_000) / twap_price
        } else {
            ((twap_price - spot_price) * 10_000) / twap_price
        };

        require!(
            deviation <= max_price_deviation_bps as u64,
            SwapError::PriceDeviationTooHigh
        );

        // 4. Execute swap with validated price
        let output = execute_swap_internal(
            &ctx.accounts.pool,
            amount_in,
            spot_price
        )?;

        require!(output >= min_amount_out, SwapError::SlippageExceeded);

        // 5. Post-swap sanity check
        let post_price = ctx.accounts.pool.get_current_price()?;
        let impact = ((spot_price - post_price) * 10_000) / spot_price;
        require!(
            impact <= max_price_deviation_bps as u64 * 2,
            SwapError::ExcessivePriceImpact
        );

        Ok(())
    }
}

#[derive(Accounts)]
pub struct SwapGuarded<'info> {
    #[account(mut)]
    pub user: Signer<'info>,
    #[account(mut)]
    pub pool: Account<'info, LiquidityPool>,
    /// CHECK: Validated by oracle program CPI
    pub oracle: AccountInfo<'info>,
    pub oracle_program: Program<'info, PythOracle>,
}

Why this works: A sandwich attack temporarily inflates the spot price. But the TWAP — averaged over 30 seconds of trading — won’t spike from a single front-run transaction. If spot diverges from TWAP by more than 1%, the swap reverts, and the attacker’s front-run transaction costs them gas with zero profit.

Defense #4: Commit-Reveal Swap Pattern

Split swaps into two transactions to make front-running impossible:

#[program]
pub mod commit_reveal_swap {
    use super::*;

    /// Phase 1: Commit — user locks funds and submits hash of swap params
    pub fn commit_swap(
        ctx: Context<CommitSwap>,
        commitment_hash: [u8; 32],  // hash(amount, min_out, nonce)
        deposit_amount: u64,
    ) -> Result<()> {
        let commitment = &mut ctx.accounts.commitment;
        let clock = Clock::get()?;

        // Lock funds in escrow
        transfer_tokens(
            &ctx.accounts.user_token_account,
            &ctx.accounts.escrow,
            deposit_amount,
            &ctx.accounts.user,
        )?;

        commitment.user = ctx.accounts.user.key();
        commitment.hash = commitment_hash;
        commitment.amount = deposit_amount;
        commitment.slot = clock.slot;
        commitment.executed = false;

        Ok(())
    }

    /// Phase 2: Reveal — user reveals params, swap executes
    pub fn reveal_and_swap(
        ctx: Context<RevealSwap>,
        amount: u64,
        min_out: u64,
        nonce: u64,
    ) -> Result<()> {
        let commitment = &mut ctx.accounts.commitment;
        let clock = Clock::get()?;

        // Must reveal within 10 slots of commit
        require!(
            clock.slot <= commitment.slot + 10,
            SwapError::CommitmentExpired
        );
        // Must wait at least 2 slots (prevents same-block reveal)
        require!(
            clock.slot >= commitment.slot + 2,
            SwapError::RevealTooEarly
        );

        // Verify commitment
        let expected_hash = hash(&[
            &amount.to_le_bytes(),
            &min_out.to_le_bytes(),
            &nonce.to_le_bytes(),
        ]);
        require!(
            expected_hash.to_bytes() == commitment.hash,
            SwapError::InvalidReveal
        );

        // Execute swap from escrow
        let output = execute_swap_from_escrow(
            &ctx.accounts.escrow,
            &ctx.accounts.pool,
            amount,
            min_out,
        )?;

        commitment.executed = true;

        Ok(())
    }
}

Trade-off: Commit-reveal adds latency (~1-2 seconds for 2-slot wait) and requires two transactions. Best for large swaps where the MEV extraction would exceed the extra gas cost.

Defense #5: Dynamic Slippage Based on Trade Size

Most wallets and DEX frontends use a static slippage tolerance (0.5%, 1%, etc.). This is a gift to sandwich bots — they know exactly how much they can extract. Dynamic slippage scales the tolerance based on the trade’s price impact:

// Frontend SDK for dynamic slippage calculation
function calculateDynamicSlippage(
    tradeSize: number,
    poolLiquidity: number,
    recentVolatility: number,  // 1-hour price volatility
    blockCongestion: number    // current slot utilization %
): number {
    // Base: expected price impact from trade size
    const expectedImpact = (tradeSize / poolLiquidity) * 100;

    // Volatility buffer: wider in volatile markets
    const volatilityBuffer = recentVolatility * 0.5;

    // Congestion premium: tighter when network is calm
    const congestionMultiplier = blockCongestion > 80 ? 1.5 : 1.0;

    // Dynamic slippage = impact + buffer, but never more than 2x impact
    const slippage = Math.min(
        (expectedImpact + volatilityBuffer) * congestionMultiplier,
        expectedImpact * 2
    );

    // Floor at 0.1%, cap at 3%
    return Math.max(0.1, Math.min(slippage, 3.0));
}

// Example results:
// $100 swap in $10M pool → 0.1% slippage (floor)
// $10K swap in $1M pool → 1.0% slippage (standard impact)
// $100K swap in $1M pool → 3.0% slippage (cap, warn user)

Why this works: A $100 swap gets 0.1% slippage — only $0.10 extractable, less than the attacker’s gas cost. A $100,000 swap still gets 3% maximum, but the user is warned and can use commit-reveal for additional protection.

Defense #6: On-Chain Sandwich Detection Circuit Breaker

For protocols managing significant TVL, implement an on-chain circuit breaker that detects sandwich patterns and pauses trading:

#[account]
pub struct TradingState {
    pub last_trade_direction: TradeDirection,  // Buy or Sell
    pub consecutive_reversals: u8,
    pub last_trade_slot: u64,
    pub last_trade_size: u64,
    pub circuit_breaker_active: bool,
    pub circuit_breaker_until: u64,
}

pub fn check_sandwich_pattern(
    state: &mut TradingState,
    current_direction: TradeDirection,
    current_slot: u64,
    trade_size: u64,
) -> Result<()> {
    // Detect rapid buy-sell-buy or sell-buy-sell in same slot
    if current_slot == state.last_trade_slot 
        && current_direction != state.last_trade_direction 
    {
        state.consecutive_reversals += 1;

        // 3 reversals in same slot = likely sandwich
        if state.consecutive_reversals >= 3 {
            state.circuit_breaker_active = true;
            state.circuit_breaker_until = current_slot + 10;

            emit!(SandwichDetected {
                slot: current_slot,
                reversals: state.consecutive_reversals,
            });

            return Err(SwapError::CircuitBreakerTripped.into());
        }
    } else if current_slot > state.last_trade_slot {
        // New slot, reset counter
        state.consecutive_reversals = 0;
    }

    // Check if circuit breaker is still active
    if state.circuit_breaker_active && current_slot < state.circuit_breaker_until {
        return Err(SwapError::CircuitBreakerActive.into());
    } else {
        state.circuit_breaker_active = false;
    }

    state.last_trade_direction = current_direction;
    state.last_trade_slot = current_slot;
    state.last_trade_size = trade_size;

    Ok(())
}

The Defense Matrix: Which Protection for Which Scenario

Defense Protects Against Latency Cost Best For
Jito dontfront Same-block sandwich None All trades via Jito
Private relays Observation-based attacks ~50ms High-value trades
TWAP validation Price manipulation None (on-chain) Protocol-level AMMs
Commit-reveal All sandwich types 1-2 seconds Trades > $10K
Dynamic slippage Over-extraction None Frontend/wallet UX
Circuit breaker Repeated attacks Pauses trading High-TVL pools

Layered Defense: The Recommended Stack

For maximum protection, layer multiple defenses:

User Intent (Frontend)
    │
    ├─ Dynamic slippage calculation
    │
    ▼
Transaction Submission
    │
    ├─ Private relay (Astralane/BloXroute)
    ├─ Jito dontfront for bundle protection
    │
    ▼
On-Chain Execution
    │
    ├─ TWAP deviation check
    ├─ Sandwich pattern detection
    ├─ Circuit breaker
    │
    ▼
Post-Trade Verification
    │
    └─ Price impact sanity check

No single defense is sufficient. Jito’s dontfront doesn’t stop wide sandwiches. Private relays don’t help if the validator itself is malicious. TWAP checks don’t prevent extraction below the deviation threshold. Layer them.

The Firedancer Forecast: What Changes in Late 2026

When Firedancer reaches majority adoption:

  1. Block times shrink → Less time for searchers to analyze and submit bundles
  2. Throughput increases → More transactions to sandwich, but also more noise
  3. Revenue Mode validators → MEV extraction becomes more efficient, not less
  4. New attack surface → Different block scheduling between Firedancer and Jito-Solana creates arbitrage between validator implementations

My prediction: Total MEV extracted will increase with Firedancer, but per-transaction extraction will decrease. The winners will be protocols with on-chain defenses (TWAP checks, circuit breakers) that work regardless of the validator client.

Action Items

  1. Today: Check your DEX frontend’s default slippage — if it’s static 0.5%, you’re donating to sandwich bots
  2. This week: Implement Jito dontfront or private relay submission for your protocol’s frontend
  3. Before Firedancer mainnet: Add TWAP deviation checks to any swap instruction in your programs
  4. For high-TVL protocols: Implement the commit-reveal pattern for large trades and circuit breakers for repeated attack detection

The $500M extracted from Solana users isn’t a technology failure — it’s a defense failure. Every tool in this article exists today. The only question is whether you’ll implement them before your users pay the tax.

This is part of the DeFi Security Research series. Previously: ZK Circuit Kill Chain, 5 Smart Contract Anti-Patterns.

Follow @ohmygod for weekly security research.

Building a RAG-Based Subsidy Matching System from Scratch with Python

What I Built

A RAG (Retrieval-Augmented Generation) system that helps Japanese small businesses find government subsidies. Users describe their business situation, and the system retrieves relevant subsidies from a vector database, then generates a detailed answer using Claude API.

Why RAG Instead of Just Using an LLM?

LLMs alone have three problems for this use case:

  1. Hallucination — They confidently make up subsidy details that don’t exist
  2. Stale data — Subsidy information updates frequently; LLM training data can’t keep up
  3. No sources — Users need to verify the information, but LLMs don’t cite where it came from

RAG solves all three by retrieving real data first, then passing it to the LLM as context. The LLM generates answers grounded in actual documents, not its training data.

Architecture

User Query
    │
    ▼
┌─────────────┐     ┌──────────────┐
│  Retriever  │────▶│  Generator   │
│ (embed +    │     │ (Claude API) │
│  search)    │     └──────┬───────┘
└──────┬──────┘            │
       │                   ▼
┌──────▼──────┐      AI Answer
│  ChromaDB   │      with sources
│ (Vector DB) │
└─────────────┘

The data ingestion pipeline:

JSON Data → Sentence Chunker → Embedding Model → ChromaDB

Tech Stack and Why

Layer Choice Why
Embedding multilingual-e5-large Best multilingual performance for free. Runs locally — no data leaves the machine
Vector DB ChromaDB Zero config. pip install and it works with local persistence
LLM Claude API Strong long-context handling and Japanese language quality
Frontend Streamlit Full UI in ~40 lines of Python

Why Not LangChain/LlamaIndex?

I intentionally built the pipeline from scratch first. Frameworks abstract away the internals, which is great for production but bad for learning. By writing each step manually — chunking, embedding, storing, retrieving, prompting — I understood exactly what each piece does and why it matters.

Key Implementation Details

1. Sentence-Boundary Chunking

The most impactful decision was how to split documents into chunks.

Naive approach (fixed-size split):

...eligible IT tools include accounting software, ord  ← cut mid-sentence
er management software, payment software...           ← next chunk

My approach (split at Japanese sentence boundaries 。):

sentences = text.split("。")
# Greedily pack sentences into chunks up to CHUNK_SIZE
# Overlap the tail for context continuity

This preserves semantic meaning in each chunk, which directly improves retrieval accuracy.

2. Embedding with Prefix

multilingual-e5 requires specific prefixes to distinguish between stored passages and search queries:

# When indexing documents
prefixed = [f"passage: {text}" for text in texts]

# When searching
query_embedding = model.encode(f"query: {user_query}")

Missing this prefix is a common mistake that silently degrades search quality.

3. Prompt Design for Grounded Answers

The generation prompt explicitly constrains the LLM:

prompt = """You are a Japanese subsidy advisor.
Answer based on the reference information below.
If information is insufficient, state that explicitly.
For critical details like deadlines and amounts, be precise."""

This reduces hallucination by telling the model to stick to the provided context.

Evaluation: Measuring What Matters

Building a RAG that “seems to work” isn’t enough. I created a test suite with 10 queries, each with an expected correct subsidy:

Query Expected Result Rank
“Want to introduce accounting software” Digital/AI Subsidy 1st
“Want to install new manufacturing equipment” Manufacturing Subsidy 2nd
“Small shop wanting to attract customers via flyers” Small Business Sustainability Subsidy 1st
“Restaurant wanting to start EC business” New Business Advancement Subsidy 3rd
“Want to convert part-timers to full-time” Career Up Grant 1st
… … …

Results:

Metric Score
Hit Rate @5 100% (10/10)
MRR @5 0.817
  • Hit Rate @5: The correct answer appeared in the top 5 results for every query
  • MRR (Mean Reciprocal Rank): On average, the correct answer ranked between 1st and 2nd place

Why These Metrics?

  • Hit Rate tells you “can the system find the right answer at all?”
  • MRR tells you “how quickly does it find it?” (1st place = 1.0, 2nd = 0.5, 3rd = 0.33)

Both are standard IR (Information Retrieval) metrics. Having these numbers lets you objectively compare different chunking strategies, embedding models, or retrieval parameters.

What I Learned

1. Chunking strategy matters more than the embedding model

Switching from fixed-size to sentence-boundary chunking had a bigger impact on retrieval quality than I expected. The embedding model can only work with what it’s given — if chunks are semantically broken, even the best model can’t fix that.

2. Evaluation should come before optimization

Building the evaluation script early gave me a baseline to measure improvements against. Without it, I would have been tuning blindly.

3. Start simple, verify, then add complexity

The entire system is ~200 lines of Python (excluding tests). No frameworks, no complex abstractions. This made debugging straightforward — when something went wrong, there were very few places to look.

What’s Next

  • Reranker: Add a cross-encoder reranker to improve ranking quality (especially for Q4 and Q9 which ranked 3rd)
  • Hybrid search: Combine vector similarity with keyword matching (BM25) for better recall
  • More data: Scale from 15 to 100+ subsidies with automated collection
  • Production frontend: Migrate from Streamlit to Next.js + FastAPI for a proper web application

If you’re building a RAG system for the first time, I’d recommend the same approach: build from scratch first, measure with real metrics, then decide if you need a framework. The understanding you gain is worth far more than the time saved by a framework.

Spin Has a Free API: Build WebAssembly Microservices in 30 Seconds

Docker containers start in seconds. WebAssembly modules start in milliseconds. Spin makes building them as easy as Express.

What Is Spin?

Spin by Fermyon is a framework for building WebAssembly-based microservices. Write in Rust, Go, Python, JavaScript, or C# — compile to Wasm — deploy anywhere.

spin new -t http-rust my-app
cd my-app
spin build
spin up
# HTTP server running on http://localhost:3000

The Code

// Rust
use spin_sdk::http::{IntoResponse, Request, Response};
use spin_sdk::http_component;

#[http_component]
fn handle_request(req: Request) -> anyhow::Result<impl IntoResponse> {
    Ok(Response::builder()
        .status(200)
        .header("content-type", "application/json")
        .body(r#"{"message": "Hello from WebAssembly!"}"#)
        .build())
}
// JavaScript/TypeScript
import { HandleRequest, HttpRequest, HttpResponse } from "@fermyon/spin-sdk"

export const handleRequest: HandleRequest = async function(request: HttpRequest): Promise<HttpResponse> {
  return {
    status: 200,
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ message: "Hello from Wasm!" })
  }
}
# Python
from spin_sdk.http import IncomingHandler, Request, Response

class IncomingHandler(IncomingHandler):
    def handle_request(self, request: Request) -> Response:
        return Response(200, {"content-type": "application/json"}, b'{"message": "Hello!"}')

Why WebAssembly Microservices

1. Cold start: <1ms — vs Docker 500ms-5s, Lambda 100ms-1s

2. Memory: 1-10MB — vs Docker container 50-500MB

3. Security — Wasm modules are sandboxed. No file system access, no network access unless explicitly granted.

4. Language agnostic — Rust, Go, Python, JS, C#, C++ → all compile to the same Wasm format

5. Portable — Wasm runs anywhere. No “works on my machine.”

Key-Value Store

use spin_sdk::key_value::Store;

let store = Store::open_default()?;
store.set("user:123", b"Alice")?;
let name = store.get("user:123")?;

Deploy to Fermyon Cloud

spin cloud deploy
# Deployed in seconds. Global edge. Free tier.

Building microservices? Check out my developer tools or email [email protected].

One Good Post Should Turn Into More Than One Post

Part 8 of 9

I really dislike the “write one post, publish it once, move on” pattern.

If you already did the hard work of writing something useful, the tool should help you squeeze more leverage out of it.

That batch became:

  • repurpose_post
  • content_gap_analysis
  • series_planner

The goal here is simple: more mileage, better follow-up angles, and less starting from zero every time.

Repo:
https://github.com/abanoub-ashraf/substack-mcp-plus

Modular Monolith Architecture in .NET: The Pragmatic Middle Ground

The Problem No One Talks About in the Microservices Hype

It’s 2019. Your team has just finished migrating a perfectly functional ASP.NET MVC app to microservices. You now have 14 services, a Kubernetes cluster, a service mesh, distributed tracing, a message broker, and three engineers whose full-time job is keeping it all running. Feature velocity? Cut in half. Onboarding a new developer? Two weeks just to understand the infrastructure.

Sound familiar?

The architecture community spent a decade evangelizing microservices as the solution to every scalability and maintainability problem. And while microservices genuinely solve hard problems at scale, they come with an enormous operational and cognitive tax — one that most product teams can’t afford, and frankly don’t need to pay.

Enter the Modular Monolith: not a retreat to the big ball of mud, and not the complexity of distributed systems. It’s an architectural style that enforces clear internal boundaries while keeping the simplicity of a single deployable unit. For many .NET teams, it’s the most pragmatic choice they never considered.

What Is a Modular Monolith?

A modular monolith is a single deployable application composed of well-defined, loosely coupled internal modules, each owning its own domain logic, data, and public API surface.

The key distinction from a traditional monolith is intentional structure. A classic monolith tends to evolve into spaghetti — shared database tables, cross-cutting service dependencies, and no clear ownership boundaries. A modular monolith fights this entropy from day one by treating each module as if it were a microservice candidate, without actually making it one yet.

The Three-Way Comparison:

Dimension Traditional Monolith Modular Monolith Microservices
Deployment Single unit Single unit Multiple units
Module boundaries Weak / absent Strong, enforced Strong, enforced
Data isolation Shared DB Logical isolation Physical isolation
Communication Direct calls In-process events Network (HTTP/gRPC/MQ)
Operational complexity Low Low High
Refactoring cost High (tightly coupled) Medium High (distributed)
Team autonomy Low Medium High
Latency overhead None None Network latency

Think of it this way: a modular monolith is a microservices architecture collapsed into a single process. The boundaries are real — they’re just not physical.

Why Not Just Use Microservices?

Microservices are genuinely powerful when you need:

  • Independent scaling of specific workloads
  • Polyglot persistence and technology choices per service
  • Complete team autonomy with separate CI/CD pipelines
  • Fault isolation at the process boundary level

But the prerequisites are steep. You need mature DevOps culture, a platform team, distributed tracing, eventual consistency patterns, saga orchestration, and more. The distributed fallacies (network is reliable, latency is zero, bandwidth is infinite…) become your daily enemies.

The hidden costs of premature microservices decomposition:

  • Distributed transactions replaced simple database transactions — now you’re managing sagas and compensating actions
  • A cross-service feature requires coordinating 3 teams and 3 PRs
  • Local development requires Docker Compose with 8 containers just to run the app
  • Debugging requires correlating traces across 5 services

A modular monolith defers these costs until they’re actually justified. And for many applications — even those at serious scale — that day may never come.

Core Principles

1. Module Boundaries

A module is a cohesive grouping of functionality around a bounded context (to borrow DDD terminology). In an e-commerce system, natural modules might be Orders, Catalog, Users, Payments, and Notifications.

The boundary rule is simple: modules do not reference each other’s internals. If Orders needs a product name, it doesn’t call Catalog‘s internal ProductService — it goes through a defined public contract.

2. High Cohesion / Low Coupling

Everything related to the Catalog domain lives inside Catalog: its entities, repositories, domain services, use cases, and event handlers. Nothing leaks out. Other modules don’t know Catalog has an EfCore repository — they only know the public interface it exposes.

3. Independent Data Per Module

This is the most important and most violated principle. Each module should own its own data. In a modular monolith, you typically use a single database with schema-level separation (e.g., catalog.Products, orders.Orders), or separate DbContext instances that map to different schemas.

No module queries another module’s tables directly. Ever. This is the line between a modular monolith and a well-organized big ball of mud.

4. Communication Patterns

Modules communicate in two ways:

Synchronous (in-process): One module exposes an interface (e.g., ICatalogModule) and another calls it through the DI container. This is appropriate for read queries where you need an immediate result.

Asynchronous (domain events): When Orders places an order, it publishes an OrderPlacedEvent. Inventory and Notifications handle it independently. This is done in-process using a mediator or event dispatcher — no message broker required.

Implementing in .NET

Project Structure

src/
├── ECommerce.Api/                  # Host project (entry point)
│   ├── Program.cs
│   └── appsettings.json
│
├── ECommerce.Shared/               # Cross-cutting contracts
│   ├── Events/                     # Domain event interfaces
│   └── Modules/                    # IModule interface
│
├── ECommerce.Modules.Catalog/      # Catalog module (Class Library)
│   ├── CatalogModule.cs            # Module registration
│   ├── Domain/
│   │   └── Product.cs
│   ├── Application/
│   │   ├── GetProductQuery.cs
│   │   └── GetProductQueryHandler.cs
│   ├── Infrastructure/
│   │   ├── CatalogDbContext.cs
│   │   └── ProductRepository.cs
│   └── Api/
│       └── CatalogEndpoints.cs
│
├── ECommerce.Modules.Orders/       # Orders module (Class Library)
│   ├── OrdersModule.cs
│   ├── Domain/
│   │   └── Order.cs
│   ├── Application/
│   │   ├── PlaceOrderCommand.cs
│   │   └── PlaceOrderCommandHandler.cs
│   ├── Infrastructure/
│   │   └── OrdersDbContext.cs
│   └── Api/
│       └── OrdersEndpoints.cs
│
└── ECommerce.Modules.Users/        # Users module (Class Library)

Each module is a separate class library project. The Api host project references all modules, but modules reference only ECommerce.Shared — never each other directly.

The IModule Contract

Define a shared registration interface that every module implements:

// ECommerce.Shared/Modules/IModule.cs
public interface IModule
{
    string Name { get; }
    IServiceCollection RegisterServices(
        IServiceCollection services,
        IConfiguration configuration);
    WebApplication MapEndpoints(WebApplication app);
}

Module Registration in Program.cs

// ECommerce.Api/Program.cs
var builder = WebApplication.CreateBuilder(args);

// Each module self-registers
var modules = new IModule[]
{
    new CatalogModule(),
    new OrdersModule(),
    new UsersModule(),
};

foreach (var module in modules)
    module.RegisterServices(builder.Services, builder.Configuration);

builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssemblies(
        typeof(CatalogModule).Assembly,
        typeof(OrdersModule).Assembly,
        typeof(UsersModule).Assembly));

var app = builder.Build();

foreach (var module in modules)
    module.MapEndpoints(app);

app.Run();

DI Per Module

Each module’s RegisterServices method wires up its own dependencies in isolation:

// ECommerce.Modules.Catalog/CatalogModule.cs
public class CatalogModule : IModule
{
    public string Name => "Catalog";

    public IServiceCollection RegisterServices(
        IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddDbContext<CatalogDbContext>(opts =>
            opts.UseSqlServer(
                configuration.GetConnectionString("Catalog"),
                sql => sql.MigrationsHistoryTable("__EFMigrationsHistory", "catalog")));

        services.AddScoped<IProductRepository, ProductRepository>();
        services.AddScoped<ICatalogModule, CatalogModuleFacade>();

        return services;
    }

    public WebApplication MapEndpoints(WebApplication app)
    {
        app.MapGroup("/catalog").MapCatalogEndpoints();
        return app;
    }
}

Real-World Example: E-Commerce Order Placement

Let’s trace an order placement flow across modules.

The Cross-Module Contract

Orders needs to verify product prices from Catalog. It does this through a public facade interface — not by importing Catalog‘s internal types:

// ECommerce.Shared/Modules/ICatalogModule.cs
public interface ICatalogModule
{
    Task<ProductDto?> GetProductAsync(Guid productId);
}

public record ProductDto(Guid Id, string Name, decimal Price, bool IsAvailable);

Placing an Order

// ECommerce.Modules.Orders/Application/PlaceOrderCommandHandler.cs
public class PlaceOrderCommandHandler
    : IRequestHandler<PlaceOrderCommand, Guid>
{
    private readonly IOrderRepository _orders;
    private readonly ICatalogModule _catalog;       // cross-module contract
    private readonly IPublisher _publisher;          // MediatR publisher

    public PlaceOrderCommandHandler(
        IOrderRepository orders,
        ICatalogModule catalog,
        IPublisher publisher)
    {
        _orders = orders;
        _catalog = catalog;
        _publisher = publisher;
    }

    public async Task<Guid> Handle(
        PlaceOrderCommand command,
        CancellationToken cancellationToken)
    {
        var product = await _catalog.GetProductAsync(command.ProductId);

        if (product is null || !product.IsAvailable)
            throw new DomainException("Product not available.");

        var order = Order.Create(command.CustomerId, product.Id, product.Price);
        await _orders.AddAsync(order, cancellationToken);

        // Publish domain event — handled by other modules in-process
        await _publisher.Publish(
            new OrderPlacedEvent(order.Id, product.Id, command.CustomerId),
            cancellationToken);

        return order.Id;
    }
}

Handling the Event in Another Module

// ECommerce.Modules.Notifications/Application/SendOrderConfirmationHandler.cs
public class SendOrderConfirmationHandler
    : INotificationHandler<OrderPlacedEvent>
{
    private readonly IEmailService _email;
    private readonly IUsersModule _users;

    public async Task Handle(
        OrderPlacedEvent notification,
        CancellationToken cancellationToken)
    {
        var user = await _users.GetUserAsync(notification.CustomerId);
        await _email.SendOrderConfirmationAsync(user.Email, notification.OrderId);
    }
}

Orders publishes an event. Notifications handles it. Neither knows the other exists. This is the power of in-process messaging with MediatR — you get the decoupling benefits of a message bus without the operational overhead.

Common Pitfalls

1. The Shared Database Anti-Pattern

The most common failure mode: all modules share a single DbContext and freely query each other’s tables. You end up with JOINs across module boundaries, and suddenly changing a column in catalog.Products breaks 4 other modules.

The fix: One DbContext per module, separate DB schemas, zero cross-schema queries. If module B needs data from module A, it calls A’s public API — not its tables.

2. Tight Coupling Through Shared Domain Models

Referencing another module’s domain entities directly creates invisible coupling. If Orders imports Catalog.Domain.Product, you’ve created a compile-time dependency that defeats the entire architecture.

The fix: Shared contracts live in ECommerce.Shared as DTOs or interfaces. Domain models are private to their module.

3. Overengineering Small Applications

If your application is a CRUD API with 5 entities and no meaningful domain complexity, a modular monolith is overkill. You’re adding project structure, DI ceremony, and module facades for no real benefit.

The fix: Apply this architecture when you have genuine domain complexity, multiple teams, or a realistic expectation of growth. Don’t architect for hypothetical futures.

When to Choose Modular Monolith vs. Microservices

Choose a Modular Monolith when:

  • You’re building a new product and the domain isn’t fully understood yet
  • Team size is under ~15 engineers with 2–4 feature teams
  • Operational maturity for Kubernetes/service mesh isn’t there yet
  • Your workloads are relatively uniform in compute/memory needs
  • You value developer experience and fast local development

Choose Microservices when:

  • Specific services have vastly different scaling requirements (e.g., a search service vs. a checkout service)
  • You have large, autonomous teams that need independent deployment pipelines
  • You need polyglot persistence (different databases per service for legitimate reasons)
  • You’ve already extracted a clear, stable bounded context that rarely changes

The honest truth: Most applications should start as a modular monolith. If they grow to need microservices, the modular structure makes extraction far less painful than it would be from a traditional monolith.

Scaling and Evolution Strategy

One of the strongest arguments for this architecture is its evolutionary path. Because your modules already have clean boundaries, migrating a single module to a standalone service is a surgical operation, not a rewrite.

The extraction checklist for a module:

  1. ✅ The module already has its own DbContext and schema — point it at a separate database
  2. ✅ All cross-module communication already goes through interfaces — swap the in-process implementation for an HTTP or message-based adapter
  3. ✅ MediatR domain events switch from in-process IPublisher to publishing to a message broker (e.g., MassTransit + RabbitMQ)
  4. ✅ The module’s endpoints already exist — lift them into a new host project

In practice, extraction of a well-bounded module can take days, not months. Compare this to extracting from a traditional monolith where untangling shared state, circular dependencies, and cross-table queries can take quarters.

A practical evolution path looks like this:

Phase 1: Modular Monolith (single deployment, all modules)
    ↓  (Catalog service gets high read traffic)
Phase 2: Extract Catalog to a standalone service
         → Orders calls Catalog via HTTP
         → Events go through a message broker
    ↓  (Payments needs PCI compliance isolation)
Phase 3: Extract Payments to a standalone service
         → Now you have 3 deployable units

You scale surgically, not speculatively.

Key Takeaways

  • A modular monolith is not a traditional monolith — it’s a disciplined architecture with enforced module boundaries, data isolation, and clean communication contracts
  • The single biggest rule: no module accesses another module’s data store directly. Ever.
  • In .NET, implement this with separate class libraries per module, module-scoped DbContext instances, public facade interfaces in a shared contracts project, and MediatR for in-process event-driven communication
  • Avoid the trap of premature microservices — the operational complexity is real and often unjustified at early product stages
  • A well-built modular monolith is the best migration path to microservices if and when you genuinely need them
  • Start here by default. Migrate to microservices only when you have a specific, measurable reason to do so — not because it’s fashionable

The architecture world likes clean, binary choices. Microservices or monolith. Distributed or single process. But the most pragmatic choice is often in the middle: a system that’s well-structured enough to evolve, and simple enough to actually ship.

Have you migrated to or from a modular monolith? The edge cases are always where the real lessons live — reach out or drop a comment.