use std::ops::Range;
pub const RELAY_MAX_CALLS_PER_TX: usize = 8;
pub const MAX_BATCH_ITEMS: usize = 28;
pub fn over_batch_limit(tool: &str, n: usize) -> Option<String> {
(n > MAX_BATCH_ITEMS).then(|| {
format!(
"{tool}: {n} items exceed the per-call bound of {MAX_BATCH_ITEMS}. \
Split the request into separate calls of at most {MAX_BATCH_ITEMS} \
items each (each call is confirmed on its own)."
)
})
}
pub const RECEIPT_TIMEOUT_MARKER: &str = "receipt timeout for ";
pub fn unconfirmed_tx_hash(err: &str) -> Option<String> {
let at = err.find(RECEIPT_TIMEOUT_MARKER)?;
let rest = &err[at + RECEIPT_TIMEOUT_MARKER.len()..];
let hash: String = rest
.split_whitespace()
.next()
.unwrap_or("")
.trim_end_matches(|c: char| !c.is_ascii_alphanumeric())
.to_string();
(!hash.is_empty()).then_some(hash)
}
pub fn classify_failure(err: String) -> ChunkOutcome {
match unconfirmed_tx_hash(&err) {
Some(tx) => ChunkOutcome::Unconfirmed(tx),
None => ChunkOutcome::Failed(err),
}
}
pub const MAX_CONSECUTIVE_CHUNK_FAILURES: usize = 2;
pub fn should_stop(outcomes: &[ChunkOutcome]) -> bool {
if matches!(outcomes.last(), Some(ChunkOutcome::Unconfirmed(_))) {
return true;
}
outcomes.len() >= MAX_CONSECUTIVE_CHUNK_FAILURES
&& outcomes
.iter()
.rev()
.take(MAX_CONSECUTIVE_CHUNK_FAILURES)
.all(|o| matches!(o, ChunkOutcome::Failed(_)))
}
pub fn chunk_capacity(reserve_aux_slot: bool) -> usize {
RELAY_MAX_CALLS_PER_TX - usize::from(reserve_aux_slot)
}
pub fn chunk_ranges(n: usize, reserve_aux_slot: bool) -> Vec<Range<usize>> {
let cap = chunk_capacity(reserve_aux_slot);
let mut out = Vec::with_capacity(n.div_ceil(cap.max(1)));
let mut start = 0;
while start < n {
let end = (start + cap).min(n);
out.push(start..end);
start = end;
}
out
}
pub fn chunk_ranges_weighted(weights: &[usize], reserve_aux_slot: bool) -> Vec<Range<usize>> {
let cap = chunk_capacity(reserve_aux_slot);
let mut out = Vec::new();
let mut start = 0;
let mut load = 0usize;
for (i, &w) in weights.iter().enumerate() {
if i > start && load + w > cap {
out.push(start..i);
start = i;
load = 0;
}
load += w;
}
if start < weights.len() {
out.push(start..weights.len());
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChunkOutcome {
Landed(String),
Failed(String),
Unconfirmed(String),
}
#[derive(Debug, Default, PartialEq, Eq)]
#[non_exhaustive] pub struct BatchFold {
pub landed: Vec<usize>,
pub failed: Vec<usize>,
pub unconfirmed: Vec<usize>,
pub unattempted: Vec<usize>,
pub tx_hashes: Vec<String>,
pub unconfirmed_txs: Vec<(usize, String)>,
pub chunk_errors: Vec<(usize, String)>,
}
pub fn fold_outcomes(ranges: &[Range<usize>], outcomes: &[ChunkOutcome]) -> BatchFold {
let mut fold = BatchFold::default();
for (i, r) in ranges.iter().enumerate() {
match outcomes.get(i) {
Some(ChunkOutcome::Landed(tx)) => {
fold.landed.extend(r.clone());
if !tx.is_empty() {
fold.tx_hashes.push(tx.clone());
}
}
Some(ChunkOutcome::Failed(e)) => {
fold.failed.extend(r.clone());
fold.chunk_errors.push((i, e.clone()));
}
Some(ChunkOutcome::Unconfirmed(tx)) => {
fold.unconfirmed.extend(r.clone());
fold.unconfirmed_txs.push((i, tx.clone()));
}
None => fold.unattempted.extend(r.clone()),
}
}
fold
}
#[cfg(test)]
mod tests {
use super::*;
fn ranges(v: &[(usize, usize)]) -> Vec<Range<usize>> {
v.iter().map(|&(a, b)| a..b).collect()
}
#[test]
fn partitions_hit_exact_boundaries() {
type Case = (usize, bool, &'static [(usize, usize)]);
let cases: &[Case] = &[
(0, false, &[]),
(7, false, &[(0, 7)]),
(8, false, &[(0, 8)]),
(9, false, &[(0, 8), (8, 9)]),
(15, false, &[(0, 8), (8, 15)]),
(16, false, &[(0, 8), (8, 16)]),
(17, false, &[(0, 8), (8, 16), (16, 17)]),
(0, true, &[]),
(7, true, &[(0, 7)]),
(8, true, &[(0, 7), (7, 8)]),
(9, true, &[(0, 7), (7, 9)]),
(14, true, &[(0, 7), (7, 14)]),
(15, true, &[(0, 7), (7, 14), (14, 15)]),
(16, true, &[(0, 7), (7, 14), (14, 16)]),
];
for &(n, reserved, want) in cases {
assert_eq!(chunk_ranges(n, reserved), ranges(want), "n={n} reserved={reserved}");
}
}
#[test]
fn weighted_never_splits_an_item() {
assert_eq!(chunk_ranges_weighted(&[], false), ranges(&[]));
assert_eq!(chunk_ranges_weighted(&[1; 7], false), ranges(&[(0, 7)]));
assert_eq!(chunk_ranges_weighted(&[3, 3, 3], false), ranges(&[(0, 2), (2, 3)]));
assert_eq!(chunk_ranges_weighted(&[1, 2, 3, 1, 3], false), ranges(&[(0, 4), (4, 5)]));
assert_eq!(
chunk_ranges_weighted(&[3; 7], false),
ranges(&[(0, 2), (2, 4), (4, 6), (6, 7)])
);
assert_eq!(chunk_ranges_weighted(&[3; 4], true), ranges(&[(0, 2), (2, 4)]));
assert_eq!(chunk_ranges_weighted(&[9, 1], false), ranges(&[(0, 1), (1, 2)]));
}
#[test]
fn fold_reports_mid_chunk_failure_honestly() {
let r = ranges(&[(0, 3), (3, 6), (6, 8)]);
let f = fold_outcomes(
&r,
&[
ChunkOutcome::Landed("0xa".into()),
ChunkOutcome::Failed("boom".into()),
ChunkOutcome::Landed("0xc".into()),
],
);
assert_eq!(f.landed, vec![0, 1, 2, 6, 7]);
assert_eq!(f.failed, vec![3, 4, 5]);
assert!(f.unattempted.is_empty());
assert_eq!(f.tx_hashes, vec!["0xa".to_string(), "0xc".to_string()]);
assert_eq!(f.chunk_errors, vec![(1, "boom".to_string())]);
}
#[test]
fn fold_marks_unexecuted_chunks_never_attempted() {
let r = ranges(&[(0, 2), (2, 4), (4, 5)]);
let f = fold_outcomes(&r, &[ChunkOutcome::Failed("signer".into())]);
assert_eq!(f.failed, vec![0, 1]);
assert_eq!(f.unattempted, vec![2, 3, 4]);
assert!(f.landed.is_empty() && f.tx_hashes.is_empty());
}
#[test]
fn fold_omits_a_vacuous_chunks_empty_hash() {
let r = ranges(&[(0, 2)]);
let f = fold_outcomes(&r, &[ChunkOutcome::Landed(String::new())]);
assert_eq!(f.landed, vec![0, 1]);
assert!(f.tx_hashes.is_empty());
}
#[test]
fn batch_bound_rejects_over_limit_with_a_split_instruction() {
assert_eq!(over_batch_limit("batch_send_lh", 0), None);
assert_eq!(over_batch_limit("batch_send_lh", MAX_BATCH_ITEMS), None);
let msg = over_batch_limit("batch_send_lh", MAX_BATCH_ITEMS + 1).unwrap();
assert!(msg.contains("batch_send_lh"));
assert!(msg.contains(&MAX_BATCH_ITEMS.to_string()));
assert!(msg.to_lowercase().contains("split"));
assert!(over_batch_limit("batch_create_subdomains", 200).is_some());
}
#[test]
fn timeout_classifies_unconfirmed_with_tx_hash_revert_stays_failed() {
let e = format!("batch failed: submit: {RECEIPT_TIMEOUT_MARKER}0xabc123");
assert_eq!(unconfirmed_tx_hash(&e).as_deref(), Some("0xabc123"));
assert_eq!(
classify_failure(e),
ChunkOutcome::Unconfirmed("0xabc123".into())
);
let e = format!("{RECEIPT_TIMEOUT_MARKER}0xdead (check later).");
assert_eq!(unconfirmed_tx_hash(&e).as_deref(), Some("0xdead"));
let e = format!("submit: {RECEIPT_TIMEOUT_MARKER}0xfeed (receipt polling unreachable)");
assert_eq!(unconfirmed_tx_hash(&e).as_deref(), Some("0xfeed"));
assert_eq!(
classify_failure(e),
ChunkOutcome::Unconfirmed("0xfeed".into())
);
for revert in ["tx reverted: LH2024 insufficient", "signer: no wallet"] {
assert_eq!(unconfirmed_tx_hash(revert), None);
assert_eq!(
classify_failure(revert.into()),
ChunkOutcome::Failed(revert.into())
);
}
}
#[test]
fn model_facing_text_carries_the_live_batch_bound() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let n = MAX_BATCH_ITEMS.to_string();
for rel in [
"src/app/chat/tools/platform.rs",
"src/app/chat/tools/company.rs",
"src/session_prompt.rs",
"web/llms.txt",
] {
let text = std::fs::read_to_string(root.join(rel)).unwrap();
let hit = text.contains(&format!("at most {n}"))
|| text.contains(&format!("At most {n}"))
|| text.contains(&format!("\u{2264}{n}"))
|| text.contains(&format!("capped at {n}"));
assert!(
hit,
"{rel}: no batch-bound phrase mentions {n} — resweep the \
model-facing text after changing MAX_BATCH_ITEMS"
);
}
}
#[test]
fn breaker_stops_after_two_consecutive_failures_and_fold_reports_unattempted() {
let fail = || ChunkOutcome::Failed("boom".into());
let land = || ChunkOutcome::Landed("0xa".into());
assert!(!should_stop(&[]));
assert!(!should_stop(&[fail()]));
assert!(should_stop(&[fail(), fail()]));
assert!(!should_stop(&[fail(), land(), fail()]));
assert!(should_stop(&[land(), fail(), fail()]));
let r = ranges(&[(0, 2), (2, 4), (4, 6), (6, 8)]);
let f = fold_outcomes(&r, &[fail(), fail()]);
assert_eq!(f.failed, vec![0, 1, 2, 3]);
assert_eq!(f.unattempted, vec![4, 5, 6, 7]);
}
#[test]
fn unconfirmed_stops_immediately_and_folds_neither_landed_nor_failed() {
let unconf = ChunkOutcome::Unconfirmed("0xbeef".into());
assert!(should_stop(std::slice::from_ref(&unconf)));
assert!(should_stop(&[ChunkOutcome::Landed("0xa".into()), unconf.clone()]));
let r = ranges(&[(0, 2), (2, 4), (4, 5)]);
let f = fold_outcomes(&r, &[ChunkOutcome::Landed("0xa".into()), unconf]);
assert_eq!(f.landed, vec![0, 1]);
assert!(f.failed.is_empty() && f.chunk_errors.is_empty());
assert_eq!(f.unconfirmed, vec![2, 3]);
assert_eq!(f.unconfirmed_txs, vec![(1, "0xbeef".to_string())]);
assert_eq!(f.unattempted, vec![4]);
}
}