Skip to content

Instantly share code, notes, and snippets.

@zmanian
Last active February 18, 2026 00:14
Show Gist options
  • Select an option

  • Save zmanian/a24ca70cffc7848c951c878eabe273bc to your computer and use it in GitHub Desktop.

Select an option

Save zmanian/a24ca70cffc7848c951c878eabe273bc to your computer and use it in GitHub Desktop.
Sender-Controlled Transaction Privacy & MEV Supply Chain via Mosaik Streams (with code sketch)

Sender-Controlled Transaction Privacy & MEV Supply Chain via Mosaik Streams

Design notes and working code sketch for integrating priority auctions, competitive builder marketplaces, and sender-controlled transaction privacy into Mosaik-based consensus architectures (Commonware Simplex, CometBFT).

Code: zmanian/commonware-mempool (compiles and runs)

Context

Mosaik provides typed Producer<T> / Consumer<T> streams with tag-based discovery and subscribe_if predicate re-evaluation. Combined with Commonware Simplex BFT, this gives us a dual-stack architecture where consensus traffic runs on Commonware's authenticated P2P and transaction dissemination runs on Mosaik streams.

The question: how do we layer MEV supply chain primitives -- builder marketplaces, priority auctions, and sender-controlled privacy -- on top of this?


Architecture Overview

Source (Public tx)     --Stream<Transaction>-->  Proposer (sees full tx)
Source (Private tx)    --Stream<Bid>--------->   Proposer (sees commitment + gas only)
Source (Private tx)    --Stream<Transaction>-->  Builder  (subscribe_if("builder" + "tee-attested"))
Builder                --Stream<BuilderBlock>->  Proposer (sealed block + bid, auction)
Proposer               --Stream<FinalizedBlock>->Source   (inclusion confirmation)

The proposer's tokio::select! loop processes 4 channels simultaneously: consensus messages from Simplex, public transactions, bids from privacy-conscious senders, and sealed blocks from builders. At proposal time, a simple PBS auction compares the best builder block's bid against the sum of individual priority fees.


MEV Integration Models

Model 1: Sidecar Builder (Lightest Touch)

Builders run as Mosaik-only nodes (like tx sources). They consume the same Stream<Transaction> as proposers, construct optimized bundles, and submit BuilderBundle back to the proposer on a separate stream.

Tx Sources --> Stream<Transaction> --> Proposer
                                  \--> Builder (Mosaik consumer)
                                         |
                                         v
                                  Stream<BuilderBundle> --> Proposer

The proposer runs a short auction window during leader_timeout (1s in Simplex): collect builder bundles, pick the highest-bidding one, include it at the top of the block. Transactions not covered by any bundle get appended in gas-price order.

Mosaik mapping:

  • Builders tag themselves "builder" in discovery
  • Proposer subscribes to Stream<BuilderBundle> with subscribe_if("builder")
  • Builders subscribe to Stream<Transaction> same as proposer (or via a relay)

Tradeoff: Simple. Builders see the same transactions as proposers. No privacy.

Model 2: Proposer-Builder Separation (PBS) -- Implemented Below

Full separation: proposers commit to builder-constructed blocks without seeing transaction content. Builders compete on block value.

Tx Sources --> Stream<Transaction> --> Builders (not proposer!)
                                         |
                                         v
                                  Stream<BuilderBlock> --> Proposer
                                    (sealed block + bid)

Proposer picks the highest bid, proposes the sealed block to consensus. Never sees individual transactions.

Tradeoff: Strongest MEV protection. Requires trust in builders (or TEE, see below). Adds latency from builder block construction.

Model 3: Priority Fee Queue (Simplest)

No separate builder role. Proposer runs an internal priority queue. Transactions include a priority_fee field. Proposer orders by fee, pockets the priority fees.

Tradeoff: Simplest. No MEV protection. Proposer can frontrun.


Code Sketch: Sender-Controlled Privacy

All code compiles against Mosaik's feature/dynamic-predicate-reevaluation branch and Commonware v2026.2.0.

Types (types.rs)

The core types that flow through the three privacy paths:

/// A transaction with priority fee for ordering.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Transaction {
    pub id: u64,
    pub sender: String,
    pub payload: Vec<u8>,
    pub gas_limit: u64,
    pub nonce: u64,
    pub priority_fee: u64,
    pub received_at: u64,
}

/// A bid is what the proposer sees when a sender opts for privacy.
/// Contains only the commitment hash, the gas bid, and the sender identity.
/// The proposer never sees the transaction payload.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Bid {
    pub tx_id: u64,
    /// SHA256(Transaction.to_bytes()) -- binding commitment to the full tx.
    pub commitment: [u8; 32],
    pub gas_bid: u64,
    pub priority_fee: u64,
    pub sender: String,
}

