FABRKNT
Build OpenHL Precompiles — connecting CLOB state to smart contracts
Bridge integration
Lesson 10 of 12·CONTENT40 min80 XP

Treat this page as a workbench, not a blog post. The goal is to extract a reusable mental model from the source and carry it into the rest of the Fabrknt stack.

Course
Build OpenHL Precompiles — connecting CLOB state to smart contracts
Lesson role
CONTENT
Sequence
10 / 12

Lesson 9 — install_fill_sink — fills flow back to the bridge

Question

When an order fills, the matching engine needs to notify the Bridge contract (so balances update). install_fill_sink registers a callback that the matching engine fires on every fill; the precompile layer routes it to the Bridge.

Principle (minimum model)

  • install_fill_sink(sink: Arc<dyn Fn(Fill) + Send + Sync>). Registers a global callback fired by the matching engine on every fill.
  • The sink itself. A function that takes a Fill { account, market, size, price, side, fee } and writes to a queue that the Bridge contract reads via another precompile.
  • Why a callback, not polling. Polling has latency (1 block = ~1s). Callback fires immediately; the Bridge contract sees the fill on the same block.
  • Thread safety. The matching engine runs on its own thread; the sink is called from there. Send + Sync bounds ensure the callback can be invoked safely.
  • Bridge integration. The sink writes to a FillQueue<Address, Fill> (per-account queue). A second precompile (clob_read_fills) lets the Bridge contract drain the queue per call.
  • Idempotency. If the Bridge contract calls clob_read_fills twice, the second call returns empty (the queue is drained). Idempotent — safe for replay.
  • Tests. Submit an order; let it fill; assert the fill appears in the queue; assert clob_read_fills returns it; assert the second call returns empty.

Worked example + steps

Lesson 9 — install_fill_sink — fills flow back to the bridge

Goal

Concepts you'll grasp in this lesson:

  • The shared-buffer pattern generalizes — Lesson 4's Arc<Mutex<T>> + process-global pattern for CLOB state is reused 1:1 for fills. Once the primitive is in place, additional shared state costs ~20 lines per buffer. The Lesson 4 abstraction pays compound interest.
  • Orthogonal globals = orthogonal test setup — bundling CLOB_STATE and FILL_SINK into one global would force every test to install both. Two separate globals keep test setup composable; only install what your test actually exercises.
  • Early-out on the common case is freeif !submit_result.fills.is_empty() skips lock acquisition when the order rests without crossing (the dominant case). One branch in the hot path saves an RwLock acquisition.
  • drop(book) before pushing to the sink — release the inner lock before taking the outer one — holding both locks (Book + sink) at once would create a lock-ordering hazard. Explicitly dropping the Book guard keeps lock acquisition strictly sequential.
  • Doc-comment-as-debt-tracker — Lesson 8's "fills are discarded" doc comment was load-bearing: it told future readers "deliberate gap, not oversight." Lesson 9 closes the gap and updates the doc. A documented gap is half-fixed; an undocumented gap is invisible debt.

Verification:

cargo test -p openhl-evm --release

…passes 47 tests (1 new).

Specific changes:

The "fills are discarded" gap from Lesson 8's doc comment is closed:

  • FILL_SINK static added — parallel to CLOB_STATE, holds Option<Arc<Mutex<Vec<Fill>>>>.
  • install_fill_sink / uninstall_fill_sink module fns — public, mirror the install_clob / uninstall_clob pattern.
  • place_order extendedlet submit_result = book.submit(...) (was _result); after drop(book), if the sink is installed, push the produced fills into it.
  • LiveRethEvmBridge::pending_fills changes from Mutex<Vec<Fill>> to Arc<Mutex<Vec<Fill>>>. The bridge's new() now calls install_fill_sink(Arc::clone(&pending_fills)) alongside install_clob.
  • New unit test place_order_routes_fills_to_installed_sink — exercises the maker/taker cross and verifies the sink receives a fill.

E2E cyclic data routing topology (the loop closes at Stage 9c+)

