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 + Syncbounds 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_fillstwice, 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_fillsreturns 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_STATEandFILL_SINKinto 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 free —
if !submit_result.fills.is_empty()skips lock acquisition when the order rests without crossing (the dominant case). One branch in the hot path saves anRwLockacquisition. 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_SINKstatic added — parallel toCLOB_STATE, holdsOption<Arc<Mutex<Vec<Fill>>>>.install_fill_sink/uninstall_fill_sinkmodule fns — public, mirror theinstall_clob/uninstall_clobpattern.place_orderextended —let submit_result = book.submit(...)(was_result); afterdrop(book), if the sink is installed, push the produced fills into it.LiveRethEvmBridge::pending_fillschanges fromMutex<Vec<Fill>>toArc<Mutex<Vec<Fill>>>. The bridge'snew()now callsinstall_fill_sink(Arc::clone(&pending_fills))alongsideinstall_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-chainbridge.submit_order), one reader (thebuild_payloaddrain) - 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'sArc<Mutex<Vec<Fill>>>—install_clob↔install_fill_sink,bridge.clob↔bridge.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::submitare discarded here. Production-shape integration would route them through the bridge'spending_fillsso they reach the nextbuild_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:
- Import
Fillinprecompiles/mod.rs(and inlive_node.rsif not already present). - Add the
FILL_SINKstatic and the 2 install/uninstall module fns. - Inside
place_order— rename_resulttosubmit_result, then pushsubmit_result.fillsinto the sink (if installed) after the Book lock is dropped. - Update
place_orderdoc comment — remove the "fills are discarded" side note, replace with the Stage 9c+ behavior. - Add the unit test
place_order_routes_fills_to_installed_sink.
For live_node.rs:
- Change
pending_fillsfield fromMutex<Vec<Fill>>toArc<Mutex<Vec<Fill>>>. - Update
new()— bindpending_fillsas an Arc, callinstall_fill_sink(Arc::clone(&pending_fills))alongside the existinginstall_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:
_result→submit_result. Per Lesson 8's design reflection ("_resultis a future-intent marker"), this is now its future. The underscore goes away; the binding is used.- 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. sink_state.as_ref().map(|sink| sink.lock()...extend(...))pattern. Same shape ascurrent_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:
- 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.
- 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_tripsstill 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 keepplace_orderhappy.
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:
- Setup —
TEST_SERIALIZER+ install both CLOB and sink. Holdsink(an Arc clone) for inspection. - Resting maker — a Buy @ 100 that doesn't cross anything (book is empty). Should produce zero fills. Sink remains empty.
- Crossing taker — a Sell @ 100 that crosses the resting Buy. The maker exits the book, the taker is fully matched → exactly one Fill.
- Inspect the sink —
clone()the Vec out so the assertion happens without holding the Mutex. Verify length, price, qty. - 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:
let pending_fills = Arc::new(Mutex::new(Vec::new()));— bind the Arc to a local, same shape aslet clob = ...above.crate::precompiles::install_fill_sink(Arc::clone(&pending_fills));— share the Arc with the precompile module. Mirrorsinstall_clob.- Struct literal
pending_fills,(noMutex::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>>>'inlive_node.rs— you forgot to wrappending_fillsin Arc::new + Mutex::new. The new() must constructlet pending_fills = Arc::new(Mutex::new(Vec::new()));.error[E0277]: 'Mutex<Vec<Fill>>' is not 'Arc<Mutex<Vec<Fill>>>'when struct-literal usesMutex::new(...)directly — leftover from Lesson 4 shape. Replace with the localpending_fills,binding.unused import: Fillinprecompiles/mod.rs— you added Fill to the import but didn't use it. TheVec<Fill>andFILL_SINK: ...Fill...references should use it. If you see this, check that the static is in place.assertion failed: fills.len() == 1in the new test —book.submitproduced 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 forever —
place_orderis holding the Book lock when it tries to acquire the FILL_SINK. Verify thedrop(book)line is before theif !submit_result.fills.is_empty()block.
Design reflection
Four points:
-
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. -
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.
-
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. -
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_sinkregisters a callback fired on every fill. Routes fills from the matching engine to a per-account FillQueue.clob_read_fillsprecompile 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.