Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
- Replaced blocking-in-async LargeSmt and account state forest operations in the store with wrappers using Tokio's `block_in_place()` ([#2076](https://github.com/0xMiden/node/pull/2076)).
- [BREAKING] Reworked note proto types for multi-attachment support: `NoteMetadata` now carries `attachment_schemes` (repeated) and `attachments_commitment` instead of a single `attachment`. `Note` and `NetworkNote` gained an `attachments` field. `NoteSyncRecord` now embeds full `NoteMetadata` instead of `NoteMetadataHeader`. Removed `NoteAttachmentKind` enum and `NoteMetadataHeader` message ([#2078](https://github.com/0xMiden/node/pull/2078)).
- [BREAKING] Changed `SyncChainMmr` endpoint: the upper end of the block range we're syncing is now the chain tip with the requested finality level. Validator signature is also returned ([#2075](https://github.com/0xMiden/node/pull/2075)).
- Added a `--tx-expiration-delta` parameter in the `ntx-builder` binary to defined expiracy of transaction for network accounts ([#2085](https://github.com/0xMiden/node/pull/2085)).
- [BREAKING] Renamed `SubmitProvenTransaction` RPC endpoint to `SubmitProvenTx` ([#2094](https://github.com/0xMiden/node/pull/2094)).
- [BREAKING] Renamed `SubmitProvenBatch` RPC endpoint to `SubmitProvenTxBatch` ([#2094](https://github.com/0xMiden/node/pull/2094)).

Expand Down
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ install-node: ## Installs node
install-validator: ## Installs validator
cargo install --path bin/validator --locked

.PHONY: install-ntx-builder
install-ntx-builder: ## Installs ntx-builder
cargo install --path bin/ntx-builder --locked

.PHONY: install-remote-prover
install-remote-prover: ## Install remote prover's CLI
cargo install --path bin/remote-prover --bin miden-remote-prover --locked
Expand Down
38 changes: 35 additions & 3 deletions bin/ntx-builder/src/actor/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ use miden_protocol::transaction::{
TransactionArgs,
TransactionId,
TransactionInputs,
TransactionScript,
};
use miden_protocol::vm::FutureMaybeSend;
use miden_remote_prover_client::RemoteTransactionProver;
use miden_standards::code_builder::CodeBuilder;
use miden_standards::note::AccountTargetNetworkNote;
use miden_tx::auth::UnreachableAuth;
use miden_tx::{
Expand Down Expand Up @@ -74,6 +76,19 @@ pub enum NtxError {

type NtxResult<T> = Result<T, NtxError>;

/// Compiles the tx script that sets the expiration block delta on every network transaction.
///
/// Called once at builder startup; the resulting [`TransactionScript`] is shared across actors
/// and cloned cheaply (`Arc<MastForest>` internally).
pub fn compile_expiration_tx_script(tx_expiration_delta: u16) -> TransactionScript {
let script_src = format!(
"begin\n push.{tx_expiration_delta}\n exec.::miden::protocol::tx::update_expiration_block_delta\nend",
);
CodeBuilder::new()
.compile_tx_script(script_src.as_str())
.expect("expiration tx script should compile")
Comment on lines +83 to +89
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not realize this had to be inserted as masm code 🤔 I somehow thought its something you specify on the transaction/builder itself.. But I guess it makes sense,

}

/// The result of a successful transaction execution.
///
/// Contains the transaction ID, any notes that failed during filtering, and note scripts fetched
Expand Down Expand Up @@ -109,10 +124,15 @@ pub struct NtxContext {

/// Maximum number of VM execution cycles for network transactions.
max_cycles: u32,

/// Pre-compiled tx script that sets each submitted transaction's expiration block delta.
/// Built once at builder startup and reused for every transaction.
expiration_script: TransactionScript,
}

impl NtxContext {
/// Creates a new [`NtxContext`] instance.
#[expect(clippy::too_many_arguments)]
pub fn new(
block_producer: BlockProducerClient,
validator: ValidatorClient,
Expand All @@ -121,6 +141,7 @@ impl NtxContext {
script_cache: LruCache<Word, NoteScript>,
db: Db,
max_cycles: u32,
expiration_script: TransactionScript,
) -> Self {
Self {
block_producer,
Expand All @@ -130,6 +151,7 @@ impl NtxContext {
script_cache,
db,
max_cycles,
expiration_script,
}
}

Expand Down Expand Up @@ -198,6 +220,10 @@ impl NtxContext {
let notes =
notes.into_iter().map(AccountTargetNetworkNote::into_note).collect::<Vec<_>>();

// The expiration script is pre-compiled at builder startup; cloning is cheap as
// TransactionScript wraps an Arc<MastForest>.
let expiration_script = self.expiration_script.clone();

// VM execution (note filtering + transaction execution) is CPU-intensive and may
// not yield between await points. Run it on a dedicated blocking thread while using
// the parent runtime handle to drive async store callbacks.
Expand All @@ -219,8 +245,12 @@ impl NtxContext {
async {
let (successful_notes, failed_notes) =
ctx.filter_notes(&data_store, notes).await?;
let executed_tx =
Box::pin(ctx.execute(&data_store, successful_notes)).await?;
let executed_tx = Box::pin(ctx.execute(
&data_store,
successful_notes,
expiration_script,
))
.await?;
let scripts_to_cache = data_store.take_fetched_scripts();
Ok::<_, NtxError>((executed_tx, failed_notes, scripts_to_cache))
}
Expand Down Expand Up @@ -317,14 +347,16 @@ impl NtxContext {
&self,
data_store: &NtxDataStore,
notes: InputNotes<InputNote>,
expiration_script: TransactionScript,
) -> NtxResult<ExecutedTransaction> {
let executor = self.create_executor(data_store);
let tx_args = TransactionArgs::default().with_tx_script(expiration_script);

Box::pin(executor.execute_transaction(
data_store.account.id(),
data_store.reference_block.block_num(),
notes,
TransactionArgs::default(),
tx_args,
))
.await
.map_err(NtxError::Execution)
Expand Down
112 changes: 82 additions & 30 deletions bin/ntx-builder/src/actor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ use std::time::Duration;

use anyhow::Context;
use candidate::TransactionCandidate;
pub use execute::compile_expiration_tx_script;
use futures::FutureExt;
use miden_node_proto::domain::account::NetworkAccountId;
use miden_node_utils::ErrorReport;
use miden_node_utils::lru_cache::LruCache;
use miden_protocol::Word;
use miden_protocol::block::BlockNumber;
use miden_protocol::note::{NoteScript, Nullifier};
use miden_protocol::transaction::TransactionId;
use miden_protocol::transaction::TransactionScript;
use miden_remote_prover_client::RemoteTransactionProver;
use miden_tx::FailedNote;
use tokio::sync::{Notify, Semaphore, mpsc};
Expand Down Expand Up @@ -71,7 +72,7 @@ pub struct State {
}

/// Per-actor configuration knobs.
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone)]
pub struct ActorConfig {
/// Maximum number of notes per transaction.
pub max_notes_per_tx: NonZeroUsize,
Expand All @@ -81,6 +82,12 @@ pub struct ActorConfig {
pub idle_timeout: Duration,
/// Maximum number of VM execution cycles for network transactions.
pub max_cycles: u32,
/// Pre-compiled tx script that sets each submitted transaction's expiration block delta.
/// Built once at builder startup and shared by all actors.
pub expiration_script: TransactionScript,
/// Same delta encoded in [`Self::expiration_script`], retained so the actor can decide when a
/// submitted transaction must have either landed or been dropped.
pub tx_expiration_delta: u16,
}

// ACCOUNT ACTOR CONTEXT
Expand Down Expand Up @@ -133,6 +140,8 @@ impl AccountActorContext {
max_note_attempts: 1,
idle_timeout: Duration::from_secs(60),
max_cycles: 1 << 18,
expiration_script: crate::actor::compile_expiration_tx_script(5),
tx_expiration_delta: 5,
},
request_tx,
}
Expand All @@ -147,7 +156,21 @@ impl AccountActorContext {
enum ActorMode {
NoViableNotes,
NotesAvailable,
TransactionInflight(TransactionId),
/// The actor has just submitted a transaction and is waiting for it to either land on-chain or
/// expire before doing any more work for this account. This avoids busy-looping with the same
/// notes between submit and block commit.
///
/// Carries the commitment of the account state we executed against and the chain tip at
/// submission time, so on each wake-up we can detect cheaply whether the tx landed (the
/// committed account commitment in the DB has moved) or definitely won't (the chain has
/// advanced past the expiration window).
///
/// Network accounts are only ever updated by ntx-builder transactions, so a commitment change
/// here is equivalent to "some submission for this account has been included in a block."
WaitForNextBlock {
submitted_commitment: Word,
submitted_at: BlockNumber,
},
Comment on lines +168 to +173
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a problem with pass-through transactions maybe? As in, if the network account for whatever reason just acts as a pass-through then the commitment wouldn't change even though the transaction has been committed?

Maybe a better model is

Suggested change
/// Network accounts are only ever updated by ntx-builder transactions, so a commitment change
/// here is equivalent to "some submission for this account has been included in a block."
WaitForNextBlock {
submitted_commitment: Word,
submitted_at: BlockNumber,
},
TransactionSubmitted {
transaction: TransactionId,
expires_at: BlockNumber,
},

though this might mean we need to track the latest committed transaction ID for a given account?

Or we need to allow inspection of the block by actors and our notify model is overly simplistic?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something else.. We should probably store the transaction itself so we can apply its delta to the local account without having to hit the database again.

}

// ACCOUNT ACTOR
Expand Down Expand Up @@ -211,7 +234,7 @@ impl AccountActor {
account_id,
clients: actor_context.clients.clone(),
state: actor_context.state.clone(),
config: actor_context.config,
config: actor_context.config.clone(),
notify,
request: actor_context.request_tx.clone(),
}
Expand Down Expand Up @@ -251,7 +274,7 @@ impl AccountActor {
// Enable or disable transaction execution based on actor mode.
let tx_permit_acquisition = match mode {
// Disable transaction execution.
ActorMode::NoViableNotes | ActorMode::TransactionInflight(_) => {
ActorMode::NoViableNotes | ActorMode::WaitForNextBlock { .. } => {
std::future::pending().boxed()
},
// Enable transaction execution.
Expand All @@ -266,34 +289,17 @@ impl AccountActor {
};

tokio::select! {
// Handle coordinator notifications. On notification, re-evaluate state from DB.
// Handle coordinator notifications. Whether we re-evaluate depends on whether
// an in-flight submission still might land.
_ = self.notify.notified() => {
match mode {
ActorMode::TransactionInflight(awaited_id) => {
// Check DB: is the inflight tx still pending?
let exists = self
.state
.db
.transaction_exists(awaited_id)
.await
.context("failed to check transaction status")?;
if exists {
mode = ActorMode::NotesAvailable;
}
},
_ => {
mode = ActorMode::NotesAvailable;
}
}
mode = self.on_notified(mode).await?;
},
// Execute transactions.
permit = tx_permit_acquisition => {
let _permit = permit.context("semaphore closed")?;

// Read the chain state.
// Read the chain state and query the DB for an executable candidate.
let chain_state = self.state.chain.get_cloned();

// Query DB for latest account and available notes.
let tx_candidate = self.select_candidate_from_db(
account_id,
chain_state,
Expand All @@ -315,6 +321,47 @@ impl AccountActor {
}
}

/// Decides the next mode when a coordinator notification arrives.
///
/// If we are in [`ActorMode::WaitForNextBlock`], we only transition out once either:
/// - the committed account commitment in the DB no longer matches the one we executed against,
/// which means some network transaction for this account has been included in a block, or
/// - the chain has advanced past the expiration window, in which case the submission must be
/// considered dropped.
///
/// While neither holds we stay in `WaitForNextBlock` so the actor does not re-execute and
/// re-submit the same notes every block. In any other mode we move to
/// [`ActorMode::NotesAvailable`] so the next loop iteration re-queries the DB.
async fn on_notified(&self, mode: ActorMode) -> anyhow::Result<ActorMode> {
let ActorMode::WaitForNextBlock { submitted_commitment, submitted_at } = mode else {
return Ok(ActorMode::NotesAvailable);
};

let chain_tip = self.state.chain.chain_tip_block_number();
let blocks_elapsed = chain_tip.as_u32().saturating_sub(submitted_at.as_u32());
let expired = blocks_elapsed >= u32::from(self.config.tx_expiration_delta);

if expired {
// Submission can no longer be included; re-evaluate from scratch.
return Ok(ActorMode::NotesAvailable);
}

let current = self
.state
.db
.account_commitment(self.account_id)
.await
.context("failed to read account commitment")?;

// The account row goes away only if the network account was removed entirely. Treat that
// as "something changed" so the next iteration re-evaluates (and almost certainly idles).
if current == Some(submitted_commitment) {
Ok(ActorMode::WaitForNextBlock { submitted_commitment, submitted_at })
} else {
Ok(ActorMode::NotesAvailable)
}
}

/// Selects a transaction candidate by querying the DB.
async fn select_candidate_from_db(
&self,
Expand Down Expand Up @@ -366,7 +413,7 @@ impl AccountActor {
if self
.state
.db
.has_committed_account(account_id)
.has_account(account_id)
.await
.context("failed to check for committed account")?
{
Expand All @@ -379,7 +426,7 @@ impl AccountActor {
if self
.state
.db
.has_committed_account(account_id)
.has_account(account_id)
.await
.context("failed to check for committed account")?
{
Expand Down Expand Up @@ -407,7 +454,11 @@ impl AccountActor {
account_id: NetworkAccountId,
tx_candidate: TransactionCandidate,
) -> ActorMode {
let block_num = tx_candidate.chain_tip_header.block_num();
// Captured before execution so that `WaitForNextBlock` records the state the tx was run
// against even though `tx_candidate` is moved into the executor below.
let submitted_commitment = tx_candidate.account.to_commitment();
let submitted_at = tx_candidate.chain_tip_header.block_num();
let block_num = submitted_at;

// Execute the selected transaction.
let context = execute::NtxContext::new(
Expand All @@ -418,6 +469,7 @@ impl AccountActor {
self.state.script_cache.clone(),
self.state.db.clone(),
self.config.max_cycles,
self.config.expiration_script.clone(),
);

let notes = tx_candidate.notes.clone();
Expand All @@ -444,7 +496,7 @@ impl AccountActor {
let failed_notes = log_failed_notes(failed);
self.mark_notes_failed(&failed_notes, block_num).await;
}
ActorMode::TransactionInflight(tx_id)
ActorMode::WaitForNextBlock { submitted_commitment, submitted_at }
},
// Transaction execution failed.
Err(err) => {
Expand Down
Loading
Loading