Lesson 9 is the moment the order → Book → Fill → payload loop finally closes. The two writers — on-chain (EVM precompile) and off-chain (bridge) — converge on the same Book and the same pending_fills:

 ── Input path 1: on-chain (Solidity → EVM) ────────────────────────────────
   Solidity contract → call(0x...0c1c, abi.encode(account, side, price, qty))
                                       │
                                       ▼
   Reth EVM dispatch → place_order  [Lesson 7 parse + Lesson 8 submit + Lesson 9 fill routing]
                                       │
                                       │ clob.lock().submit(Order{…})
                                       │     → SubmitResult { fills: Vec<Fill>, … }
                                       │
                                       ▼ (1) mutate Book      (2) extend fills into sink
 ── Input path 2: off-chain (App / RPC → bridge) ───────────────────────────
   App / RPC → bridge.submit_order(…)              [pre-existing Course 7 path]
                                       │
                                       │ self.clob.lock().submit(…)
                                       │     → writes fills directly to self.pending_fills
                                       ▼
 ── Shared Book (convergence point ① for both writers) ─────────────────────
   ┌─────────────────────────────────────────────────────────────────────┐
   │ Arc<Mutex<Book>>                                                    │
   │   ▲ static CLOB_STATE references it (installed in Lesson 4)          │
   │   ▲ bridge.clob shares the same Arc → both writers see one Book     │
   └─────────────────────────────────────────────────────────────────────┘

 ── Shared Fill buffer (convergence point ② for both writers) ──────────────
   ┌─────────────────────────────────────────────────────────────────────┐
   │ Arc<Mutex<Vec<Fill>>>                                               │
   │   ▲ static FILL_SINK references it (installed in Lesson 9 — new)    │
   │   ▲ bridge.pending_fills shares the same Arc (Lesson 9 upgrades it from │
   │     Mutex to Arc<Mutex>)                                            │
   │                                                                     │
   │   Write path A: place_order extends via FILL_SINK                   │
   │   Write path B: bridge.submit_order pushes directly into            │
   │                 pending_fills                                       │
   │   (Both writes land in the same Vec<Fill> — only one Arc exists)    │
   └────────────────────────────────┬────────────────────────────────────┘
                                    │ self.pending_fills.lock().drain(..)
                                    ▼
 ── Exit: block payload ────────────────────────────────────────────────────
   bridge.build_payload()  →  drain pending fills, attach to block
                                    │
                                    ▼
                            next Reth block

What makes the loop cyclic (loop closure):

  • Two writers (on-chain place_order / off-chain bridge.submit_order), one reader (the build_payload drain)
  • Both writers reach the same physical Arc<Mutex<Vec<Fill>>> — the global (FILL_SINK) and the bridge field (pending_fills) clone the same Arc, in perfect mirror of the Lesson 4 "two holders, one Arc" pattern
  • Module 4's core property finally holds: "fills from EVM-placed orders ride into the block alongside fills from bridge-placed orders, indistinguishable from each other"
  • Lesson 4's Arc<Mutex<Book>> distribution gets a complete mirror in Lesson 9's Arc<Mutex<Vec<Fill>>>install_clobinstall_fill_sink, bridge.clobbridge.pending_fills, every piece is symmetric

After Lesson 9, the precompile and the bridge are no longer write-side independent. EVM-placed orders produce fills that flow into the same pending_fills queue that bridge-side submit_order writes to. As the topology above shows, this convergence is what lets on-chain liquidity changes propagate uninterrupted into the next build_payload and onwards into the block.

Recap

Lesson 8 closed Stage 9c proper: place_order now writes to the book, and the round-trip via place_order → read_best_bid is proven. But Lesson 8's doc comment named a gap:

Side note: the fills returned by Book::submit are discarded here. Production-shape integration would route them through the bridge's pending_fills so they reach the next build_payload.

That gap was deliberate — Stage 9c shipped without it to keep the diff focused. Stage 9c+ closes it.

Plan

