mod allowlist;
pub mod candidate;
mod execute;
use std::num::{NonZeroU16, NonZeroUsize};
use std::sync::Arc;
use std::time::Duration;
use allowlist::{NoteScriptNotAllowlisted, partition_by_allowlist};
use anyhow::Context;
use candidate::TransactionCandidate;
use futures::FutureExt;
use miden_node_utils::ErrorReport;
use miden_node_utils::lru_cache::LruCache;
use miden_node_utils::shutdown::CancellationToken;
use miden_node_utils::tracing::miden_instrument;
use miden_protocol::Word;
use miden_protocol::account::{Account, AccountDelta, AccountId};
use miden_protocol::block::BlockNumber;
use miden_protocol::note::{NoteScript, Nullifier};
use miden_protocol::transaction::{TransactionId, TransactionScript};
use miden_remote_prover_client::RemoteTransactionProver;
use miden_standards::code_builder::CodeBuilder;
use miden_tx::FailedNote;
use tokio::sync::{Notify, Semaphore, mpsc};
use crate::chain_state::{ChainState, SharedChainState};
use crate::clients::RpcClient;
use crate::db::Db;
use crate::{LOG_TARGET, NoteError};
pub(crate) fn expiration_tx_script(delta: NonZeroU16) -> anyhow::Result<TransactionScript> {
let delta = delta.get();
let source = format!(
"begin\n push.{delta} exec.::miden::protocol::tx::update_expiration_block_delta\nend"
);
CodeBuilder::new()
.compile_tx_script(source)
.context("failed to compile network-tx expiration script")
}
pub enum ActorRequest {
NotesFailed {
failed_notes: Vec<(Nullifier, NoteError)>,
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: Db,
pub chain: Arc<SharedChainState>,
pub script_cache: LruCache<Word, NoteScript>,
pub expiration_script: TransactionScript,
}
#[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: &crate::db::Db) -> 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 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);
Self {
clients: GrpcClients {
rpc: RpcClient::new(
url.clone(),
miden_protocol::Word::default(),
Duration::from_millis(100),
Duration::from_secs(30),
)
.expect("rpc client should be constructed"),
prover: RemoteTransactionProver::new(url.as_str()),
},
state: State {
db: db.clone(),
chain: chain_state,
script_cache: LruCache::new(NonZeroUsize::new(1).unwrap()),
expiration_script: expiration_tx_script(NonZeroU16::new(30).unwrap())
.expect("expiration script should compile"),
},
config: ActorConfig {
max_notes_per_tx: NonZeroUsize::new(1).unwrap(),
max_note_attempts: 1,
idle_timeout: Duration::from_secs(60),
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_delta: AccountDelta,
},
}
pub struct AccountActor {
account_id: AccountId,
clients: GrpcClients,
state: State,
config: ActorConfig,
notify: Arc<Notify>,
request: mpsc::Sender<ActorRequest>,
}
impl AccountActor {
pub fn new(
account_id: AccountId,
actor_context: &AccountActorContext,
notify: Arc<Notify>,
) -> Self {
Self {
account_id,
clients: actor_context.clients.clone(),
state: actor_context.state.clone(),
config: actor_context.config,
notify,
request: actor_context.request_tx.clone(),
}
}
pub async fn run(
self,
semaphore: Arc<Semaphore>,
shutdown: CancellationToken,
) -> anyhow::Result<()> {
let account_id = self.account_id;
let mut account = 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 has_notes = self
.state
.db
.has_available_notes(account_id, block_num, self.config.max_note_attempts)
.await
.context("failed to check for available notes")?;
let mut mode = if has_notes {
ActorMode::NotesAvailable
} else {
ActorMode::NoViableNotes
};
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 => tokio::time::sleep_until(idle_deadline).boxed(),
_ => std::future::pending().boxed(),
};
tokio::select! {
() = shutdown.cancelled() => return Ok(()),
_ = self.notify.notified() => {
mode = self.reevaluate_mode(&mut account, mode).await?;
},
permit = tx_permit_acquisition => {
let _permit = permit.context("semaphore closed")?;
let chain_state = self.state.chain.get_cloned();
let tx_candidate = self.select_candidate(&account, chain_state).await?;
mode = match tx_candidate {
Some(candidate) => {
let next = self.execute_transactions(account_id, candidate).await;
idle_deadline = tokio::time::Instant::now() + self.config.idle_timeout;
next
},
None => ActorMode::NoViableNotes,
};
}
() = idle_timeout_sleep => {
tracing::debug!(
target: LOG_TARGET,
%account_id,
"Account actor deactivated due to idle timeout"
);
return Ok(());
}
}
}
}
async fn reevaluate_mode(
&self,
account: &mut Account,
mode: ActorMode,
) -> anyhow::Result<ActorMode> {
let ActorMode::WaitForBlock {
submitted_tx_id,
submitted_at,
pending_delta,
} = mode
else {
return Ok(ActorMode::NotesAvailable);
};
let landed = self
.state
.db
.account_last_tx(self.account_id)
.await
.context("failed to check submitted tx landing")?
== Some(submitted_tx_id);
if landed {
account
.apply_delta(&pending_delta)
.context("failed to apply landed transaction delta to in-memory account")?;
tracing::info!(
target: LOG_TARGET,
account_id = %self.account_id,
tx_id = %submitted_tx_id,
"submitted transaction landed; advanced in-memory account by its delta",
);
return Ok(ActorMode::NotesAvailable);
}
let chain_tip = self.state.chain.chain_tip_block_number();
let elapsed = chain_tip.checked_sub(submitted_at.as_u32()).unwrap_or_default();
if elapsed.as_u32() >= u32::from(self.config.tx_expiration_delta.get()) {
tracing::info!(
target: LOG_TARGET,
account_id = %self.account_id,
%submitted_at,
current_tip = %chain_tip,
delta = self.config.tx_expiration_delta,
"submitted transaction expired",
);
if let Some(latest) = self
.state
.db
.get_account(self.account_id)
.await
.context("failed to reload account after submission expiry")?
{
*account = latest;
}
return Ok(ActorMode::NotesAvailable);
}
Ok(ActorMode::WaitForBlock {
submitted_tx_id,
submitted_at,
pending_delta,
})
}
async fn select_candidate(
&self,
account: &Account,
chain_state: ChainState,
) -> anyhow::Result<Option<TransactionCandidate>> {
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 notes = self
.state
.db
.available_notes(account_id, block_num, self.config.max_note_attempts)
.await
.context("failed to query DB for available notes")?;
let partitioned_notes = partition_by_allowlist(account, notes)
.context("failed to read network account note allowlist")?;
if !partitioned_notes.rejected.is_empty() {
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<_>>();
tracing::info!(
target: LOG_TARGET,
%account_id,
rejected_count = failed_notes.len(),
"dropping network notes whose script roots are not allowlisted",
);
self.mark_notes_failed(&failed_notes, block_num).await;
}
let notes: Vec<_> = partitioned_notes.allowed.into_iter().take(max_notes).collect();
if notes.is_empty() {
return Ok(None);
}
let (chain_tip_header, chain_mmr) = chain_state.into_parts();
Ok(Some(TransactionCandidate {
account: account.clone(),
notes,
chain_tip_header,
chain_mmr,
}))
}
#[miden_instrument(name = "ntx.actor.execute_transactions", skip(self, tx_candidate))]
async fn execute_transactions(
&self,
account_id: AccountId,
tx_candidate: TransactionCandidate,
) -> 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.expiration_script.clone(),
self.config.request_backoff_initial,
self.config.request_backoff_max,
);
let notes = tx_candidate.notes.clone();
let account_id = tx_candidate.account.id();
let note_ids: Vec<_> = notes.iter().map(|n| n.as_note().id()).collect();
tracing::info!(
target: LOG_TARGET,
%account_id,
?note_ids,
num_notes = notes.len(),
"executing network transaction",
);
let execution_result = context.execute_transaction(tx_candidate).await;
match execution_result {
Ok(execute::NtxExecutionResult {
tx_id,
account_delta,
failed_notes: failed,
fetched_scripts,
}) => {
tracing::info!(
target: LOG_TARGET,
%account_id,
%tx_id,
num_failed = failed.len(),
"network transaction executed with some failed notes",
);
self.cache_note_scripts(fetched_scripts).await;
let all_notes_failed = failed.len() == notes.len();
if !failed.is_empty() {
let failed_notes = log_failed_notes(failed);
self.mark_notes_failed(&failed_notes, block_num).await;
}
if all_notes_failed {
ActorMode::NoViableNotes
} else {
ActorMode::WaitForBlock {
submitted_tx_id: tx_id,
submitted_at: block_num,
pending_delta: account_delta,
}
}
},
Err(err) => {
let error_msg = err.as_report();
tracing::error!(
target: LOG_TARGET,
%account_id,
?note_ids,
err = %error_msg,
"network transaction failed",
);
let failed_notes: Vec<_> = match err {
execute::NtxError::AllNotesFailed(per_note) => log_failed_notes(per_note),
other => {
let error: NoteError = Arc::new(other);
notes
.iter()
.map(|note| {
tracing::info!(
target: LOG_TARGET,
{
note.id = %note.as_note().id(),
nullifier = %note.as_note().nullifier(),
err = %error_msg,
},
"note failed: transaction execution error",
);
(note.as_note().nullifier(), error.clone())
})
.collect()
},
};
self.mark_notes_failed(&failed_notes, block_num).await;
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,
) {
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;
}
}
fn log_failed_notes(failed: Vec<FailedNote>) -> Vec<(Nullifier, NoteError)> {
failed
.into_iter()
.map(|f| {
let error_msg = f.error().as_report();
tracing::info!(
target: LOG_TARGET,
{
note.id = %f.note().id(),
nullifier = %f.note().nullifier(),
err = %error_msg,
},
"note failed: consumability check",
);
let error: NoteError = Arc::new(std::io::Error::other(error_msg));
(f.note().nullifier(), error)
})
.collect()
}
#[cfg(test)]
mod tests {
use std::num::NonZeroU16;
use miden_protocol::ONE;
use miden_protocol::account::{Account, AccountDelta, AccountStorageDelta, AccountVaultDelta};
use tokio::sync::Notify;
use super::*;
use crate::db::Db;
use crate::test_utils::{mock_account, mock_network_account_id, mock_transaction_id};
fn nonce_bump_delta(account_id: AccountId) -> AccountDelta {
AccountDelta::new(
account_id,
AccountStorageDelta::default(),
AccountVaultDelta::default(),
ONE,
)
.expect("a nonce-only delta is valid")
}
fn test_actor(db: &Db, account: &Account) -> AccountActor {
let ctx = AccountActorContext::test(db);
AccountActor::new(account.id(), &ctx, Arc::new(Notify::new()))
}
#[tokio::test]
async fn landing_advances_in_memory_account_by_its_delta() {
let (db, _dir) = Db::test_setup().await;
let account = mock_account(mock_network_account_id());
let account_id = account.id();
let submitted = mock_transaction_id(7);
db.upsert_account_for_test(account_id, account.clone(), submitted)
.await
.unwrap();
let delta = nonce_bump_delta(account_id);
let mut expected = account.clone();
expected.apply_delta(&delta).unwrap();
let actor = test_actor(&db, &account);
let mut in_memory = account.clone();
let mode = actor
.reevaluate_mode(
&mut in_memory,
ActorMode::WaitForBlock {
submitted_tx_id: submitted,
submitted_at: 0_u32.into(),
pending_delta: delta,
},
)
.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 delta",
);
}
#[tokio::test]
async fn pending_submission_keeps_waiting_without_touching_account() {
let (db, _dir) = Db::test_setup().await;
let account = mock_account(mock_network_account_id());
let actor = test_actor(&db, &account);
let mut in_memory = account.clone();
let submitted = mock_transaction_id(7);
let mode = actor
.reevaluate_mode(
&mut in_memory,
ActorMode::WaitForBlock {
submitted_tx_id: submitted,
submitted_at: 0_u32.into(),
pending_delta: nonce_bump_delta(account.id()),
},
)
.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_timeout_fires_despite_repeated_notifications() {
let (db, _dir) = 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 notify = Arc::new(Notify::new());
let actor = AccountActor::new(account_id, &ctx, notify.clone());
let semaphore = Arc::new(Semaphore::new(1));
let handle = tokio::spawn(actor.run(semaphore, CancellationToken::new()));
let notifier = tokio::spawn(async move {
loop {
notify.notify_one();
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 notifications")
.expect("actor task should not panic");
assert!(result.is_ok(), "idle deactivation is a clean shutdown");
notifier.abort();
}
#[test]
fn expiration_script_compiles_and_encodes_delta() {
let one =
expiration_tx_script(NonZeroU16::new(1).unwrap()).expect("delta 1 should compile");
let thirty =
expiration_tx_script(NonZeroU16::new(30).unwrap()).expect("delta 30 should compile");
let max = expiration_tx_script(NonZeroU16::MAX).expect("delta u16::MAX should compile");
assert_ne!(one.root(), thirty.root(), "distinct deltas must yield distinct scripts");
assert_ne!(thirty.root(), max.root(), "distinct deltas must yield distinct scripts");
}
}