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)
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?
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.
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>withsubscribe_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.
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.
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.
All code compiles against Mosaik's feature/dynamic-predicate-reevaluation branch and Commonware v2026.2.0.
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]> },
}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;
}
}
}
}
}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;
}
}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.
// ...
}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()?;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.
| 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 |
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.
-
Multiple typed streams per sender: A sender can produce
BidandTransactionon separate streams simultaneously, each with differentsubscribe_ifpredicates -
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
-
No protocol-level changes: Simplex doesn't care what the block payload contains. The proposer's consensus logic is unchanged
-
Dynamic builder discovery: New builders publish
builder+tee-attestedtags, senders automatically discover and route to them -
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
| 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.
-
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_ifmatches zero peers. Need a fallback: timeout toBuilderOnly, or timeout toPublic -
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