use std::collections::{BTreeSet, HashMap};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use backon::ExponentialBuilder;
use miden_node_tracing::spawn::spawn_blocking_in_current_span;
use miden_node_tracing::{
ErrorSpanExt,
Instrument,
info,
miden_instrument,
miden_span_record,
warn,
};
use miden_node_utils::lru_cache::LruCache;
use miden_node_utils::retry::{self, Retryable};
use miden_protocol::Word;
use miden_protocol::account::{
Account,
AccountId,
AccountPatch,
AccountStorageHeader,
PartialAccount,
StorageMapKey,
StorageMapWitness,
StorageSlotName,
StorageSlotType,
};
use miden_protocol::asset::{AssetId, AssetWitness};
use miden_protocol::block::{BlockHeader, BlockNumber};
use miden_protocol::errors::TransactionInputError;
use miden_protocol::note::{Note, NoteId, NoteScript, NoteScriptRoot};
use miden_protocol::transaction::{
AccountInputs,
ExecutedTransaction,
InputNote,
InputNotes,
PartialBlockchain,
ProvenTransaction,
TransactionArgs,
TransactionId,
TransactionInputs,
};
use miden_protocol::vm::FutureMaybeSend;
use miden_tx::auth::UnreachableAuth;
use miden_tx::{
DataStore,
DataStoreError,
ExecutionOptions,
FailedNote,
LoadedMastForest,
MastForestStore,
NoteCheckerError,
NoteConsumptionChecker,
TransactionExecutor,
TransactionExecutorError,
TransactionMastStore,
TransactionProverError,
};
use crate::actor::candidate::{SponsoredFeatureNote, TransactionCandidate};
use crate::clients::{RemoteTransactionProver, RpcClient, RpcError};
use crate::db::NtxDbReader;
use crate::{COMPONENT, LOG_TARGET};
#[derive(Debug, thiserror::Error)]
pub enum NtxError {
#[error("note inputs were invalid")]
InputNotes(#[source] TransactionInputError),
#[error("failed to filter notes")]
NoteFilter(#[source] NoteCheckerError),
#[error("all notes failed to be executed")]
AllNotesFailed(Vec<FailedNote>),
#[error("failed to execute transaction")]
Execution(#[source] TransactionExecutorError),
#[error("failed to prove transaction")]
Proving(#[source] TransactionProverError),
#[error("failed to submit transaction")]
Submission(#[source] tonic::Status),
}
type NtxResult<T> = Result<T, NtxError>;
fn is_transient_status(status: &tonic::Status) -> bool {
matches!(
status.code(),
tonic::Code::Unavailable
| tonic::Code::DeadlineExceeded
| tonic::Code::Cancelled
| tonic::Code::Aborted
| tonic::Code::Unknown
| tonic::Code::Internal
| tonic::Code::ResourceExhausted,
)
}
fn is_transient_rpc_error(err: &RpcError) -> bool {
matches!(err, RpcError::GrpcClientError(status) if is_transient_status(status))
}
const MAX_REQUEST_RETRIES: usize = 20;
fn request_backoff(initial: Duration, max: Duration) -> ExponentialBuilder {
retry::exponential_bounded(initial, max, MAX_REQUEST_RETRIES)
}
fn log_transient_retry<E: std::error::Error>(operation: &'static str, err: &E, sleep: Duration) {
warn!(
err,
target: COMPONENT,
"ntx transient request failure; retrying after backoff",
operation.name = operation,
retry.delay_ms = sleep.as_millis() as u64
);
}
pub struct NtxExecutionResult {
pub tx_id: TransactionId,
pub account_patch: AccountPatch,
pub failed_notes: Vec<FailedNote>,
pub deferred_notes: Vec<FailedNote>,
pub oversized_notes: Vec<FailedNote>,
pub fetched_scripts: Vec<(Word, NoteScript)>,
}
struct FilteredNotes {
successful: InputNotes<InputNote>,
failed: Vec<FailedNote>,
deferred: Vec<FailedNote>,
oversized: Vec<FailedNote>,
}
#[derive(Clone)]
pub struct NtxContext {
prover: RemoteTransactionProver,
rpc: RpcClient,
script_cache: LruCache<Word, NoteScript>,
db: NtxDbReader,
max_cycles: u32,
tx_args: TransactionArgs,
request_backoff: ExponentialBuilder,
}
impl NtxContext {
#[expect(
clippy::too_many_arguments,
reason = "execution context aggregates actor resources"
)]
pub fn new(
prover: RemoteTransactionProver,
rpc: RpcClient,
script_cache: LruCache<Word, NoteScript>,
db: NtxDbReader,
max_cycles: u32,
tx_args: TransactionArgs,
request_backoff_initial: Duration,
request_backoff_max: Duration,
) -> Self {
let request_backoff = request_backoff(request_backoff_initial, request_backoff_max);
Self {
prover,
rpc,
script_cache,
db,
max_cycles,
tx_args,
request_backoff,
}
}
fn request_backoff(&self) -> ExponentialBuilder {
self.request_backoff
}
fn create_executor<'a, 'b>(
&self,
data_store: &'a NtxDataStore,
) -> TransactionExecutor<'a, 'b, NtxDataStore, UnreachableAuth> {
let exec_options = ExecutionOptions::new(
Some(self.max_cycles),
self.max_cycles,
ExecutionOptions::DEFAULT_CORE_TRACE_FRAGMENT_SIZE,
)
.expect("max_cycles should be within valid range");
TransactionExecutor::new(data_store)
.with_options(exec_options)
.expect("execution options should be valid for transaction executor")
}
#[miden_instrument(
target = COMPONENT,
name = "ntx.execute_transaction",
err,
)]
pub fn execute_transaction(
self,
tx: TransactionCandidate,
) -> impl FutureMaybeSend<NtxResult<NtxExecutionResult>> {
let num_notes = tx.num_notes();
let TransactionCandidate {
account,
notes,
chain_tip_header,
chain_mmr,
} = tx;
miden_span_record!(
account.id = account.id(),
account.id.network_prefix = account.id().prefix(),
note.count = num_notes,
reference_block.number = chain_tip_header.block_num()
);
async move {
Box::pin(async move {
let ctx = self.clone();
let handle = tokio::runtime::Handle::current();
let span = miden_node_tracing::Span::current();
let (executed_tx, failed_notes, deferred_notes, oversized_notes, scripts_to_cache) =
spawn_blocking_in_current_span(move || {
let data_store = NtxDataStore::new(
account,
chain_tip_header,
chain_mmr,
ctx.rpc.clone(),
ctx.script_cache.clone(),
ctx.db.clone(),
ctx.request_backoff,
);
handle.block_on(
async {
let FilteredNotes { successful, failed, deferred, oversized } =
ctx.filter_notes(&data_store, notes).await?;
let executed_tx =
Box::pin(ctx.execute(&data_store, successful)).await?;
let scripts_to_cache = data_store.take_fetched_scripts();
Ok::<_, NtxError>((
executed_tx,
failed,
deferred,
oversized,
scripts_to_cache,
))
}
.instrument(span),
)
})
.await
.unwrap_or_else(|err| std::panic::resume_unwind(err.into_panic()))?;
let (tx_inputs, _, account_patch, _) = executed_tx.into_parts();
let proven_tx = Box::pin(self.prove(&tx_inputs)).await?;
self.submit(&proven_tx, &tx_inputs).await?;
Ok(NtxExecutionResult {
tx_id: proven_tx.id(),
account_patch,
failed_notes,
deferred_notes,
oversized_notes,
fetched_scripts: scripts_to_cache,
})
})
.in_current_span()
.await
.inspect_err(|err| miden_node_tracing::Span::current().set_error(err))
}
}
#[miden_instrument(
target = COMPONENT,
name = "ntx.execute_transaction.filter_notes",
err,
)]
async fn filter_notes(
&self,
data_store: &NtxDataStore,
sponsored_notes: Vec<SponsoredFeatureNote>,
) -> NtxResult<FilteredNotes> {
let all_notes = sponsored_notes.iter().flat_map(bundled_notes).collect::<Vec<_>>();
let (mut successful_notes, batch_failed) =
self.check_consumability(data_store, all_notes).await?;
successful_notes = retry_sponsored_notes(
&sponsored_notes,
successful_notes,
|trial: Vec<Note>| async move {
let trial_len = trial.len();
let (trial_successful, trial_failed) =
self.check_consumability(data_store, trial).await?;
Ok((trial_failed.is_empty() && trial_successful.len() == trial_len)
.then_some(trial_successful))
},
)
.await?;
let successful_ids = successful_notes.iter().map(Note::id).collect::<BTreeSet<_>>();
let sponsor_to_feature = sponsored_notes
.iter()
.flat_map(|sponsored| {
let feature_id = sponsored.feature.as_note().id();
sponsored
.sponsorships
.iter()
.map(move |sponsorship| (sponsorship.id(), feature_id))
})
.collect::<HashMap<_, _>>();
let failed = batch_failed
.into_iter()
.filter(|failed| {
should_record_failure(failed.note().id(), &successful_ids, &sponsor_to_feature)
})
.collect::<Vec<_>>();
for failed_note in &failed {
info!(
failed_note.error(),
target: LOG_TARGET,
"note failed consumability check",
note.id = failed_note.note().id(),
note.nullifier = failed_note.note().nullifier()
);
}
let successful = InputNotes::from_unauthenticated_notes(successful_notes)
.map_err(NtxError::InputNotes)?;
if successful.is_empty() {
return Err(NtxError::AllNotesFailed(failed));
}
let (cycle_limited, failed) = partition_cycle_limited(failed);
let (deferred, oversized) = self.classify_cycle_limited(data_store, cycle_limited).await;
Ok(FilteredNotes { successful, failed, deferred, oversized })
}
async fn check_consumability(
&self,
data_store: &NtxDataStore,
notes: Vec<Note>,
) -> NtxResult<(Vec<Note>, Vec<FailedNote>)> {
let executor = self.create_executor(data_store);
let checker = NoteConsumptionChecker::new(&executor);
let consumption_info = Box::pin(checker.check_notes_consumability(
data_store.account.id(),
data_store.reference_block.block_num(),
notes,
self.tx_args.clone(),
))
.await
.map_err(NtxError::NoteFilter)?;
let (successful, failed) = consumption_info.into_parts();
Ok((successful.into_iter().map(|s| s.note().clone()).collect(), failed))
}
async fn classify_cycle_limited(
&self,
data_store: &NtxDataStore,
cycle_limited: Vec<FailedNote>,
) -> (Vec<FailedNote>, Vec<FailedNote>) {
let mut deferred = Vec::new();
let mut oversized = Vec::new();
for failed in cycle_limited {
if self.note_exceeds_budget_alone(data_store, failed.note()).await {
oversized.push(failed);
} else {
deferred.push(failed);
}
}
(deferred, oversized)
}
async fn note_exceeds_budget_alone(&self, data_store: &NtxDataStore, note: &Note) -> bool {
let executor = self.create_executor(data_store);
let checker = NoteConsumptionChecker::new(&executor);
match Box::pin(checker.check_notes_consumability(
data_store.account.id(),
data_store.reference_block.block_num(),
vec![note.clone()],
self.tx_args.clone(),
))
.await
{
Ok(info) => {
let (successful, failed) = info.into_parts();
successful.is_empty() && failed.iter().any(|f| f.num_cycles().is_some())
},
Err(err) => {
warn!(
&err,
target: LOG_TARGET,
"isolation re-check for a cycle-limited note failed; treating it as deferrable",
note.id = note.id()
);
false
},
}
}
#[miden_instrument(
target = COMPONENT,
name = "ntx.execute_transaction.execute",
err,
)]
async fn execute(
&self,
data_store: &NtxDataStore,
notes: InputNotes<InputNote>,
) -> NtxResult<ExecutedTransaction> {
let executor = self.create_executor(data_store);
let tx_args = self.tx_args.clone();
Box::pin(executor.execute_transaction(
data_store.account.id(),
data_store.reference_block.block_num(),
notes,
tx_args,
))
.await
.map_err(NtxError::Execution)
}
#[miden_instrument(
target = COMPONENT,
name = "ntx.execute_transaction.prove",
err,
)]
async fn prove(&self, tx_inputs: &TransactionInputs) -> NtxResult<ProvenTransaction> {
(|| async { self.prover.prove(tx_inputs).await })
.retry(self.request_backoff())
.when(|err| matches!(err, TransactionProverError::Other { .. }))
.notify(|err, dur| {
log_transient_retry("remote_prover.prove", err, dur);
})
.await
.map_err(NtxError::Proving)
}
#[miden_instrument(
target = COMPONENT,
name = "ntx.execute_transaction.submit",
err,
)]
async fn submit(
&self,
proven_tx: &ProvenTransaction,
tx_inputs: &TransactionInputs,
) -> NtxResult<()> {
(|| async { self.rpc.submit_proven_tx(proven_tx, tx_inputs).await })
.retry(self.request_backoff())
.when(is_transient_status)
.notify(|status, dur| {
log_transient_retry("rpc.submit_proven_tx", status, dur);
})
.await
.map_err(NtxError::Submission)
}
}
fn bundled_notes(sponsored: &SponsoredFeatureNote) -> Vec<Note> {
std::iter::once(sponsored.feature.as_note().clone())
.chain(sponsored.sponsorships.iter().cloned())
.collect()
}
fn retry_variants(
sponsored: &SponsoredFeatureNote,
successful: &BTreeSet<NoteId>,
) -> Vec<Vec<Note>> {
let feature = sponsored.feature.as_note();
if successful.contains(&feature.id()) {
return Vec::new();
}
let missing_sponsorships = sponsored
.sponsorships
.iter()
.filter(|sponsorship| !successful.contains(&sponsorship.id()))
.cloned()
.collect::<Vec<_>>();
(0..=missing_sponsorships.len())
.rev()
.map(|prefix_len| {
std::iter::once(feature.clone())
.chain(missing_sponsorships[..prefix_len].iter().cloned())
.collect()
})
.collect()
}
async fn retry_sponsored_notes(
sponsored_notes: &[SponsoredFeatureNote],
mut successful_notes: Vec<Note>,
mut check_exact: impl AsyncFnMut(Vec<Note>) -> NtxResult<Option<Vec<Note>>>,
) -> NtxResult<Vec<Note>> {
let mut successful_ids = successful_notes.iter().map(Note::id).collect::<BTreeSet<_>>();
for sponsored in sponsored_notes {
for additions in retry_variants(sponsored, &successful_ids) {
let mut trial = successful_notes.clone();
trial.extend(additions);
if let Some(trial_successful) = check_exact(trial).await? {
successful_notes = trial_successful;
successful_ids = successful_notes.iter().map(Note::id).collect::<BTreeSet<_>>();
break;
}
}
}
Ok(successful_notes)
}
fn should_record_failure(
failed_note: NoteId,
successful: &BTreeSet<NoteId>,
sponsor_to_feature: &HashMap<NoteId, NoteId>,
) -> bool {
!successful.contains(&failed_note)
&& sponsor_to_feature
.get(&failed_note)
.is_none_or(|feature| !successful.contains(feature))
}
fn partition_cycle_limited(failed: Vec<FailedNote>) -> (Vec<FailedNote>, Vec<FailedNote>) {
failed.into_iter().partition(|note| note.num_cycles().is_some())
}
struct NtxDataStore {
account: Arc<Account>,
reference_block: BlockHeader,
chain_mmr: Arc<PartialBlockchain>,
mast_store: TransactionMastStore,
rpc: RpcClient,
script_cache: LruCache<Word, NoteScript>,
db: NtxDbReader,
fetched_scripts: Arc<Mutex<Vec<(Word, NoteScript)>>>,
storage_slots: Arc<Mutex<HashMap<(AccountId, Word), StorageSlotName>>>,
request_backoff: ExponentialBuilder,
}
impl NtxDataStore {
fn new(
account: Arc<Account>,
reference_block: BlockHeader,
chain_mmr: Arc<PartialBlockchain>,
rpc: RpcClient,
script_cache: LruCache<Word, NoteScript>,
db: NtxDbReader,
request_backoff: ExponentialBuilder,
) -> Self {
let mast_store = TransactionMastStore::new();
mast_store.load_account_code(account.code());
Self {
account,
reference_block,
chain_mmr,
mast_store,
rpc,
script_cache,
db,
fetched_scripts: Arc::new(Mutex::new(Vec::new())),
storage_slots: Arc::new(Mutex::new(HashMap::default())),
request_backoff,
}
}
fn rpc_backoff(&self) -> ExponentialBuilder {
self.request_backoff
}
fn take_fetched_scripts(&self) -> Vec<(Word, NoteScript)> {
self.fetched_scripts
.lock()
.expect("fetched scripts lock poisoned")
.drain(..)
.collect()
}
fn register_storage_map_slots(
&self,
account_id: AccountId,
storage_header: &AccountStorageHeader,
) {
let mut storage_slots = self.storage_slots.lock().expect("storage slots lock poisoned");
for slot_header in storage_header.slots() {
if let StorageSlotType::Map = slot_header.slot_type() {
storage_slots.insert((account_id, slot_header.value()), slot_header.name().clone());
}
}
}
}
impl DataStore for NtxDataStore {
fn get_transaction_inputs(
&self,
account_id: AccountId,
ref_blocks: BTreeSet<BlockNumber>,
) -> impl FutureMaybeSend<Result<(PartialAccount, BlockHeader, PartialBlockchain), DataStoreError>>
{
async move {
if self.account.id() != account_id {
return Err(DataStoreError::AccountNotFound(account_id));
}
match ref_blocks.last().copied() {
Some(reference) if reference == self.reference_block.block_num() => {},
Some(other) => return Err(DataStoreError::BlockNotFound(other)),
None => return Err(DataStoreError::other("no reference block requested")),
}
self.register_storage_map_slots(account_id, &self.account.storage().to_header());
let partial_account = PartialAccount::from(self.account.as_ref());
Ok((partial_account, self.reference_block.clone(), (*self.chain_mmr).clone()))
}
}
fn get_foreign_account_inputs(
&self,
foreign_account_id: AccountId,
ref_block: BlockNumber,
) -> impl FutureMaybeSend<Result<AccountInputs, DataStoreError>> {
async move {
debug_assert_eq!(ref_block, self.reference_block.block_num());
let account_inputs =
(|| async { self.rpc.get_account_inputs(foreign_account_id, ref_block).await })
.retry(self.rpc_backoff())
.when(is_transient_rpc_error)
.notify(|err, dur| {
log_transient_retry("rpc.get_account_inputs", err, dur);
})
.await
.map_err(|err| {
DataStoreError::other_with_source("failed to get account inputs", err)
})?;
self.mast_store.load_account_code(account_inputs.code());
self.register_storage_map_slots(foreign_account_id, account_inputs.storage().header());
Ok(account_inputs)
}
}
fn get_vault_asset_witnesses(
&self,
account_id: AccountId,
_vault_root: Word,
vault_keys: BTreeSet<AssetId>,
) -> impl FutureMaybeSend<Result<Vec<AssetWitness>, DataStoreError>> {
async move {
let ref_block = self.reference_block.block_num();
let witnesses = (|| {
let vault_keys = vault_keys.clone();
async move {
self.rpc
.get_vault_asset_witnesses(account_id, vault_keys, Some(ref_block))
.await
}
})
.retry(self.rpc_backoff())
.when(is_transient_rpc_error)
.notify(|err, dur| {
log_transient_retry("rpc.get_vault_asset_witnesses", err, dur);
})
.await
.map_err(|err| {
DataStoreError::other_with_source("failed to get vault asset witnesses", err)
})?;
Ok(witnesses)
}
}
fn get_storage_map_witness(
&self,
account_id: AccountId,
map_root: Word,
map_key: StorageMapKey,
) -> impl FutureMaybeSend<Result<StorageMapWitness, DataStoreError>> {
async move {
let slot_name = {
let storage_slots = self.storage_slots.lock().expect("storage slots lock poisoned");
let Some(slot_name) = storage_slots.get(&(account_id, map_root)) else {
return Err(DataStoreError::other(
"requested storage slot has not been registered",
));
};
slot_name.clone()
};
let ref_block = self.reference_block.block_num();
let witness = (|| {
let slot_name = slot_name.clone();
async move {
self.rpc
.get_storage_map_witness(account_id, slot_name, map_key, Some(ref_block))
.await
}
})
.retry(self.rpc_backoff())
.when(is_transient_rpc_error)
.notify(|err, dur| {
log_transient_retry("rpc.get_storage_map_witness", err, dur);
})
.await
.map_err(|err| {
DataStoreError::other_with_source("failed to get storage map witness", err)
})?;
Ok(witness)
}
}
fn get_note_script(
&self,
script_root: NoteScriptRoot,
) -> impl FutureMaybeSend<Result<Option<NoteScript>, DataStoreError>> {
async move {
let script_root = Word::from(script_root);
if let Some(cached_script) = self.script_cache.get(&script_root) {
return Ok(Some(cached_script));
}
if let Some(script) = self.db.lookup_note_script(script_root).await.map_err(|err| {
DataStoreError::other_with_source("failed to look up note script in local DB", err)
})? {
self.script_cache.put(script_root, script.clone());
return Ok(Some(script));
}
let maybe_script = (|| async { self.rpc.get_note_script_by_root(script_root).await })
.retry(self.rpc_backoff())
.when(is_transient_rpc_error)
.notify(|err, dur| {
log_transient_retry("rpc.get_note_script_by_root", err, dur);
})
.await
.map_err(|err| {
DataStoreError::other_with_source(
"failed to retrieve note script from RPC",
err,
)
})?;
if let Some(script) = maybe_script {
self.fetched_scripts
.lock()
.expect("fetched scripts lock poisoned")
.push((script_root, script.clone()));
self.script_cache.put(script_root, script.clone());
Ok(Some(script))
} else {
Ok(None)
}
}
}
}
impl MastForestStore for NtxDataStore {
fn get(&self, procedure_hash: &miden_protocol::Word) -> Option<LoadedMastForest> {
self.mast_store.get(procedure_hash)
}
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeSet, HashMap};
use std::future::ready;
use miden_protocol::note::Note;
use miden_tx::{FailedNote, TransactionExecutorError, TransactionProverError};
use super::{
NtxError,
RpcError,
SponsoredFeatureNote,
is_transient_rpc_error,
is_transient_status,
partition_cycle_limited,
retry_sponsored_notes,
retry_variants,
should_record_failure,
};
use crate::test_utils::{
mock_network_account_id,
mock_single_target_note,
mock_sponsorship_note,
mock_sponsorship_note_with_amount,
};
fn sponsored_note_with_two_sponsorships() -> SponsoredFeatureNote {
let account_id = mock_network_account_id();
let feature = mock_single_target_note(account_id, 1);
let feature_id = feature.as_note().id();
let sponsorships = vec![
mock_sponsorship_note_with_amount(account_id, feature_id, 2, 1),
mock_sponsorship_note_with_amount(account_id, feature_id, 3, 100),
];
SponsoredFeatureNote { feature, sponsorships }
}
#[test]
fn retry_variants_preserve_sponsorship_order() {
let sponsored = sponsored_note_with_two_sponsorships();
let variants = retry_variants(&sponsored, &BTreeSet::new());
assert_eq!(variants.iter().map(Vec::len).collect::<Vec<_>>(), [3, 2, 1]);
assert!(variants.iter().all(|variant| {
variant.iter().any(|note| note.id() == sponsored.feature.as_note().id())
}));
assert_eq!(variants[0][1].id(), sponsored.sponsorships[0].id());
assert_eq!(variants[0][2].id(), sponsored.sponsorships[1].id());
assert_eq!(variants[1][1].id(), sponsored.sponsorships[0].id());
}
#[test]
fn retry_variants_do_nothing_when_feature_is_proven() {
let sponsored = sponsored_note_with_two_sponsorships();
let successful =
BTreeSet::from([sponsored.feature.as_note().id(), sponsored.sponsorships[0].id()]);
let variants = retry_variants(&sponsored, &successful);
assert!(variants.is_empty());
}
#[tokio::test]
async fn retry_sponsored_notes_recovers_pair_from_poisoned_batch() {
let account_id = mock_network_account_id();
let feature_0 = mock_single_target_note(account_id, 10);
let sponsorship_0 = mock_sponsorship_note(account_id, feature_0.as_note().id(), 11);
let feature_1 = mock_single_target_note(account_id, 12);
let sponsored_notes = vec![
SponsoredFeatureNote {
feature: feature_0.clone(),
sponsorships: vec![sponsorship_0.clone()],
},
SponsoredFeatureNote { feature: feature_1, sponsorships: vec![] },
];
let intact_ids = BTreeSet::from([feature_0.as_note().id(), sponsorship_0.id()]);
let recovered = retry_sponsored_notes(&sponsored_notes, vec![], |trial: Vec<Note>| {
let trial_ids = trial.iter().map(Note::id).collect::<BTreeSet<_>>();
ready(Ok::<_, NtxError>((trial_ids == intact_ids).then_some(trial)))
})
.await
.unwrap();
assert_eq!(recovered.iter().map(Note::id).collect::<BTreeSet<_>>(), intact_ids);
}
#[test]
fn successful_feature_suppresses_its_sponsorship_failures() {
let sponsored = sponsored_note_with_two_sponsorships();
let feature_id = sponsored.feature.as_note().id();
let sponsorship_id = sponsored.sponsorships[0].id();
let sponsor_to_feature = HashMap::from([(sponsorship_id, feature_id)]);
assert!(should_record_failure(sponsorship_id, &BTreeSet::new(), &sponsor_to_feature,));
let successful = BTreeSet::from([feature_id]);
assert!(!should_record_failure(sponsorship_id, &successful, &sponsor_to_feature,));
assert!(!should_record_failure(feature_id, &successful, &sponsor_to_feature));
}
#[test]
fn partition_cycle_limited_splits_on_cycle_count() {
let account_id = mock_network_account_id();
let cycle_limited_note = mock_single_target_note(account_id, 1).into_note();
let genuine_note = mock_single_target_note(account_id, 2).into_note();
let cycle_limited_id = cycle_limited_note.id();
let genuine_id = genuine_note.id();
let err = || TransactionExecutorError::AccountUpdateCommitment("test error");
let failed = vec![
FailedNote::new(cycle_limited_note, err(), Some(1234)),
FailedNote::new(genuine_note, err(), None),
];
let (cycle_limited, genuine) = partition_cycle_limited(failed);
assert_eq!(cycle_limited.len(), 1, "exactly the cycle-limited note must be re-checked");
assert_eq!(cycle_limited[0].note().id(), cycle_limited_id);
assert_eq!(genuine.len(), 1, "exactly the genuine failure must be penalized directly");
assert_eq!(genuine[0].note().id(), genuine_id);
}
#[test]
fn transient_status_classifies_transport_codes() {
let transient = [
tonic::Status::unavailable("u"),
tonic::Status::deadline_exceeded("d"),
tonic::Status::cancelled("c"),
tonic::Status::aborted("a"),
tonic::Status::unknown("u"),
tonic::Status::internal("i"),
tonic::Status::resource_exhausted("r"),
];
for s in &transient {
assert!(is_transient_status(s), "{:?} should be transient", s.code());
}
let terminal = [
tonic::Status::invalid_argument("ia"),
tonic::Status::failed_precondition("fp"),
tonic::Status::out_of_range("oor"),
tonic::Status::not_found("nf"),
tonic::Status::already_exists("ae"),
tonic::Status::unauthenticated("ua"),
tonic::Status::permission_denied("pd"),
tonic::Status::unimplemented("ui"),
tonic::Status::data_loss("dl"),
];
for s in &terminal {
assert!(!is_transient_status(s), "{:?} should be terminal", s.code());
}
}
#[test]
fn transient_rpc_error_only_for_transient_grpc() {
let transient = RpcError::GrpcClientError(tonic::Status::unavailable("down"));
assert!(is_transient_rpc_error(&transient));
let terminal_grpc = RpcError::GrpcClientError(tonic::Status::invalid_argument("bad input"));
assert!(!is_transient_rpc_error(&terminal_grpc));
let non_grpc = RpcError::Deserialize(
miden_protocol::utils::serde::DeserializationError::InvalidValue("bad".into()),
);
assert!(!is_transient_rpc_error(&non_grpc));
}
#[test]
fn prover_other_is_the_retried_variant() {
let err = TransactionProverError::other("remote prover unreachable");
assert!(matches!(err, TransactionProverError::Other { .. }));
}
}