mod allowlist;
pub mod candidate;
mod execute;
use std::collections::{HashMap, HashSet};
use std::num::{NonZeroU16, NonZeroUsize};
use std::sync::Arc;
use std::time::Duration;
use allowlist::{NoteScriptNotAllowlisted, partition_by_allowlist};
use anyhow::Context;
use candidate::{SponsoredFeatureNote, TransactionCandidate};
use futures::FutureExt;
use miden_node_tracing::{ErrorReport, debug, error, info, miden_instrument, warn};
use miden_node_utils::formatting::format_opt;
use miden_node_utils::lru_cache::LruCache;
use miden_node_utils::shutdown::CancellationToken;
use miden_protocol::Word;
use miden_protocol::account::{Account, AccountId, AccountPatch};
use miden_protocol::block::BlockNumber;
use miden_protocol::note::{Note, NoteId, NoteScript, Nullifier};
use miden_protocol::transaction::{TransactionArgs, TransactionId};
use miden_standards::account::fees::FeePolicyManager;
use miden_standards::tx_script::ExpirationTransactionScript;
use miden_tx::FailedNote;
use tokio::sync::{Semaphore, mpsc, watch};
use crate::chain_state::{ChainState, SharedChainState};
use crate::clients::{RemoteTransactionProver, RpcClient};
use crate::coordinator::AccountView;
use crate::db::NtxDbReader;
use crate::{LOG_TARGET, NoteError};
pub(crate) fn build_tx_args(expiration_delta: NonZeroU16) -> TransactionArgs {
let script = ExpirationTransactionScript::new(expiration_delta);
TransactionArgs::default().with_tx_script_and_args(script.into(), script.tx_script_args())
}
const MAX_SPONSORSHIPS_PER_NOTE: usize = 3;
pub enum ActorRequest {
NotesFailed {
failed_notes: Vec<(Nullifier, NoteError)>,
block_num: BlockNumber,
ack_tx: tokio::sync::oneshot::Sender<()>,
},
NotesDiscarded {
nullifiers: Vec<Nullifier>,
block_num: BlockNumber,
ack_tx: tokio::sync::oneshot::Sender<()>,
},
CacheNoteScript { script_root: Word, script: NoteScript },
}
#[derive(Clone)]
pub struct GrpcClients {
pub rpc: RpcClient,
pub prover: RemoteTransactionProver,
}
#[derive(Clone)]
pub struct State {
pub db: NtxDbReader,
pub chain: Arc<SharedChainState>,
pub script_cache: LruCache<Word, NoteScript>,
pub tx_args: TransactionArgs,
}
#[derive(Debug, Clone, Copy)]
pub struct ActorConfig {
pub max_notes_per_tx: NonZeroUsize,
pub max_note_attempts: usize,
pub idle_timeout: Duration,
pub max_cycles: u32,
pub tx_expiration_delta: NonZeroU16,
pub request_backoff_initial: Duration,
pub request_backoff_max: Duration,
}
#[derive(Clone)]
pub struct AccountActorContext {
pub clients: GrpcClients,
pub state: State,
pub config: ActorConfig,
pub request_tx: mpsc::Sender<ActorRequest>,
}
#[cfg(test)]
impl AccountActorContext {
pub fn test(db: &NtxDbReader) -> Self {
use miden_protocol::crypto::merkle::mmr::{Forest, MmrPeaks, PartialMmr};
use url::Url;
use crate::chain_state::SharedChainState;
use crate::clients::RpcClient;
use crate::test_utils::mock_block_header;
let url = Url::parse("http://127.0.0.1:1").unwrap();
let block_header = mock_block_header(0_u32.into());
let trusted_validator_signing_keys = block_header.validator_keys().as_keys().to_vec();
let chain_mmr = PartialMmr::from_peaks(
MmrPeaks::new(Forest::new(0).expect("forest 0 is valid"), vec![]).unwrap(),
);
let chain_state = Arc::new(SharedChainState::new(block_header, chain_mmr));
let (request_tx, _request_rx) = mpsc::channel(1);
let tx_args = build_tx_args(NonZeroU16::new(30).unwrap());
Self {
clients: GrpcClients {
rpc: RpcClient::new(
url.clone(),
miden_protocol::Word::default(),
trusted_validator_signing_keys,
Duration::from_secs(10),
Duration::from_millis(100),
Duration::from_secs(30),
)
.expect("rpc client should be constructed"),
prover: RemoteTransactionProver::new(url.clone(), Duration::from_secs(10))
.expect("prover client should be constructed"),
},
state: State {
db: db.clone(),
chain: chain_state,
script_cache: LruCache::new(NonZeroUsize::new(1).unwrap()),
tx_args,
},
config: ActorConfig {
max_notes_per_tx: NonZeroUsize::new(1).unwrap(),
max_note_attempts: 1,
idle_timeout: Duration::from_mins(1),
max_cycles: 1 << 18,
tx_expiration_delta: NonZeroU16::new(30).unwrap(),
request_backoff_initial: Duration::from_millis(1),
request_backoff_max: Duration::from_millis(10),
},
request_tx,
}
}
}
#[derive(Debug)]
enum ActorMode {
NoViableNotes,
NotesAvailable,
WaitForBlock {
submitted_tx_id: TransactionId,
submitted_at: BlockNumber,
pending_patch: AccountPatch,
},
}
pub struct AccountActor {
account_id: AccountId,
clients: GrpcClients,
state: State,
config: ActorConfig,
request: mpsc::Sender<ActorRequest>,
}
impl AccountActor {
pub fn new(account_id: AccountId, actor_context: &AccountActorContext) -> Self {
Self {
account_id,
clients: actor_context.clients.clone(),
state: actor_context.state.clone(),
config: actor_context.config,
request: actor_context.request_tx.clone(),
}
}
pub async fn run(
self,
semaphore: Arc<Semaphore>,
mut view_rx: watch::Receiver<AccountView>,
shutdown: CancellationToken,
) -> anyhow::Result<()> {
let account_id = self.account_id;
let mut account = Arc::new(
self.state
.db
.get_account(account_id)
.await
.context("failed to load committed account")?
.context("no committed state for the account; the coordinator must only spawn actors for committed accounts")?,
);
let block_num = self.state.chain.chain_tip_block_number();
let availability = self
.state
.db
.available_notes(account_id, block_num, self.config.max_note_attempts)
.await
.context("failed to check for available notes")?;
let mut next_retry_block = availability.next_retry_block;
let mut mode = if availability.eligible.is_empty() {
ActorMode::NoViableNotes
} else {
ActorMode::NotesAvailable
};
let mut notes_cursor = view_rx.borrow_and_update().notes_seen;
let mut idle_deadline = tokio::time::Instant::now() + self.config.idle_timeout;
loop {
let tx_permit_acquisition = match mode {
ActorMode::NoViableNotes | ActorMode::WaitForBlock { .. } => {
std::future::pending().boxed()
},
ActorMode::NotesAvailable => semaphore.acquire().boxed(),
};
let idle_timeout_sleep = match mode {
ActorMode::NoViableNotes if next_retry_block.is_none() => {
tokio::time::sleep_until(idle_deadline).boxed()
},
_ => std::future::pending().boxed(),
};
tokio::select! {
biased;
() = shutdown.cancelled() => return Ok(()),
changed = view_rx.changed() => {
changed.context("coordinator dropped the account view channel")?;
let view = view_rx.borrow_and_update().clone();
mode = self
.reevaluate_mode(&mut account, mode, &view, &mut notes_cursor, next_retry_block)
.await?;
},
permit = tx_permit_acquisition => {
let _permit = permit.context("semaphore closed")?;
let chain_state = self.state.chain.get_cloned();
let (tx_candidate, retry) = self.select_candidate(&account, chain_state).await?;
next_retry_block = retry;
mode = match tx_candidate {
Some(candidate) => {
let next = self
.execute_transactions(account_id, candidate, &mut account)
.await?;
idle_deadline = tokio::time::Instant::now() + self.config.idle_timeout;
next
},
None => ActorMode::NoViableNotes,
};
}
() = idle_timeout_sleep => {
debug!(
target: LOG_TARGET,
"Account actor deactivated due to idle timeout",
account.id = account_id
);
return Ok(());
}
}
}
}
async fn reevaluate_mode(
&self,
account: &mut Arc<Account>,
mode: ActorMode,
view: &AccountView,
notes_cursor: &mut u64,
next_retry_block: Option<BlockNumber>,
) -> anyhow::Result<ActorMode> {
let next = match mode {
ActorMode::NotesAvailable => ActorMode::NotesAvailable,
ActorMode::NoViableNotes => {
let new_work = view.notes_seen > *notes_cursor;
let retry_due = next_retry_block.is_some_and(|block| view.chain_tip >= block);
if new_work || retry_due {
ActorMode::NotesAvailable
} else {
ActorMode::NoViableNotes
}
},
ActorMode::WaitForBlock {
submitted_tx_id,
submitted_at,
pending_patch,
} => {
let elapsed = view.chain_tip.checked_sub(submitted_at.as_u32()).unwrap_or_default();
if view.last_committed_tx == Some(submitted_tx_id) {
Arc::make_mut(account)
.apply_patch(&pending_patch)
.context("failed to apply landed transaction patch to in-memory account")?;
info!(
target: LOG_TARGET,
"submitted transaction landed; advanced in-memory account by its patch",
account.id = self.account_id,
transaction.id = submitted_tx_id
);
ActorMode::NotesAvailable
} else if elapsed.as_u32() >= u32::from(self.config.tx_expiration_delta.get()) {
info!(
target: LOG_TARGET,
"submitted transaction expired",
account.id = self.account_id,
transaction.submitted_at = submitted_at,
tip.number = view.chain_tip,
transaction.expiration_delta = self.config.tx_expiration_delta.get()
);
if let Some(latest) = self
.state
.db
.get_account(self.account_id)
.await
.context("failed to reload account after submission expiry")?
{
*account = Arc::new(latest);
}
ActorMode::NotesAvailable
} else {
ActorMode::WaitForBlock {
submitted_tx_id,
submitted_at,
pending_patch,
}
}
},
};
if matches!(next, ActorMode::NotesAvailable) {
*notes_cursor = view.notes_seen;
}
Ok(next)
}
async fn select_candidate(
&self,
account: &Arc<Account>,
chain_state: ChainState,
) -> anyhow::Result<(Option<TransactionCandidate>, Option<BlockNumber>)> {
let account_id = self.account_id;
let block_num = chain_state.chain_tip_header.block_num();
let max_notes = self.config.max_notes_per_tx.get();
let max_note_attempts = self.config.max_note_attempts;
let availability = self
.state
.db
.available_notes(account_id, block_num, max_note_attempts)
.await
.context("failed to query DB for available notes")?;
let next_retry_block = availability.next_retry_block;
let partitioned_notes = partition_by_allowlist(account.as_ref(), availability.eligible)
.context("failed to read network account note allowlist")?;
let rejected_any = !partitioned_notes.rejected.is_empty();
if rejected_any {
let failed_notes = partitioned_notes
.rejected
.into_iter()
.map(|(nullifier, script_root)| {
let error: NoteError = Arc::new(NoteScriptNotAllowlisted::new(script_root));
(nullifier, error)
})
.collect::<Vec<_>>();
info!(
target: LOG_TARGET,
"dropping network notes whose script roots are not allowlisted",
account.id = account_id,
note.rejected.count = failed_notes.len()
);
self.mark_notes_failed(&failed_notes, block_num).await;
}
let mut sponsorships = if partitioned_notes.allowed.is_empty() {
HashMap::new()
} else {
self.state
.db
.sponsorships_for_pending_notes(account_id)
.await
.context("failed to query DB for pending sponsorships")?
};
let max_sponsorships = MAX_SPONSORSHIPS_PER_NOTE.min(max_notes - 1);
let fee_asset_id = account
.storage()
.get_item(FeePolicyManager::fee_asset_id_slot())
.context("failed to read network account fee asset ID")?;
let mut selected: Vec<SponsoredFeatureNote> = Vec::new();
let mut selected_notes = 0_usize;
for feature in partitioned_notes.allowed {
let bundled_sponsorships =
sponsorships.remove(&feature.as_note().id()).unwrap_or_default();
let mut sponsored = SponsoredFeatureNote {
feature,
sponsorships: bundled_sponsorships,
};
sponsored.retain_sponsorships_for_fee_asset(fee_asset_id);
sponsored.sort_sponsorships_by_amount();
sponsored.sponsorships.truncate(max_sponsorships);
if selected_notes + sponsored.num_notes() > max_notes {
continue;
}
selected_notes += sponsored.num_notes();
selected.push(sponsored);
}
if selected.is_empty() {
let next_retry_block = if rejected_any {
Some(
next_retry_block
.map_or(block_num.child(), |block| block.min(block_num.child())),
)
} else {
next_retry_block
};
return Ok((None, next_retry_block));
}
let (chain_tip_header, chain_mmr) = chain_state.into_parts();
Ok((
Some(TransactionCandidate {
account: Arc::clone(account),
notes: selected,
chain_tip_header,
chain_mmr,
}),
next_retry_block,
))
}
#[miden_instrument(
name = "ntx.actor.execute_transactions",
fields(account.id = account_id),
)]
async fn execute_transactions(
&self,
account_id: AccountId,
tx_candidate: TransactionCandidate,
account: &mut Arc<Account>,
) -> anyhow::Result<ActorMode> {
let block_num = tx_candidate.chain_tip_header.block_num();
let context = execute::NtxContext::new(
self.clients.prover.clone(),
self.clients.rpc.clone(),
self.state.script_cache.clone(),
self.state.db.clone(),
self.config.max_cycles,
self.state.tx_args.clone(),
self.config.request_backoff_initial,
self.config.request_backoff_max,
);
let sponsored_notes = tx_candidate.notes.clone();
let sponsor_to_feature = tx_candidate.sponsor_to_feature_nullifier();
let account_id = tx_candidate.account.id();
let note_ids: Vec<_> = sponsored_notes
.iter()
.flat_map(|sponsored| {
std::iter::once(sponsored.feature.as_note().id())
.chain(sponsored.sponsorships.iter().map(Note::id))
})
.collect();
info!(
target: LOG_TARGET,
"executing network transaction",
account.id = account_id,
note.ids = note_ids.as_slice(),
note.count = note_ids.len()
);
let execution_result = context.execute_transaction(tx_candidate).await;
Ok(match execution_result {
Ok(execute::NtxExecutionResult {
tx_id,
account_patch,
failed_notes,
deferred_notes,
oversized_notes,
fetched_scripts,
}) => {
info!(
target: LOG_TARGET,
"network transaction executed",
account.id = account_id,
transaction.id = tx_id,
note.failed.count = failed_notes.len(),
note.deferred.count = deferred_notes.len(),
note.oversized.count = oversized_notes.len()
);
self.cache_note_scripts(fetched_scripts).await;
log_deferred_notes(deferred_notes);
let (oversized_sponsorships, oversized_features): (Vec<_>, Vec<_>) =
oversized_notes
.into_iter()
.partition(|f| sponsor_to_feature.contains_key(&f.note().id()));
let mut to_penalize = failed_notes;
to_penalize.extend(oversized_sponsorships);
let failed_notes = attribute_failed_notes(to_penalize, &sponsor_to_feature);
self.mark_notes_failed(&failed_notes, block_num).await;
let nullifiers = log_oversized_notes(oversized_features);
self.discard_notes(&nullifiers, block_num).await;
ActorMode::WaitForBlock {
submitted_tx_id: tx_id,
submitted_at: block_num,
pending_patch: account_patch,
}
},
Err(err) => {
error!(
&err,
target: LOG_TARGET,
"network transaction failed",
account.id = account_id,
note.ids = note_ids.as_slice()
);
let submission_rejected = matches!(err, execute::NtxError::Submission(_));
let failed_notes: Vec<_> = match err {
execute::NtxError::AllNotesFailed(per_note) => {
attribute_failed_notes(per_note, &sponsor_to_feature)
},
other => {
let error: NoteError = Arc::new(other);
sponsored_notes
.iter()
.map(|sponsored| {
let feature = sponsored.feature.as_note();
info!(
error.as_ref(),
target: LOG_TARGET,
"note failed: transaction execution error",
note.id = feature.id(),
note.nullifier = feature.nullifier()
);
(feature.nullifier(), error.clone())
})
.collect()
},
};
self.mark_notes_failed(&failed_notes, block_num).await;
if submission_rejected {
if let Some(latest) = self
.state
.db
.get_account(self.account_id)
.await
.context("failed to reload account after a rejected submission")?
{
info!(
target: LOG_TARGET,
"reloaded account from the database after a rejected submission",
account.id = account_id
);
*account = Arc::new(latest);
}
}
ActorMode::NoViableNotes
},
})
}
async fn cache_note_scripts(&self, scripts: Vec<(Word, NoteScript)>) {
for (script_root, script) in scripts {
if self
.request
.send(ActorRequest::CacheNoteScript { script_root, script })
.await
.is_err()
{
break;
}
}
}
async fn mark_notes_failed(
&self,
failed_notes: &[(Nullifier, NoteError)],
block_num: BlockNumber,
) {
if failed_notes.is_empty() {
return;
}
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
if self
.request
.send(ActorRequest::NotesFailed {
failed_notes: failed_notes.to_vec(),
block_num,
ack_tx,
})
.await
.is_err()
{
return;
}
let _ = ack_rx.await;
}
async fn discard_notes(&self, nullifiers: &[Nullifier], block_num: BlockNumber) {
if nullifiers.is_empty() {
return;
}
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
if self
.request
.send(ActorRequest::NotesDiscarded {
nullifiers: nullifiers.to_vec(),
block_num,
ack_tx,
})
.await
.is_err()
{
return;
}
let _ = ack_rx.await;
}
}
fn log_oversized_notes(oversized: Vec<FailedNote>) -> Vec<Nullifier> {
oversized
.into_iter()
.map(|note| {
warn!(
target: LOG_TARGET,
"note discarded: exceeds the per-tx cycle budget on its own and can never be consumed",
note.id = note.note().id(),
note.nullifier = note.note().nullifier(),
note.execution_cycles = format_opt(note.num_cycles().as_ref())
);
note.note().nullifier()
})
.collect()
}
fn log_deferred_notes(deferred: Vec<FailedNote>) {
for note in deferred {
info!(
target: LOG_TARGET,
"note deferred: exceeded per-tx cycle budget, will retry next round",
note.id = note.note().id(),
note.nullifier = note.note().nullifier(),
note.execution_cycles = format_opt(note.num_cycles().as_ref())
);
}
}
fn attribute_failed_notes(
failed: Vec<FailedNote>,
sponsor_to_feature: &HashMap<NoteId, Nullifier>,
) -> Vec<(Nullifier, NoteError)> {
let mut seen = HashSet::new();
let mut attributed = Vec::new();
for f in failed {
let error_msg = f.error().as_report();
info!(
f.error(),
target: LOG_TARGET,
"note failed: consumability check",
note.id = f.note().id(),
note.nullifier = f.note().nullifier()
);
let nullifier = sponsor_to_feature
.get(&f.note().id())
.copied()
.unwrap_or_else(|| f.note().nullifier());
if seen.insert(nullifier) {
let error: NoteError = Arc::new(std::io::Error::other(error_msg));
attributed.push((nullifier, error));
}
}
attributed
}
#[cfg(test)]
mod tests {
use std::num::NonZeroU16;
use miden_protocol::account::{Account, AccountPatch, AccountStoragePatch, AccountVaultPatch};
use miden_protocol::{Felt, ONE};
use tokio::sync::watch;
use super::*;
use crate::test_utils::{mock_account, mock_network_account_id, mock_transaction_id};
fn nonce_bump_patch(account: &Account) -> AccountPatch {
AccountPatch::new(
account.id(),
AccountStoragePatch::default(),
AccountVaultPatch::default(),
None,
Some(account.nonce() + ONE),
)
.expect("a nonce-only patch is valid")
}
fn test_actor(db: &NtxDbReader, account: &Account) -> AccountActor {
let ctx = AccountActorContext::test(db);
AccountActor::new(account.id(), &ctx)
}
fn view(
chain_tip: u32,
last_committed_tx: Option<TransactionId>,
notes_seen: u64,
) -> AccountView {
AccountView {
chain_tip: chain_tip.into(),
last_committed_tx,
notes_seen,
}
}
#[tokio::test]
async fn landing_advances_in_memory_account_by_its_patch() {
let (db, _dir) = crate::db::test_setup().await;
let account = mock_account(mock_network_account_id());
let submitted = mock_transaction_id(7);
let patch = nonce_bump_patch(&account);
let mut expected = account.clone();
expected.apply_patch(&patch).unwrap();
let actor = test_actor(&db, &account);
let mut in_memory = Arc::new(account.clone());
let mut notes_cursor = 0;
let view = view(1, Some(submitted), 0);
let mode = actor
.reevaluate_mode(
&mut in_memory,
ActorMode::WaitForBlock {
submitted_tx_id: submitted,
submitted_at: 0_u32.into(),
pending_patch: patch,
},
&view,
&mut notes_cursor,
None,
)
.await
.unwrap();
assert!(matches!(mode, ActorMode::NotesAvailable), "a landed tx must resume selection");
assert_eq!(
in_memory.to_commitment(),
expected.to_commitment(),
"the in-memory account must be advanced by the landed tx's patch",
);
}
#[tokio::test]
async fn pending_submission_keeps_waiting_without_touching_account() {
let (db, _dir) = crate::db::test_setup().await;
let account = mock_account(mock_network_account_id());
let actor = test_actor(&db, &account);
let mut in_memory = Arc::new(account.clone());
let mut notes_cursor = 0;
let submitted = mock_transaction_id(7);
let view = view(1, None, 0);
let mode = actor
.reevaluate_mode(
&mut in_memory,
ActorMode::WaitForBlock {
submitted_tx_id: submitted,
submitted_at: 0_u32.into(),
pending_patch: nonce_bump_patch(&account),
},
&view,
&mut notes_cursor,
None,
)
.await
.unwrap();
match mode {
ActorMode::WaitForBlock { submitted_tx_id, .. } => {
assert_eq!(submitted_tx_id, submitted, "the actor must keep waiting on its own tx");
},
other => panic!("expected to stay in WaitForBlock, got {other:?}"),
}
assert_eq!(
in_memory.to_commitment(),
account.to_commitment(),
"a still-pending submission must not change the in-memory account",
);
}
#[tokio::test]
async fn idle_actor_ignores_view_without_new_work() {
let (db, _dir) = crate::db::test_setup().await;
let account = mock_account(mock_network_account_id());
let actor = test_actor(&db, &account);
let mut in_memory = Arc::new(account.clone());
let mut notes_cursor = 3;
let view = view(10, None, 3);
let mode = actor
.reevaluate_mode(
&mut in_memory,
ActorMode::NoViableNotes,
&view,
&mut notes_cursor,
None,
)
.await
.unwrap();
assert!(
matches!(mode, ActorMode::NoViableNotes),
"no new notes and no due retry must leave the actor idle",
);
assert_eq!(notes_cursor, 3, "the cursor is untouched while the actor stays idle");
}
#[tokio::test]
async fn new_notes_wake_idle_actor() {
let (db, _dir) = crate::db::test_setup().await;
let account = mock_account(mock_network_account_id());
let actor = test_actor(&db, &account);
let mut in_memory = Arc::new(account.clone());
let mut notes_cursor = 3;
let view = view(10, None, 4);
let mode = actor
.reevaluate_mode(
&mut in_memory,
ActorMode::NoViableNotes,
&view,
&mut notes_cursor,
None,
)
.await
.unwrap();
assert!(matches!(mode, ActorMode::NotesAvailable), "a new note must trigger a re-select");
assert_eq!(notes_cursor, 4, "the cursor advances to the observed note count");
}
#[tokio::test]
async fn due_retry_wakes_idle_actor_at_its_block() {
let (db, _dir) = crate::db::test_setup().await;
let account = mock_account(mock_network_account_id());
let actor = test_actor(&db, &account);
let mut in_memory = Arc::new(account.clone());
let mut notes_cursor = 0;
let early = actor
.reevaluate_mode(
&mut in_memory,
ActorMode::NoViableNotes,
&view(9, None, 0),
&mut notes_cursor,
Some(10_u32.into()),
)
.await
.unwrap();
assert!(matches!(early, ActorMode::NoViableNotes), "a retry is not due before its block");
let due = actor
.reevaluate_mode(
&mut in_memory,
ActorMode::NoViableNotes,
&view(10, None, 0),
&mut notes_cursor,
Some(10_u32.into()),
)
.await
.unwrap();
assert!(matches!(due, ActorMode::NotesAvailable), "a due retry must trigger a re-select");
}
#[tokio::test]
async fn idle_timeout_fires_despite_repeated_view_updates() {
let (db, _dir) = crate::db::test_setup().await;
let (account, _) = crate::test_utils::mock_network_account_update();
let account_id = account.id();
db.upsert_account_for_test(account_id, account.clone(), mock_transaction_id(1))
.await
.unwrap();
let mut ctx = AccountActorContext::test(&db);
ctx.config.idle_timeout = Duration::from_millis(300);
let actor = AccountActor::new(account_id, &ctx);
let (view_tx, view_rx) = watch::channel(view(0, None, 0));
let semaphore = Arc::new(Semaphore::new(1));
let handle = tokio::spawn(actor.run(semaphore, view_rx, CancellationToken::new()));
let notifier = tokio::spawn(async move {
loop {
view_tx.send_modify(|v| v.chain_tip = v.chain_tip.child());
tokio::time::sleep(Duration::from_millis(50)).await;
}
});
let result = tokio::time::timeout(Duration::from_secs(3), handle)
.await
.expect("actor must deactivate on idle timeout despite repeated view updates")
.expect("actor task should not panic");
assert!(result.is_ok(), "idle deactivation is a clean shutdown");
notifier.abort();
}
use crate::sponsorship::SponsorshipNote;
use crate::test_utils::{
mock_network_account_update,
mock_single_target_note,
mock_sponsorship,
mock_sponsorship_note_with_faucet_and_amount,
mock_sponsorship_with_amount,
};
async fn seed_selection_account(db: &crate::db::NtxDbWriter) -> (AccountId, Account) {
let (account, _) = mock_network_account_update();
db.upsert_account_for_test(account.id(), account.clone(), mock_transaction_id(1))
.await
.unwrap();
(account.id(), account)
}
#[tokio::test]
async fn select_candidate_attaches_sponsorships_for_pending_notes() {
let (db, _dir) = crate::db::test_setup().await;
let (account_id, account) = seed_selection_account(&db).await;
let feature_a = mock_single_target_note(account_id, 1);
let feature_b = mock_single_target_note(account_id, 2);
db.insert_network_notes(vec![feature_a.clone(), feature_b.clone()])
.await
.unwrap();
db.insert_sponsorship_notes(vec![
mock_sponsorship(account_id, feature_a.as_note().id(), 3),
mock_sponsorship(account_id, feature_a.as_note().id(), 4),
])
.await
.unwrap();
let mut ctx = AccountActorContext::test(&db);
ctx.config.max_notes_per_tx = NonZeroUsize::new(20).unwrap();
let actor = AccountActor::new(account_id, &ctx);
let chain_state = actor.state.chain.get_cloned();
let (candidate, _) = actor.select_candidate(&Arc::new(account), chain_state).await.unwrap();
let candidate = candidate.expect("both bundles are viable");
assert_eq!(candidate.notes.len(), 2);
for sponsored in &candidate.notes {
if sponsored.feature.as_note().id() == feature_a.as_note().id() {
assert_eq!(sponsored.sponsorships.len(), 2, "feature A carries its sponsorships");
} else {
assert!(sponsored.sponsorships.is_empty(), "feature B has no sponsorships");
}
}
}
#[tokio::test]
async fn select_candidate_caps_sponsorships_per_note() {
let (db, _dir) = crate::db::test_setup().await;
let (account_id, account) = seed_selection_account(&db).await;
let feature = mock_single_target_note(account_id, 1);
db.insert_network_notes(vec![feature.clone()]).await.unwrap();
db.insert_sponsorship_notes(
(0..5)
.map(|i| {
mock_sponsorship_with_amount(
account_id,
feature.as_note().id(),
10 + i,
u64::from(i + 1) * 100,
)
})
.collect(),
)
.await
.unwrap();
let mut ctx = AccountActorContext::test(&db);
ctx.config.max_notes_per_tx = NonZeroUsize::new(20).unwrap();
let actor = AccountActor::new(account_id, &ctx);
let chain_state = actor.state.chain.get_cloned();
let (candidate, _) = actor.select_candidate(&Arc::new(account), chain_state).await.unwrap();
let candidate = candidate.expect("the bundle is viable");
assert_eq!(candidate.notes.len(), 1);
assert_eq!(candidate.notes[0].sponsorships.len(), MAX_SPONSORSHIPS_PER_NOTE);
let amounts = candidate.notes[0]
.sponsorships
.iter()
.map(|note| note.assets().as_slice()[0].unwrap_fungible().amount().as_u64())
.collect::<Vec<_>>();
assert_eq!(amounts, [500, 400, 300]);
}
#[tokio::test]
async fn select_candidate_filters_wrong_fee_asset_before_cap() {
use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1;
let (db, _dir) = crate::db::test_setup().await;
let (account_id, account) = seed_selection_account(&db).await;
let feature = mock_single_target_note(account_id, 1);
let feature_id = feature.as_note().id();
let valid = mock_sponsorship_with_amount(account_id, feature_id, 2, 100);
let wrong_faucet = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1).unwrap();
let invalid = (3..=5)
.map(|seed| {
SponsorshipNote::try_from(mock_sponsorship_note_with_faucet_and_amount(
account_id,
feature_id,
seed,
wrong_faucet,
u64::from(seed) * 10_000,
))
.expect("wrong-asset sponsorship is structurally valid")
})
.collect::<Vec<_>>();
db.insert_network_notes(vec![feature]).await.unwrap();
db.insert_sponsorship_notes(std::iter::once(valid.clone()).chain(invalid).collect())
.await
.unwrap();
let mut ctx = AccountActorContext::test(&db);
ctx.config.max_notes_per_tx = NonZeroUsize::new(20).unwrap();
let actor = AccountActor::new(account_id, &ctx);
let chain_state = actor.state.chain.get_cloned();
let (candidate, _) = actor.select_candidate(&Arc::new(account), chain_state).await.unwrap();
let candidate = candidate.expect("the feature and its valid sponsorship are viable");
assert_eq!(candidate.notes.len(), 1);
assert_eq!(candidate.notes[0].sponsorships.len(), 1);
assert_eq!(candidate.notes[0].sponsorships[0].id(), valid.id());
}
#[tokio::test]
async fn select_candidate_packs_bundles_atomically() {
let (db, _dir) = crate::db::test_setup().await;
let (account_id, account) = seed_selection_account(&db).await;
let feature_a = mock_single_target_note(account_id, 1);
let feature_b = mock_single_target_note(account_id, 2);
db.insert_network_notes(vec![feature_a.clone(), feature_b.clone()])
.await
.unwrap();
db.insert_sponsorship_notes(vec![
mock_sponsorship(account_id, feature_a.as_note().id(), 3),
mock_sponsorship(account_id, feature_a.as_note().id(), 4),
mock_sponsorship(account_id, feature_b.as_note().id(), 5),
])
.await
.unwrap();
let mut ctx = AccountActorContext::test(&db);
ctx.config.max_notes_per_tx = NonZeroUsize::new(3).unwrap();
let actor = AccountActor::new(account_id, &ctx);
let chain_state = actor.state.chain.get_cloned();
let (candidate, _) = actor.select_candidate(&Arc::new(account), chain_state).await.unwrap();
let candidate = candidate.expect("at least one bundle fits the budget");
assert_eq!(candidate.notes.len(), 1, "only one whole bundle fits three note slots");
assert!(candidate.num_notes() <= 3, "a bundle must never be split to fit");
let sponsored = &candidate.notes[0];
assert!(!sponsored.sponsorships.is_empty(), "the selected bundle keeps its sponsorships");
}
#[test]
fn expiration_script_shares_root_and_encodes_delta_in_args() {
let one = build_tx_args(NonZeroU16::new(1).unwrap());
let thirty = build_tx_args(NonZeroU16::new(30).unwrap());
let max = build_tx_args(NonZeroU16::MAX);
let root = ExpirationTransactionScript::script_root();
assert_eq!(one.tx_script().unwrap().root(), root);
assert_eq!(thirty.tx_script().unwrap().root(), root);
assert_eq!(max.tx_script().unwrap().root(), root);
assert_eq!(one.tx_script_args()[0], Felt::from(1_u16));
assert_eq!(thirty.tx_script_args()[0], Felt::from(30_u16));
assert_eq!(max.tx_script_args()[0], Felt::from(u16::MAX));
}
}