Skip to content

Commit 3d856a6

Browse files
kariyclaude
andauthored
docs+test(settlement): pin idle-flush-secs' max-interval settlement semantics (#642)
## Summary `idle-flush-secs` already provides time-based settlement, but its docs described it as an inactivity timer ("settle a partial batch after this many seconds without a new block"). The implementation never resets the deadline when blocks arrive mid-window, so it is actually a **max interval between settlements**: a batch settles when it reaches `batch-size` blocks or when `idle-flush-secs` have elapsed since its first pending block — whichever comes first — meaning even a single block produced in the window settles on time. This PR corrects the docs to state that contract and adds run-loop tests that pin it. No behavior change; the config key keeps its name. ## Changes - `chain-spec` / `settlement`: corrected doc comments on `SettlementRuntime.idle_flush_secs` and `SettlementConfig.idle_flush_interval`, plus stale in-loop comments in the settlement worker. - New paused-clock tests drive `Worker::run` end to end: a lone pending block settles once the interval elapses, and blocks arriving mid-window don't push the deadline back (settlement happens ~interval after the window opens, not after the last block). Adds `katana-db` as a dev-dependency to advance the chain head via direct table writes. - `docs/tee-deployment.md`: config snippet comments updated to the accurate semantics. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 9278726 commit 3d856a6

6 files changed

Lines changed: 126 additions & 12 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/chain-spec/src/settlement.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ pub struct SettlementRuntime {
6767
#[serde(default = "default_settlement_batch_size")]
6868
pub batch_size: usize,
6969

70-
/// Settle a partial batch after this many seconds without a new block.
70+
/// Maximum seconds between settlements while blocks are pending.
71+
///
72+
/// A batch is settled when it reaches `batch_size` blocks or when this many
73+
/// seconds have elapsed since its first pending block — whichever comes first.
7174
#[serde(default = "default_settlement_idle_flush_secs")]
7275
pub idle_flush_secs: u64,
7376
}

crates/settlement/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ tracing.workspace = true
3434
url.workspace = true
3535

3636
[dev-dependencies]
37+
# direct table writes to advance the chain head in the idle-flush tests
38+
katana-db.workspace = true
3739
katana-genesis.workspace = true
3840
# test-util: paused-clock tests of the submission-retry loop (`start_paused`).
3941
tokio = { workspace = true, features = [ "test-util" ] }

crates/settlement/src/config.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ pub struct SettlementConfig {
3030

3131
/// Number of blocks settled per `update_state` transaction.
3232
pub batch_size: usize,
33-
/// Settle a partial batch after this long without a new block.
33+
/// Maximum time between settlements while blocks are pending: a batch is settled when it
34+
/// reaches `batch_size` blocks or when this much time has elapsed since its first pending
35+
/// block, whichever comes first.
3436
pub idle_flush_interval: Duration,
3537
}
3638

crates/settlement/src/service.rs