Five edits to crates/evm/src/precompiles/mod.rs + 2 edits to crates/evm/src/live_node.rs:

  1. Import Fill in precompiles/mod.rs (and in live_node.rs if not already present).
  2. Add the FILL_SINK static and the 2 install/uninstall module fns.
  3. Inside place_order — rename _result to submit_result, then push submit_result.fills into the sink (if installed) after the Book lock is dropped.
  4. Update place_order doc comment — remove the "fills are discarded" side note, replace with the Stage 9c+ behavior.
  5. Add the unit test place_order_routes_fills_to_installed_sink.

For live_node.rs:

  1. Change pending_fills field from Mutex<Vec<Fill>> to Arc<Mutex<Vec<Fill>>>.
  2. Update new() — bind pending_fills as an Arc, call install_fill_sink(Arc::clone(&pending_fills)) alongside the existing install_clob.

(Answer: The precompile is a fn pointer; it can't capture a reference to the bridge. Option (a) would require giving the precompile an &Bridge somehow, which is the same function-pointer-capture problem we solved with the CLOB_STATE global. Option (b) would require the bridge to know it should poll — a clear separation-of-concerns violation. Option (c) is the same pattern: bridge owns the buffer, precompile sees it through a global. Once the architecture for shared CLOB state is in place, shared fill state is the natural extension.)

Walk-through

Step 1: Import Fill

In crates/evm/src/precompiles/mod.rs, the current import is:

use openhl_clob::{AccountId, Book, Order, OrderId, OrderType, Price, Qty, Side};

Add Fill:

use openhl_clob::{AccountId, Book, Fill, Order, OrderId, OrderType, Price, Qty, Side};

Fill is a value type defined in crates/clob/src/lib.rs (from course 7). It has price: Price and qty: Qty fields (and possibly more — maker_order_id, taker_order_id, etc., but the test below only inspects price and qty). Copy-able, so passing fills around is cheap.

In crates/evm/src/live_node.rs, the Fill import is already present (the existing pending_fills field uses it). No change there yet.

Step 2: Add FILL_SINK + install/uninstall fns

After uninstall_clob:

/// Process-global handle to the buffer where the precompile pushes fills.
///
/// Same lifecycle rules as `CLOB_STATE`: installed by `LiveRethEvmBridge::new`,
/// none until set. When set, `place_order` extends this buffer with any fills
/// produced by the matched order, so production-shape EVM-placed orders flow
/// into the next `build_payload`'s drained fills exactly like bridge-side
/// `submit_order` does.
static FILL_SINK: RwLock<Option<Arc<Mutex<Vec<Fill>>>>> = RwLock::new(None);

/// Install the `pending_fills` buffer the precompile should write to.
/// Companion to `install_clob`. Calling this replaces any previously-installed
/// sink.
pub fn install_fill_sink(sink: Arc<Mutex<Vec<Fill>>>) {
    *FILL_SINK.write().expect("FILL_SINK rwlock poisoned") = Some(sink);
}

/// Clear the installed fill sink. Test-only typical use; idempotent.
pub fn uninstall_fill_sink() {
    *FILL_SINK.write().expect("FILL_SINK rwlock poisoned") = None;
}

The static is an exact structural parallel to CLOB_STATE:

  • CLOB_STATE: RwLock<Option<Arc<Mutex<Book>>>> — outer install/uninstall lock, inner Book lock.
  • FILL_SINK: RwLock<Option<Arc<Mutex<Vec<Fill>>>>> — outer install/uninstall lock, inner buffer lock.

Same lifecycle, same lock-layering rationale (from Lesson 4 §Design reflection 2): RwLock for the rare install/uninstall write, Mutex for the frequent buffer writes.

install_fill_sink and uninstall_fill_sink mirror their CLOB counterparts: 1-line bodies, both pub fn. The doc comments name the lifecycle ("by LiveRethEvmBridge::new") so readers tracing the code know who's expected to call them.

Step 3: Extend place_order to push fills

Lesson 8's body had:

    let mut book = clob.lock().expect("clob mutex poisoned");
    let _result = book.submit(Order {
        id: OrderId(order_id_val),
        account: AccountId(account_id),
        side,
        qty: Qty(qty_value),
        order_type: OrderType::Limit {
            price: Price(price_value),
        },
    });
    drop(book);

    out[24..32].copy_from_slice(&order_id_val.to_be_bytes());

Change to:

    let mut book = clob.lock().expect("clob mutex poisoned");
    let submit_result = book.submit(Order {
        id: OrderId(order_id_val),
        account: AccountId(account_id),
        side,
        qty: Qty(qty_value),
        order_type: OrderType::Limit {
            price: Price(price_value),
        },
    });
    drop(book);

    // Stage 9c+: route any fills produced by this order through the bridge's
    // pending_fills buffer so they reach the next `build_payload`. Drops
    // silently if no sink is installed (consistent with no-CLOB → return 0).
    if !submit_result.fills.is_empty() {
        let sink_state = FILL_SINK.read().expect("FILL_SINK rwlock poisoned");
        if let Some(sink) = sink_state.as_ref() {
            sink.lock()
                .expect("fill_sink mutex poisoned")
                .extend(submit_result.fills.iter().copied());
        }
    }

    out[24..32].copy_from_slice(&order_id_val.to_be_bytes());

Three changes:

  1. _resultsubmit_result. Per Lesson 8's design reflection ("_result is a future-intent marker"), this is now its future. The underscore goes away; the binding is used.
  2. The if !submit_result.fills.is_empty() early-out. When the order rested without crossing (no fills produced), we skip the lock acquisition entirely. Common case for resting limits → no fill-sink traffic.
  3. sink_state.as_ref().map(|sink| sink.lock()...extend(...)) pattern. Same shape as current_best_bid's read pattern (Step 4 of Lesson 4): hold the outer read lock briefly to access the inner Arc, then acquire the inner Mutex.

submit_result.fills.iter().copied()Fill is Copy, so .iter().copied() produces an owned-fill iterator. Cheaper than .into_iter() because submit_result may have other fields we don't want to consume. Iterating-by-copy keeps the source intact.

(Answer: Behavior would be identical, but performance would suffer in the no-fill case. Every place_order call that rests without crossing — the common case for limit orders — would acquire a FILL_SINK read lock just to find out there's nothing to push. The guard short-circuits that. Early-out on the common case is a free win. This is a hot path; the cost of the unnecessary lock acquisition would compound.)

Step 4: Update the place_order doc comment

Lesson 8's bottom paragraph said:

/// Side note: the fills returned by `Book::submit` are discarded here.
/// Production-shape integration would route them through the bridge's
/// `pending_fills` so they reach the next `build_payload`. At v0 the
/// precompile and the bridge are write-side independent.

Replace with:

/// Stage 9c+ (this commit): any fills produced by the submit are pushed into
/// the `FILL_SINK` global if installed. This is what makes EVM-placed orders
/// flow into the bridge's `pending_fills` and out via `build_payload`,
/// matching the bridge-side `submit_order` semantics. If no sink is
/// installed the fills are still produced (visible via subsequent
/// `read_best_bid`) but won't reach a payload.

Two things named:

  1. The "what changed" line — "Stage 9c+ (this commit)". When a reader skims this doc 6 months later they'll know exactly what version of the code is doing this.
  2. The fallback semantic — "if no sink is installed the fills are still produced." Crucial for test isolation: the round-trip test from Lesson 8 doesn't install a sink, but place_order_then_read_best_bid_round_trips still works because fills land in the Book regardless. Naming the fallback in the doc comment means tests that don't care about fills don't have to install a sink just to keep place_order happy.

Step 5: Add the unit test

After place_order_then_read_best_bid_round_trips, in the #[cfg(test)] mod tests block:

    /// **Stage 9c+**: when a `FILL_SINK` is installed alongside the CLOB,
    /// fills produced by a `place_order` call flow into the sink. This is the
    /// hook the bridge relies on to surface EVM-placed fills in the next
    /// `build_payload`. With no sink installed, fills are still produced but
    /// silently dropped — verified by the round-trip test above (which never
    /// installs a sink yet still observes book state changes).
    #[test]
    fn place_order_routes_fills_to_installed_sink() {
        let _g = TEST_SERIALIZER.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
        let book = Arc::new(Mutex::new(Book::new()));
        let sink: Arc<Mutex<Vec<Fill>>> = Arc::new(Mutex::new(Vec::new()));
        install_clob(book);
        install_fill_sink(Arc::clone(&sink));

        // Maker: Buy @ 100, qty 10. Rests, no fill.
        let maker = place_order_calldata(1, 0, 100, 10);
        let r = place_order(&maker, 100_000, 0).unwrap();
        assert!(U256::from_be_slice(&r.bytes[0..32]) > U256::ZERO);
        assert!(sink.lock().unwrap().is_empty(), "no fills after resting maker");

        // Taker: Sell @ 100, qty 10. Crosses the maker → exactly one fill.
        let taker = place_order_calldata(2, 1, 100, 10);
        let r = place_order(&taker, 100_000, 0).unwrap();
        assert!(U256::from_be_slice(&r.bytes[0..32]) > U256::ZERO);

        let fills = sink.lock().unwrap().clone();
        assert_eq!(fills.len(), 1, "exactly one fill from the crossing taker");
        assert_eq!(fills[0].price, Price(100));
        assert_eq!(fills[0].qty, Qty(10));

        uninstall_fill_sink();
        uninstall_clob();
    }

The test's shape:

  1. SetupTEST_SERIALIZER + install both CLOB and sink. Hold sink (an Arc clone) for inspection.
  2. Resting maker — a Buy @ 100 that doesn't cross anything (book is empty). Should produce zero fills. Sink remains empty.
  3. Crossing taker — a Sell @ 100 that crosses the resting Buy. The maker exits the book, the taker is fully matched → exactly one Fill.
  4. Inspect the sinkclone() the Vec out so the assertion happens without holding the Mutex. Verify length, price, qty.
  5. Cleanup — both uninstall calls in reverse install order.

Why a maker + taker pair, not just a single submit? Because Book::submit only produces fills when the new order crosses existing orders. A solo submit on an empty book produces zero fills. To test the routing logic, we need at least one fill to actually route. The maker rests; the taker crosses → 1 fill — minimum test data.

(Answer: The fill is produced inside the Book but not pushed anywhere — the precompile's if !submit_result.fills.is_empty() guard hits, but FILL_SINK.read() returns None, so the inner block doesn't execute. The order is still on/off the book correctly; only the flow to the bridge is missing. This is the "still works for solo tests" property called out in the doc comment. Lesson 8's round-trip test relies on this — it never installs a sink and still observes correct best-bid behavior.)

Step 6: live_node.rs — pending_fills as Arc

Open crates/evm/src/live_node.rs. The current struct (from Lesson 4):

pub struct LiveRethEvmBridge<P> {
    provider: P,
    chain_spec: Arc<ChainSpec>,
    validator: EthBeaconConsensus<ChainSpec>,
    clob: Arc<Mutex<Book>>,
    pending_fills: Mutex<Vec<Fill>>,
    state: Mutex<State>,
}

Change pending_fills:

pub struct LiveRethEvmBridge<P> {
    provider: P,
    chain_spec: Arc<ChainSpec>,
    validator: EthBeaconConsensus<ChainSpec>,
    clob: Arc<Mutex<Book>>,
    /// Same shared-Arc pattern as `clob`: the precompile module's `FILL_SINK`
    /// global points at this buffer too, so fills produced by EVM-placed
    /// orders (via `clob_place_order`) flow into the same queue the bridge's
    /// own `submit_order` writes to (Stage 9c+).
    pending_fills: Arc<Mutex<Vec<Fill>>>,
    state: Mutex<State>,
}

Doc comment explains the architectural symmetry — pending_fills and clob are now both shared-Arc pattern. Anyone tracing the type and seeing the Arc will know there's a global pointing at it too.

Step 7: Update LiveRethEvmBridge::new

Current new (after Lesson 4):

    pub fn new(provider: P, chain_spec: Arc<ChainSpec>) -> Self {
        let validator = EthBeaconConsensus::new(Arc::clone(&chain_spec));
        let clob = Arc::new(Mutex::new(Book::new()));

        // Make our CLOB visible to the `clob_read_best_bid` precompile so
        // smart contracts can query live orderbook state. The bridge writes
        // (submit_order), the EVM reads (precompile); they share the same Arc.
        crate::precompiles::install_clob(Arc::clone(&clob));

        Self {
            provider,
            chain_spec,
            validator,
            clob,
            pending_fills: Mutex::new(Vec::new()),
            state: Mutex::new(State::default()),
        }
    }

Change to:

    pub fn new(provider: P, chain_spec: Arc<ChainSpec>) -> Self {
        let validator = EthBeaconConsensus::new(Arc::clone(&chain_spec));
        let clob = Arc::new(Mutex::new(Book::new()));
        let pending_fills = Arc::new(Mutex::new(Vec::new()));

        // Make our CLOB visible to the `clob_read_best_bid` precompile so
        // smart contracts can query live orderbook state. The bridge writes
        // (submit_order), the EVM reads (precompile); they share the same Arc.
        crate::precompiles::install_clob(Arc::clone(&clob));

        // Route fills produced by the `clob_place_order` precompile into the
        // same queue `submit_order` writes to. Without this, EVM-placed orders
        // would match but their fills would be silently dropped (Stage 9c+).
        crate::precompiles::install_fill_sink(Arc::clone(&pending_fills));

        Self {
            provider,
            chain_spec,
            validator,
            clob,
            pending_fills,
            state: Mutex::new(State::default()),
        }
    }

Three changes:

  1. let pending_fills = Arc::new(Mutex::new(Vec::new())); — bind the Arc to a local, same shape as let clob = ... above.
  2. crate::precompiles::install_fill_sink(Arc::clone(&pending_fills)); — share the Arc with the precompile module. Mirrors install_clob.
  3. Struct literal pending_fills, (no Mutex::new(Vec::new()) inline) — just use the local.

Other call sites that use self.pending_fills (e.g., pending_fill_count(), the drain in build_payload) keep working — Arc<Mutex<T>> derefs to &Mutex<T>, so self.pending_fills.lock() is unchanged. Same coercion that kept submit_order working when we Arc-wrapped clob in Lesson 4.

Test

cargo test -p openhl-evm --release

After ~30 seconds:

running 47 tests
... 47 tests pass ...

test result: ok. 47 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

One more than Lesson 8 (46 → 47). The new one is place_order_routes_fills_to_installed_sink. To see only it:

cargo test -p openhl-evm --release routes_fills

Output:

running 1 test
test precompiles::tests::place_order_routes_fills_to_installed_sink ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 46 filtered out

Common errors and fixes:

  • error[E0277]: 'Vec<Fill>' is not 'Arc<Mutex<Vec<Fill>>>' in live_node.rs — you forgot to wrap pending_fills in Arc::new + Mutex::new. The new() must construct let pending_fills = Arc::new(Mutex::new(Vec::new()));.
  • error[E0277]: 'Mutex<Vec<Fill>>' is not 'Arc<Mutex<Vec<Fill>>>' when struct-literal uses Mutex::new(...) directly — leftover from Lesson 4 shape. Replace with the local pending_fills, binding.
  • unused import: Fill in precompiles/mod.rs — you added Fill to the import but didn't use it. The Vec<Fill> and FILL_SINK: ...Fill... references should use it. If you see this, check that the static is in place.
  • assertion failed: fills.len() == 1 in the new test — book.submit produced 0 fills instead of 1. Likely cause: the second order didn't cross the first. Verify maker is Buy @ 100 and taker is Sell @ 100 (same price = crosses).
  • Hangs foreverplace_order is holding the Book lock when it tries to acquire the FILL_SINK. Verify the drop(book) line is before the if !submit_result.fills.is_empty() block.

Design reflection

Four points:

  1. The shared-buffer pattern generalizes. Lesson 4 introduced the Arc<Mutex<T>> + process-global pattern for the CLOB. Lesson 9 reuses it for fills. Once the architectural primitive is in place, additional "shared between bridge and precompile" state costs ~20 lines of code per new buffer. The investment in Lesson 4's abstraction pays compound interest.

  2. Different states have different installation lifetimes — keep them separate. Bundling CLOB and FILL_SINK into one global would force every test to install both. Orthogonal globals = orthogonal test setup. Cohesion of related state matters less than orthogonal lifecycle composability when tests are the primary consumer.

  3. Early-out on the common case is free. if !submit_result.fills.is_empty() skips lock acquisition when the order rests without crossing — the most common case. The guard adds one branch in the hot path and saves an RwLock acquisition when fills are empty. The cheapest optimization in a hot path is often an early-out on the dominant case.

  4. The flag is in the doc comment. "Side note: fills are discarded" in Lesson 8's doc was load-bearing — it told future readers "this is a deliberate gap, not an oversight." Lesson 9 closes the gap and updates the doc accordingly. A gap that's documented is half-fixed; an undocumented gap is invisible technical debt.

Answer key

cd ~/code/openhl-reference
git checkout d19ba1b
diff -u ~/code/my-openhl/crates/evm/src/precompiles/mod.rs ./crates/evm/src/precompiles/mod.rs
diff -u ~/code/my-openhl/crates/evm/src/live_node.rs ./crates/evm/src/live_node.rs

After Lesson 9, the precompiles/mod.rs diff should be empty, and the live_node.rs diff should also be empty for the changes covered in Lesson 9. The Stage 9c+ commit also extends the bridge integration test (which doesn't exist yet — Lesson 10 adds it). So a non-empty diff in live_node.rs is expected at the test region — that's Lesson 10's territory.

Return:

git checkout main

Common questions

Q: What if two threads call place_order at the same time and both produce fills? Both threads will acquire the FILL_SINK read lock (non-exclusive, fine). Both will get a reference to the same Arc-wrapped buffer. Each will .lock() the inner Mutex — that acquisition serializes them. One thread's fills land first, then the other's. Order matches the order of submit calls; nothing is lost. Standard Mutex semantics.

Q: Why does place_order_routes_fills_to_installed_sink test the maker-taker cross instead of a simpler scenario? Because we need a fill to test routing. Book::submit returns 0 fills when the order doesn't cross anything; we'd never exercise the routing block. The maker-taker pair is the minimum test data that produces a fill. Simpler scenarios miss the routing logic entirely.

Q: What's submit_result exactly? Is it just Vec<Fill>? It's the struct returned by Book::submit (defined in course 7's CLOB crate). It typically has at least a .fills: Vec<Fill> field, possibly more (order_id_assigned, resting_qty, etc.). We only need .fills for Lesson 9; the rest is unused at v0.

Q: When the bridge build_payload drains pending_fills, does it drain fills from both sources atomically? Yes. pending_fills is one buffer (one Mutex), regardless of whether fills came from bridge.submit_order (calls inside the bridge) or place_order (via the FILL_SINK). When build_payload calls self.pending_fills.lock().unwrap().drain(..), it gets every fill that's been pushed since the last drain — both EVM-placed and bridge-placed, interleaved by chronological order. A unified queue means a unified drain.

Next lesson (Lesson 10)

Lesson 10 is the course-level milestone: the Stage 9d integration test bridge_against_custom_evm_node_shares_clob_with_precompile. We bootstrap a Reth node with OpenHlExecutorBuilder, construct a LiveRethEvmBridge against that node's provider, submit an order via bridge.submit_order, observe it through current_best_bid, then call place_order via the precompile and verify bridge.pending_fill_count() increments. This is the proof that everything — Module 1 EVM bootstrap, Module 2 read precompile, Module 3 write precompile, Module 4 FILL_SINK — fits together inside a real Reth process. After Lesson 10, the openhl reference implementation closes Stage 9d.

Summary (3 lines)

  • install_fill_sink registers a callback fired on every fill. Routes fills from the matching engine to a per-account FillQueue.
  • clob_read_fills precompile drains the queue. Idempotent — second call returns empty.
  • Same-block notification (vs polling latency). Tests: order fills → queue has it → precompile returns → drained. Next: course milestone.