use miden_node_db::sqlite::ReadTx;
use miden_node_db::{DatabaseError, SqlTypeConvert};
use miden_protocol::account::AccountId;
use miden_protocol::block::BlockNumber;
use miden_protocol::note::Note;
use miden_standards::note::{AccountTargetNetworkNote, NoteExecutionHint};
const SQL: &str = include_str!("available_notes.sql");
pub struct AvailableNotes {
pub eligible: Vec<AccountTargetNetworkNote>,
pub next_retry_block: Option<BlockNumber>,
}
#[expect(clippy::cast_possible_wrap)]
pub fn available_notes(
tx: &ReadTx<'_>,
account_id: AccountId,
block_num: BlockNumber,
max_attempts: usize,
) -> Result<AvailableNotes, DatabaseError> {
let rows = tx.query(SQL, &[&account_id, &(max_attempts as i64)], |row| {
Ok((row.get::<Note>(0)?, row.get::<i64>(1)?, row.get::<Option<i64>>(2)?))
})?;
let mut eligible = Vec::new();
let mut next_retry_block: Option<BlockNumber> = None;
for (note, attempt_count, last_attempt) in rows {
#[expect(clippy::cast_sign_loss)]
let attempt_count = attempt_count as usize;
let last_attempt = last_attempt.map(BlockNumber::from_raw_sql).transpose()?;
let note = AccountTargetNetworkNote::new(note).map_err(|source| {
DatabaseError::deserialization("failed to convert to network note", source)
})?;
let hint = note.execution_hint();
let hint_ok = hint.can_be_consumed(block_num).unwrap_or(true);
let backoff_ok = has_backoff_passed(block_num, last_attempt, attempt_count);
if hint_ok && backoff_ok {
eligible.push(note);
} else {
let recheck = note_recheck_block(
hint,
block_num,
last_attempt,
attempt_count,
backoff_ok,
hint_ok,
);
next_retry_block =
Some(next_retry_block.map_or(recheck, |earliest| earliest.min(recheck)));
}
}
Ok(AvailableNotes { eligible, next_retry_block })
}
#[expect(clippy::cast_precision_loss, clippy::cast_sign_loss)]
fn has_backoff_passed(
chain_tip: BlockNumber,
last_attempt: Option<BlockNumber>,
attempts: usize,
) -> bool {
if attempts == 0 {
return true;
}
let blocks_passed = last_attempt
.and_then(|last| chain_tip.checked_sub(last.as_u32()))
.unwrap_or_default();
let backoff_threshold = (0.25 * attempts as f64).exp().round() as usize;
blocks_passed.as_usize() > backoff_threshold
}
#[expect(
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::cast_possible_truncation
)]
fn backoff_ready_block(last_attempt: Option<BlockNumber>, attempts: usize) -> BlockNumber {
let last = last_attempt.unwrap_or(BlockNumber::GENESIS);
let threshold = (0.25 * attempts as f64).exp().round() as u32;
last + threshold + 1
}
fn note_recheck_block(
hint: NoteExecutionHint,
chain_tip: BlockNumber,
last_attempt: Option<BlockNumber>,
attempts: usize,
backoff_ok: bool,
hint_ok: bool,
) -> BlockNumber {
let mut recheck = chain_tip.child();
if !backoff_ok {
recheck = recheck.max(backoff_ready_block(last_attempt, attempts));
}
if !hint_ok && let Some(hint_block) = hint_next_consumable_block(hint, chain_tip) {
recheck = recheck.max(hint_block);
}
recheck
}
fn hint_next_consumable_block(hint: NoteExecutionHint, from: BlockNumber) -> Option<BlockNumber> {
match hint {
NoteExecutionHint::None | NoteExecutionHint::Always => None,
NoteExecutionHint::AfterBlock { block_num } => Some(block_num),
NoteExecutionHint::OnBlockSlot { round_len, slot_len, slot_offset } => {
let block = u64::from(from.as_u32());
let round_len_blocks = 1u64.checked_shl(u32::from(round_len))?;
let slot_len_blocks = 1u64.checked_shl(u32::from(slot_len))?;
let round_index = block / round_len_blocks;
let slot_start =
round_index * round_len_blocks + u64::from(slot_offset) * slot_len_blocks;
let slot_end = slot_start + slot_len_blocks;
let next = if block < slot_start {
slot_start
} else if block >= slot_end {
slot_start + round_len_blocks
} else {
block
};
Some(BlockNumber::from(u32::try_from(next).unwrap_or(u32::MAX)))
},
}
}
#[cfg(test)]
mod tests {
use miden_protocol::block::BlockNumber;
use miden_standards::note::NoteExecutionHint;
use super::{has_backoff_passed, hint_next_consumable_block};
fn brute_force_next(hint: NoteExecutionHint, from: u32) -> Option<u32> {
(from..=from.saturating_add(4096))
.find(|&b| hint.can_be_consumed(BlockNumber::from(b)) == Some(true))
}
#[test]
fn hint_next_consumable_block_matches_can_be_consumed() {
let hints = [
NoteExecutionHint::after_block(BlockNumber::from(200)),
NoteExecutionHint::on_block_slot(10, 7, 1), NoteExecutionHint::on_block_slot(8, 4, 0), NoteExecutionHint::on_block_slot(9, 5, 3),
];
for hint in hints {
for b in 0u32..1300 {
if hint.can_be_consumed(BlockNumber::from(b)) != Some(false) {
continue;
}
let got = hint_next_consumable_block(hint, BlockNumber::from(b))
.expect("a windowed hint must report a next block")
.as_u32();
let expected = brute_force_next(hint, b)
.expect("oracle must find a consumable block within the scan window");
assert_eq!(got, expected, "hint {hint:?} at block {b}");
}
}
}
#[rstest::rstest]
#[test]
#[case::all_zero(Some(BlockNumber::GENESIS), BlockNumber::GENESIS, 0, true)]
#[case::no_attempts(None, BlockNumber::GENESIS, 0, true)]
#[case::one_attempt(Some(BlockNumber::GENESIS), BlockNumber::from(2), 1, true)]
#[case::three_attempts(Some(BlockNumber::GENESIS), BlockNumber::from(3), 3, true)]
#[case::ten_attempts(Some(BlockNumber::GENESIS), BlockNumber::from(13), 10, true)]
#[case::twenty_attempts(Some(BlockNumber::GENESIS), BlockNumber::from(149), 20, true)]
#[case::one_attempt_false(Some(BlockNumber::GENESIS), BlockNumber::from(1), 1, false)]
#[case::three_attempts_false(Some(BlockNumber::GENESIS), BlockNumber::from(2), 3, false)]
#[case::ten_attempts_false(Some(BlockNumber::GENESIS), BlockNumber::from(12), 10, false)]
#[case::twenty_attempts_false(Some(BlockNumber::GENESIS), BlockNumber::from(148), 20, false)]
fn backoff_has_passed(
#[case] last_attempt_block_num: Option<BlockNumber>,
#[case] current_block_num: BlockNumber,
#[case] attempt_count: usize,
#[case] backoff_should_have_passed: bool,
) {
assert_eq!(
backoff_should_have_passed,
has_backoff_passed(current_block_num, last_attempt_block_num, attempt_count)
);
}
}