Lines changed: 111 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,9 @@ where
422422
_ = &mut shutdown_rx => break,
423423
r = notify_rx.recv() => match r {
424424
Ok(_) => {
425-
// First block of a fresh batch window: arm the idle flush timer.
425+
// First block of a fresh batch window: the window settles no
426+
// later than `idle_flush_interval` from now, even if the batch
427+
// never fills. Later blocks do not push the deadline back.
426428
idle_deadline = Instant::now() + self.idle_flush_interval;
427429
}
428430
Err(broadcast::error::RecvError::Lagged(_)) => {}
@@ -643,7 +645,7 @@ mod tests {
643645
assert_eq!(next_action(None, 0, 1, false), Action::Settle { first: 0, last: 0 });
644646
// Only the genesis block, larger batch → wait for more blocks (or the idle flush).
645647
assert_eq!(next_action(None, 0, 10, false), Action::WaitForBatch);
646-
// A few blocks present, batch not yet full → wait unless idle.
648+
// A few blocks present, batch not yet full → wait unless the idle deadline elapsed.
647649
assert_eq!(next_action(None, 2, 10, false), Action::WaitForBatch);
648650
assert_eq!(next_action(None, 2, 10, true), Action::Settle { first: 0, last: 2 });
649651
}
@@ -712,15 +714,15 @@ mod tests {
712714

713715
/// Counts `prove`/`recover` calls; `prove` fails when `fail` is set. The proof id
714716
/// encodes the prove-call count so tests can tell which round produced a payload.
715-
struct CountingBackend {
716-
calls: AtomicUsize,
717+
pub(super) struct CountingBackend {
718+
pub(super) calls: AtomicUsize,
717719
recover_calls: AtomicUsize,
718720
fail: bool,
719721
recover: RecoverBehavior,
720722
}
721723

722724
impl CountingBackend {
723-
fn new(fail: bool) -> Self {
725+
pub(super) fn new(fail: bool) -> Self {
724726
Self {
725727
calls: AtomicUsize::new(0),
726728
recover_calls: AtomicUsize::new(0),
@@ -797,7 +799,7 @@ mod tests {
797799
})
798800
}
799801

800-
fn test_worker(
802+
pub(super) fn test_worker(
801803
backend: Arc<dyn ProvingBackend>,
802804
provider: DbProviderFactory,
803805
) -> Worker<DbProviderFactory> {
@@ -936,4 +938,107 @@ mod tests {
936938
assert_eq!(backend.calls.load(Ordering::SeqCst), 2);
937939
}
938940
}
941+
942+
/// Exercises the run loop's time-based trigger under a paused tokio clock: a partial
943+
/// batch settles once `idle_flush_interval` elapses, measured from when the batch window
944+
/// opens — blocks arriving mid-window must not push the deadline back, so the interval
945+
/// is the maximum time between settlements while blocks are pending.
946+
///
947+
/// The Piltover endpoint is unreachable, so "settled" is observed at the proving layer
948+
/// (a `CountingBackend` prove call) and via the persisted pending-proof range, not a
949+
/// landed transaction.
950+
mod idle_flush {
951+
use std::sync::atomic::Ordering;
952+
use std::sync::Arc;
953+
954+
use katana_db::abstraction::{Database, DbTx, DbTxMut};
955+
use katana_db::tables;
956+
use katana_primitives::block::BlockNumber;
957+
use katana_primitives::Felt;
958+
use katana_provider::DbProviderFactory;
959+
use tokio::sync::{broadcast, oneshot};
960+
use tokio::time::{Duration, Instant};
961+
962+
use super::proof_reuse::{test_worker, CountingBackend};
963+
use crate::service::read_pending_batch_proof;
964+
965+
/// Advances the local chain head as the block producer would, by recording the
966+
/// block's hash — `latest_number` reads the last `BlockHashes` entry.
967+
fn insert_block(provider: &DbProviderFactory, number: BlockNumber) {
968+
let tx = provider.db().tx_mut().unwrap();
969+
tx.put::<tables::BlockHashes>(number, Felt::from(number)).unwrap();
970+
tx.commit().unwrap();
971+
}
972+
973+
/// Polls (in virtual time) until the backend has proven at least `count` batches.
974+
async fn wait_for_prove(backend: &CountingBackend, count: usize) {
975+
for _ in 0..600 {
976+
if backend.calls.load(Ordering::SeqCst) >= count {
977+
return;
978+
}
979+
tokio::time::sleep(Duration::from_secs(1)).await;
980+
}
981+
panic!("prove was never attempted");
982+
}
983+
984+
#[tokio::test(start_paused = true)]
985+
async fn partial_batch_settles_once_interval_elapses() {
986+
let provider = DbProviderFactory::new_in_memory();
987+
insert_block(&provider, 0);
988+
989+
let backend = Arc::new(CountingBackend::new(false));
990+
let mut worker = test_worker(backend.clone(), provider.clone());
991+
worker.idle_flush_interval = Duration::from_secs(60);
992+
993+
let (notify_tx, notify_rx) = broadcast::channel::<()>(8);
994+
let (shutdown_tx, shutdown_rx) = oneshot::channel();
995+
let handle = tokio::spawn(worker.run(notify_rx, shutdown_rx));
996+
997+
// One pending block out of a batch of 10: just short of the deadline,
998+
// nothing settles.
999+
tokio::time::sleep(Duration::from_secs(59)).await;
1000+
assert_eq!(backend.calls.load(Ordering::SeqCst), 0);
1001+
1002+
// Once the interval elapses, the lone pending block is settled.
1003+
wait_for_prove(&backend, 1).await;
1004+
let pending = read_pending_batch_proof(&provider).unwrap();
1005+
assert_eq!((pending.first, pending.last), (0, 0));
1006+
1007+
drop(notify_tx);
1008+
let _ = shutdown_tx.send(());
1009+
handle.await.unwrap();
1010+
}
1011+
1012+
#[tokio::test(start_paused = true)]
1013+
async fn mid_window_blocks_do_not_postpone_settlement() {
1014+
let provider = DbProviderFactory::new_in_memory();
1015+
insert_block(&provider, 0);
1016+
1017+
let backend = Arc::new(CountingBackend::new(false));
1018+
let mut worker = test_worker(backend.clone(), provider.clone());
1019+
worker.idle_flush_interval = Duration::from_secs(60);
1020+
1021+
let (notify_tx, notify_rx) = broadcast::channel::<()>(8);
1022+
let (shutdown_tx, shutdown_rx) = oneshot::channel();
1023+
let start = Instant::now();
1024+
let handle = tokio::spawn(worker.run(notify_rx, shutdown_rx));
1025+
1026+
// A second block lands 50s into the 60s window.
1027+
tokio::time::sleep(Duration::from_secs(50)).await;
1028+
assert_eq!(backend.calls.load(Ordering::SeqCst), 0);
1029+
insert_block(&provider, 1);
1030+
notify_tx.send(()).unwrap();
1031+
1032+
// Both blocks settle at the original deadline (~60s from the window opening),
1033+
// not a fresh interval from the second block (110s).
1034+
wait_for_prove(&backend, 1).await;
1035+
assert!(start.elapsed() < Duration::from_secs(70), "deadline was pushed back");
1036+
let pending = read_pending_batch_proof(&provider).unwrap();
1037+
assert_eq!((pending.first, pending.last), (0, 1));
1038+
1039+
drop(notify_tx);
1040+
let _ = shutdown_tx.send(());
1041+
handle.await.unwrap();
1042+
}
1043+
}
9391044
}

docs/tee-deployment.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,9 @@ operator-local half you add by hand:
129129
account-address = "<DEPLOYER_ADDRESS>"
130130
account-private-key = "<SEPOLIA_DEPLOYER_PRIVATE_KEY>"
131131
tee-registry = "<TEE_REGISTRY_ADDRESS>"
132-
batch-size = 1 # blocks per settlement tx; raise for prod
133-
idle-flush-secs = 30 # settle a partial batch after this many idle seconds
132+
batch-size = 1 # blocks per settlement tx; raise for prod
133+
idle-flush-secs = 30 # settle pending blocks at most this many seconds apart
134+
# (batch-size or this, whichever comes first)
134135
# prover-key is omitted: with a mock attester no SP1 proving happens.
135136
```
136137

@@ -237,8 +238,8 @@ account-address = "<DEPLOYER_ADDRESS>"
237238
account-private-key = "<SEPOLIA_DEPLOYER_PRIVATE_KEY>"
238239
tee-registry = "<TEE_REGISTRY_ADDRESS>"
239240
prover-key = "<SP1_PROVER_NETWORK_KEY>"
240-
batch-size = 32 # amortize settlement gas; tune to throughput
241-
idle-flush-secs = 60
241+
batch-size = 32 # amortize settlement gas; tune to throughput
242+
idle-flush-secs = 60 # max seconds between settlements while blocks are pending
242243
```
243244

244245
Commit `chain-config/` (minus the `[settlement-runtime]` secrets, if you

0 commit comments

Comments
 (0)