/// A sealed block produced by a builder. The proposer selects the
/// highest-bidding BuilderBlock and proposes it to Simplex consensus
/// without ever seeing individual transaction content.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BuilderBlock {
    pub bid: u64,
    pub tx_ids: Vec<u64>,
    pub block_body: Vec<u8>,         // Opaque serialized ordered transactions
    pub commitment: [u8; 32],        // SHA256 of block_body
    pub builder_id: String,
    pub tee_attestation: Option<Vec<u8>>,  // TEE quote binding block to enclave
}

/// Sender-side privacy policy. Each transaction independently chooses
/// its visibility level.
#[derive(Clone, Debug)]
pub enum TxPrivacy {
    /// Proposer sees full transaction (no privacy).
    Public,
    /// Proposer sees bid only. All registered builders see the full tx.
    BuilderOnly,
    /// Proposer sees bid only. Only TEE-attested builders see the full tx.
    TeeOnly { allowed_measurements: Vec<[u8; 32]> },
}

Source Node with Dual-Stream Production (source.rs)

The source node maintains three producers -- one for each privacy path -- and routes each transaction according to its TxPrivacy setting:

pub struct TxSourceNode {
    pool: HashMap<u64, PoolEntry>,

    // Public path: full tx directly to proposer
    tx_producer: Producer<Transaction>,

    // Private path: bid to proposer, full tx to builders
    bid_producer: Producer<Bid>,
    builder_tx_producer: Producer<Transaction>,

    block_consumer: Consumer<FinalizedBlock>,
    repropose_interval_ms: u64,
    name: String,
}

impl TxSourceNode {
    /// Submit a public transaction (proposer sees full content).
    pub async fn submit_public(&mut self, tx: Transaction) -> Result<(), anyhow::Error> {
        let tx_id = tx.id;
        self.tx_producer.send(tx.clone()).await?;
        self.pool.insert(tx_id, PoolEntry { tx, privacy: TxPrivacy::Public });
        Ok(())
    }

    /// Submit a private transaction. Proposer sees only a Bid.
    /// Full content goes only to builders matching the privacy policy.
    pub async fn submit_private(
        &mut self, tx: Transaction, privacy: TxPrivacy,
    ) -> Result<(), anyhow::Error> {
        let bid = Self::make_bid(&tx);
        self.bid_producer.send(bid).await?;            // commitment + gas to proposer
        self.builder_tx_producer.send(tx.clone()).await?; // full tx to builders
        self.pool.insert(tx.id, PoolEntry { tx, privacy });
        Ok(())
    }

    /// Construct a Bid: SHA256 commitment binding to the full tx, no payload.
    fn make_bid(tx: &Transaction) -> Bid {
        let mut hasher = Sha256::default();
        hasher.update(&tx.to_bytes());
        let digest = hasher.finalize();
        let mut commitment = [0u8; 32];
        commitment.copy_from_slice(&digest[..32]);
        Bid {
            tx_id: tx.id, commitment,
            gas_bid: tx.gas_limit, priority_fee: tx.priority_fee,
            sender: tx.sender.clone(),
        }
    }

    /// Re-produce unincluded txs via their original privacy path.
    async fn repropose_all(&mut self) {
        for entry in self.pool.values() {
            match entry.privacy {
                TxPrivacy::Public => {
                    let _ = self.tx_producer.send(entry.tx.clone()).await;
                }
                TxPrivacy::BuilderOnly | TxPrivacy::TeeOnly { .. } => {
                    let bid = Self::make_bid(&entry.tx);
                    let _ = self.bid_producer.send(bid).await;
                    let _ = self.builder_tx_producer.send(entry.tx.clone()).await;
                }
            }
        }
    }
}

Builder Node with TEE Attestation Tags (builder.rs)

Builders are Mosaik-only nodes that register themselves via discovery tags:

pub struct BuilderNode {
    name: String,
    discovery: Discovery,
    secret_key: SecretKey,
    tx_consumer: Consumer<Transaction>,
    block_producer: Producer<BuilderBlock>,
    pending: BTreeMap<PriorityKey, Transaction>,  // sorted by priority fee
    build_interval_ms: u64,
    max_txs_per_block: usize,
    tee_attestation: Option<TeeAttestation>,
}

pub struct TeeAttestation {
    pub measurement: [u8; 32],  // MRENCLAVE / launch digest / etc.
    pub quote: Vec<u8>,         // Remote attestation quote
}

impl BuilderNode {
    /// Register in Mosaik discovery with "builder" + optional "tee-attested:..." tags.
    pub fn register_tags(&self) -> anyhow::Result<()> {
        let entry: PeerEntry = self.discovery.me().into_unsigned();
        let mut updated = entry.add_tags(Tag::from("builder"));

        if let Some(ref att) = self.tee_attestation {
            let tag = format!("tee-attested:{}", hex::encode(att.measurement));
            updated = updated.add_tags(Tag::from(tag.as_str()));
        }

        let signed = updated.sign(&self.secret_key)?;
        self.discovery.feed(signed);
        Ok(())
    }

    /// Run loop: consume full txs, periodically build sealed blocks.
    pub async fn run(mut self) {
        let mut interval = tokio::time::interval(
            std::time::Duration::from_millis(self.build_interval_ms)
        );
        loop {
            tokio::select! {
                Some(tx) = self.tx_consumer.next() => {
                    let key = PriorityKey::new(tx.priority_fee, tx.id);
                    self.pending.insert(key, tx);
                }
                _ = interval.tick() => {
                    if !self.pending.is_empty() {
                        self.build_and_submit().await;
                    }
                }
            }
        }
    }

    /// Drain highest-priority txs into a sealed BuilderBlock.
    async fn build_and_submit(&mut self) {
        let take = self.pending.len().min(self.max_txs_per_block);
        let included: Vec<Transaction> = self.pending.iter()
            .take(take).map(|(_, tx)| tx.clone()).collect();
        // ... remove drained keys ...

        let total_bid: u64 = included.iter().map(|tx| tx.priority_fee).sum();
        let block_body = serde_json::to_vec(&included).unwrap();

        let mut hasher = Sha256::default();
        hasher.update(&block_body);
        let digest = hasher.finalize();
        let mut commitment = [0u8; 32];
        commitment.copy_from_slice(&digest[..32]);

        let _ = self.block_producer.send(BuilderBlock {
            bid: total_bid,
            tx_ids: included.iter().map(|tx| tx.id).collect(),
            block_body, commitment,
            builder_id: self.name.clone(),
            tee_attestation: self.tee_attestation.as_ref().map(|a| a.quote.clone()),
        }).await;
    }
}

Proposer's PBS Auction (mempool.rs)

The MempoolActor's tokio::select! loop now processes 4 channels. At proposal time, it runs a simple auction:

pub struct MempoolActor {
    // ... consensus fields ...
    tx_consumer: Consumer<Transaction>,              // public txs
    bid_consumer: Consumer<Bid>,                     // private bids
    builder_block_consumer: Consumer<BuilderBlock>,  // sealed builder blocks
    block_producer: Producer<FinalizedBlock>,         // inclusion confirmation
    pending: VecDeque<Transaction>,
    pending_bids: VecDeque<Bid>,
    best_builder_block: Option<BuilderBlock>,
}

// In the run loop:
tokio::select! {
    msg = self.mailbox.recv() => { /* consensus messages */ }

    tx = self.tx_consumer.next() => {
        // Public path: full transactions
        self.pending.push_back(tx);
    }

    bid = self.bid_consumer.next() => {
        // Private path: commitment + gas, no payload
        self.pending_bids.push_back(bid);
    }

    builder_block = self.builder_block_consumer.next() => {
        // PBS path: keep the highest-bidding builder block
        if bb.bid > self.best_builder_block.map_or(0, |b| b.bid) {
            self.best_builder_block = Some(bb);
        }
    }
}

// At proposal time:
let individual_fee: u64 = self.pending.iter().take(MAX_TXS_PER_BLOCK)
    .map(|tx| tx.priority_fee).sum::<u64>()
    + self.pending_bids.iter().take(MAX_TXS_PER_BLOCK)
        .map(|b| b.priority_fee).sum::<u64>();

let builder_bid = self.best_builder_block.as_ref().map_or(0, |bb| bb.bid);

if builder_bid > individual_fee {
    // PBS path: use the builder's sealed block.
    // Proposer never saw individual tx content.
    let bb = self.best_builder_block.take().unwrap();
    hasher.update(b"builder-block");
    hasher.update(&bb.commitment);
    // ...
} else {
    // Proposer-constructed block from public txs + bids.
    // Bids are included by commitment for later reveal.
    // ...
}

Stream Wiring (main.rs)

On the validator side, three consumers feed the MempoolActor:

// Public path: full transactions from sources
let tx_consumer = mosaik_net.streams().consumer::<Transaction>().build();

// Private path: bids (commitment + gas, no payload)
let bid_consumer = mosaik_net.streams().consumer::<Bid>().build();

// PBS path: sealed blocks from builders
let builder_block_consumer = mosaik_net.streams()
    .consumer::<BuilderBlock>().build();

let (actor, mailbox, reporter) = MempoolActor::new(
    num_participants, 1024,
    tx_consumer, bid_consumer, builder_block_consumer,
    block_producer, view_tx,
);

On the source side, subscribe_if predicates control routing:

// Public txs go to the proposer
let tx_producer = network.streams().producer::<Transaction>()
    .accept_if(|peer| peer.tags().contains(&Tag::from("proposer")))
    .build()?;

// Bids also go to the proposer (commitment only, no payload)
let bid_producer = network.streams().producer::<Bid>()
    .accept_if(|peer| peer.tags().contains(&Tag::from("proposer")))
    .build()?;

// Full txs go only to TEE-attested builders
let builder_tx_producer = network.streams().producer::<Transaction>()
    .accept_if(|peer| {
        peer.tags().contains(&Tag::from("builder"))
        && peer.tags().iter().any(|t| /* starts with "tee-attested:" */)
    })
    .build()?;

TEE Attestation as Discovery Tags (bridge.rs)

pub const TAG_BUILDER: &str = "builder";
pub const TAG_TEE_ATTESTED_PREFIX: &str = "tee-attested:";

// Builder at startup:
let entry = discovery.me().into_unsigned()
    .add_tags(Tag::from(TAG_BUILDER))
    .add_tags(Tag::from(format!("{}{}",
        TAG_TEE_ATTESTED_PREFIX, hex::encode(measurement)
    ).as_str()));
let signed = entry.sign(&secret_key)?;
discovery.feed(signed);

When a builder's attestation expires, the tag is removed, and Mosaik's predicate re-evaluation automatically stops routing full transactions to it -- same mechanism that handles proposer rotation.


Trust Model

Party Sees Trusts
Sender Everything (own txs) TEE attestation of builder
Builder (TEE) Full transactions Enclave integrity (can't exfiltrate)
Proposer Bids only (or BuilderBlocks) Builder's commitment hash
Validators Finalized block (post-execution) Consensus + TEE attestation chain

Timing Constraints

With Simplex's leader_timeout of 1s:

Phase Budget What Happens
Tx collection ~400ms Sources produce bids + full txs
Builder auction ~400ms Builders construct blocks, submit bids
Block proposal ~200ms Proposer picks winning builder, proposes to consensus

The 3-tier tag system (proposer / proposer-next / proposer-soon) pre-warms connections to upcoming proposers and builders, eliminating connection setup latency during fast BFT view rotation.


Why Mosaik Makes This Work

  1. Multiple typed streams per sender: A sender can produce Bid and Transaction on separate streams simultaneously, each with different subscribe_if predicates

  2. Tag-based attestation filtering: TEE attestation status is just another discovery tag. Predicate re-evaluation means if a builder's attestation expires, senders automatically stop routing full transactions to it

  3. No protocol-level changes: Simplex doesn't care what the block payload contains. The proposer's consensus logic is unchanged

  4. Dynamic builder discovery: New builders publish builder + tee-attested tags, senders automatically discover and route to them

  5. Source-retains-ownership: Senders keep transactions in local pools until confirmed. If a builder goes offline, unincluded txs get re-routed to remaining valid builders

Mapping to Existing MEV Infrastructure

Flashbots Concept Mosaik Equivalent
mev-boost relay Mosaik stream router (tag-filtered)
Builder API Stream<BuilderBlock> typed stream
Searcher bundle Stream<Transaction> to builder
Block auction Builder subscribe_if("proposer") + bid comparison
Proposer commitment Simplex consensus proposal of winning BuilderBlock
TEE block builder Builder with tee-attested discovery tag

The key difference: Mosaik makes the routing dynamic and sender-controlled rather than relay-mediated. There's no central relay -- senders route directly to builders matching their privacy requirements via subscribe_if predicates over discovery tags.


Open Design Questions

  • Attestation verification: Who verifies the TEE attestation quote is genuine? Options: (a) sender verifies before routing, (b) a separate attestation oracle publishes verified builder lists, (c) trust the tag publisher (weakest)

  • Bid-to-block binding: How does the proposer verify a builder's block actually contains the transactions matching the bids? Post-execution verification works but adds latency. TEE attestation of the block construction process provides stronger guarantees

  • Encrypted vs blinded: Full encryption (to builder's enclave key) vs commit-reveal (sender publishes commitment, reveals after inclusion). Encryption is simpler with TEE; commit-reveal works without TEE but adds a round

  • Builder liveness: If no TEE-attested builder is available, sender's subscribe_if matches zero peers. Need a fallback: timeout to BuilderOnly, or timeout to Public

  • Cross-builder competition: Multiple builders construct competing blocks from the same transaction pool. TEE isolation means each builder's strategy runs inside its own enclave

  • Attestation freshness: TEE attestation quotes have expiry. The tee-attested: tag should include a timestamp or nonce. There's a window between attestation expiry and tag removal via gossip

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment