diff --git a/Cargo.lock b/Cargo.lock index b6e14704a2..8d856d8bfc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4470,6 +4470,7 @@ dependencies = [ "miden-protocol", "miden-standards", "miden-tx", + "rand 0.10.2", "rand_chacha 0.10.0", "rstest", "tempfile", diff --git a/bin/ntx-builder/Cargo.toml b/bin/ntx-builder/Cargo.toml index 36574d865e..4cc4207a47 100644 --- a/bin/ntx-builder/Cargo.toml +++ b/bin/ntx-builder/Cargo.toml @@ -31,6 +31,7 @@ miden-node-utils = { workspace = true } miden-protocol = { default-features = true, workspace = true } miden-standards = { workspace = true } miden-tx = { features = ["concurrent"], workspace = true } +rand = { workspace = true } thiserror = { workspace = true } tokio = { features = ["macros", "net", "rt-multi-thread"], workspace = true } tokio-stream = { features = ["net"], workspace = true } diff --git a/bin/ntx-builder/src/actor/candidate.rs b/bin/ntx-builder/src/actor/candidate.rs index fd30258b54..17b3f54fc5 100644 --- a/bin/ntx-builder/src/actor/candidate.rs +++ b/bin/ntx-builder/src/actor/candidate.rs @@ -1,10 +1,36 @@ +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use miden_protocol::account::Account; use miden_protocol::block::BlockHeader; +use miden_protocol::note::{Note, NoteId, Nullifier}; use miden_protocol::transaction::PartialBlockchain; use miden_standards::note::AccountTargetNetworkNote; +// NOTE GROUP +// ================================================================================================ + +/// A feature note grouped with the `FEE_SPONSORSHIP` notes that pay its fee. +/// +/// The group is the atomic unit of transaction selection: a sponsorship note may only be consumed +/// in the same transaction as its feature note, so a group is included in (or excluded from) a +/// candidate as a whole. A group with no sponsorships is a plain network note. +#[derive(Clone, Debug)] +pub struct NoteGroup { + /// The network note targeted at the account. + pub feature: AccountTargetNetworkNote, + /// `FEE_SPONSORSHIP` notes bound to the feature note, consumed in the same transaction. + pub sponsorships: Vec, +} + +impl NoteGroup { + /// Number of notes the group contributes to a transaction: the feature note plus its + /// sponsorships. + pub fn num_notes(&self) -> usize { + 1 + self.sponsorships.len() + } +} + // TRANSACTION CANDIDATE // ================================================================================================ @@ -22,8 +48,9 @@ pub struct TransactionCandidate { /// the candidate has been consumed. pub account: Arc, - /// A set of notes addressed to this network account. - pub notes: Vec, + /// The note groups selected for this transaction: each feature note addressed to the account + /// together with the sponsorships that pay its fee. + pub notes: Vec, /// The latest locally committed block header. /// @@ -35,3 +62,226 @@ pub struct TransactionCandidate { /// Wrapped in `Arc` to avoid expensive clones when reading the chain state. pub chain_mmr: Arc, } + +impl TransactionCandidate { + /// Total number of notes across all groups. + pub fn num_notes(&self) -> usize { + self.notes.iter().map(NoteGroup::num_notes).sum() + } + + /// Maps each sponsorship note id to the nullifier of the feature note it sponsors. + /// + /// Sponsorship notes have no row in the `notes` table, so any failure of a sponsorship is + /// attributed to (and penalizes) the feature note of its group. Feature notes are absent from + /// the map: their failures are recorded under their own nullifier. + pub fn sponsor_to_feature_nullifier(&self) -> HashMap { + self.notes + .iter() + .flat_map(|group| { + let feature_nullifier = group.feature.as_note().nullifier(); + group + .sponsorships + .iter() + .map(move |sponsorship| (sponsorship.id(), feature_nullifier)) + }) + .collect() + } +} + +// GROUP INDEX +// ================================================================================================ + +/// Pairing metadata derived from a candidate's groups, used to re-pair the note checker's output. +/// +/// The consumability checker eliminates notes individually, so it may split a group: keep a +/// sponsorship whose feature note it dropped, or keep a feature note whose sponsorships it +/// dropped. Both halves are guaranteed to fail on-chain (the sponsorship script aborts without its +/// feature note; a fee-charging account's auth procedure rejects an unsponsored feature note), so +/// [`GroupIndex::repair`] removes them before execution. +pub struct GroupIndex { + /// Sponsorship note id to the id of the feature note it sponsors. + sponsor_to_feature: HashMap, + /// Feature notes that must not execute without at least one of their sponsorships. + gated_features: HashSet, +} + +impl GroupIndex { + /// Builds the index from a candidate's groups. + /// + /// `require_sponsorship` mirrors the selection-time gate: when set, a feature note that was + /// selected together with sponsorships must not execute after losing all of them. + pub fn new(groups: &[NoteGroup], require_sponsorship: bool) -> Self { + let sponsor_to_feature = groups + .iter() + .flat_map(|group| { + let feature_id = group.feature.as_note().id(); + group.sponsorships.iter().map(move |sponsorship| (sponsorship.id(), feature_id)) + }) + .collect(); + let gated_features = if require_sponsorship { + groups + .iter() + .filter(|group| !group.sponsorships.is_empty()) + .map(|group| group.feature.as_note().id()) + .collect() + } else { + HashSet::new() + }; + Self { sponsor_to_feature, gated_features } + } + + /// Splits the checker's surviving notes into `(retained, dropped)`, removing notes that must + /// not execute after the checker eliminated part of their group: sponsorships whose feature + /// note is gone, and gated feature notes that lost every sponsorship. + pub fn repair(&self, notes: Vec) -> (Vec, Vec) { + let ids: HashSet = notes.iter().map(Note::id).collect(); + // A sponsorship survives when its feature note also survived the checker; the features + // named here satisfy the gate. + let sponsored_features: HashSet = self + .sponsor_to_feature + .iter() + .filter(|(sponsorship, feature)| ids.contains(sponsorship) && ids.contains(feature)) + .map(|(_, feature)| *feature) + .collect(); + + notes.into_iter().partition(|note| { + if let Some(feature) = self.sponsor_to_feature.get(¬e.id()) { + ids.contains(feature) + } else if self.gated_features.contains(¬e.id()) { + sponsored_features.contains(¬e.id()) + } else { + true + } + }) + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::{ + mock_network_account_id, + mock_single_target_note, + mock_sponsorship_note, + }; + + /// Builds a group of one feature note and `num_sponsorships` sponsorships bound to it. + fn group(feature_seed: u8, num_sponsorships: u8) -> NoteGroup { + let account_id = mock_network_account_id(); + let feature = mock_single_target_note(account_id, feature_seed); + let sponsorships = (0..num_sponsorships) + .map(|i| { + mock_sponsorship_note(account_id, feature.as_note().id(), feature_seed + 100 + i) + }) + .collect(); + NoteGroup { feature, sponsorships } + } + + fn flatten(groups: &[NoteGroup]) -> Vec { + groups + .iter() + .flat_map(|g| { + std::iter::once(g.feature.as_note().clone()).chain(g.sponsorships.iter().cloned()) + }) + .collect() + } + + /// An intact group passes repair untouched, with or without the gate. + #[test] + fn repair_keeps_intact_groups() { + let groups = [group(1, 2), group(2, 0)]; + let notes = flatten(&groups); + + for require in [false, true] { + let index = GroupIndex::new(&groups, require); + let (retained, dropped) = index.repair(notes.clone()); + assert_eq!(retained.len(), 4); + assert!(dropped.is_empty()); + } + } + + /// A sponsorship whose feature note the checker eliminated is dropped: its script would abort + /// the VM. + #[test] + fn repair_drops_orphaned_sponsorship() { + let groups = [group(1, 1), group(2, 0)]; + let index = GroupIndex::new(&groups, false); + + // The checker eliminated the feature note of group 1; its sponsorship survived. + let notes = vec![groups[0].sponsorships[0].clone(), groups[1].feature.as_note().clone()]; + let (retained, dropped) = index.repair(notes); + + assert_eq!(retained.len(), 1); + assert_eq!(retained[0].id(), groups[1].feature.as_note().id()); + assert_eq!(dropped.len(), 1); + assert_eq!(dropped[0].id(), groups[0].sponsorships[0].id()); + } + + /// With the sponsorship requirement active, a feature note that lost every sponsorship is + /// dropped as well: the account's auth procedure would reject it. + #[test] + fn repair_drops_gated_feature_without_surviving_sponsorship() { + let groups = [group(1, 1)]; + let notes = vec![groups[0].feature.as_note().clone()]; + + let gated = GroupIndex::new(&groups, true); + let (retained, dropped) = gated.repair(notes.clone()); + assert!(retained.is_empty()); + assert_eq!(dropped.len(), 1); + + // Without the requirement the feature note executes alone (the account may not charge + // fees). + let ungated = GroupIndex::new(&groups, false); + let (retained, dropped) = ungated.repair(notes); + assert_eq!(retained.len(), 1); + assert!(dropped.is_empty()); + } + + /// A gated feature keeps executing while at least one of its sponsorships survived. + #[test] + fn repair_keeps_gated_feature_with_one_surviving_sponsorship() { + let groups = [group(1, 2)]; + let index = GroupIndex::new(&groups, true); + + // One of the two sponsorships was eliminated by the checker. + let notes = vec![groups[0].feature.as_note().clone(), groups[0].sponsorships[1].clone()]; + let (retained, dropped) = index.repair(notes); + + assert_eq!(retained.len(), 2); + assert!(dropped.is_empty()); + } + + /// The failure-attribution map names every sponsorship and no feature note. + #[test] + fn sponsor_to_feature_nullifier_covers_sponsorships_only() { + let groups = [group(1, 2), group(2, 0)]; + let chain_mmr = PartialBlockchain::new( + miden_protocol::crypto::merkle::mmr::PartialMmr::from_peaks( + miden_protocol::crypto::merkle::mmr::MmrPeaks::new( + miden_protocol::crypto::merkle::mmr::Forest::new(0).unwrap(), + vec![], + ) + .unwrap(), + ), + [], + ) + .unwrap(); + let candidate = TransactionCandidate { + account: Arc::new(crate::test_utils::mock_account(mock_network_account_id())), + notes: groups.to_vec(), + chain_tip_header: crate::test_utils::mock_block_header(0_u32.into()), + chain_mmr: Arc::new(chain_mmr), + }; + + let map = candidate.sponsor_to_feature_nullifier(); + assert_eq!(map.len(), 2); + let feature_nullifier = groups[0].feature.as_note().nullifier(); + for sponsorship in &groups[0].sponsorships { + assert_eq!(map[&sponsorship.id()], feature_nullifier); + } + assert_eq!(candidate.num_notes(), 4); + } +} diff --git a/bin/ntx-builder/src/actor/execute.rs b/bin/ntx-builder/src/actor/execute.rs index f97f852522..0233015c0c 100644 --- a/bin/ntx-builder/src/actor/execute.rs +++ b/bin/ntx-builder/src/actor/execute.rs @@ -36,7 +36,6 @@ use miden_protocol::transaction::{ TransactionInputs, }; use miden_protocol::vm::FutureMaybeSend; -use miden_standards::note::AccountTargetNetworkNote; use miden_tx::auth::UnreachableAuth; use miden_tx::{ DataStore, @@ -54,7 +53,7 @@ use miden_tx::{ }; use tracing::Instrument; -use crate::actor::candidate::TransactionCandidate; +use crate::actor::candidate::{GroupIndex, TransactionCandidate}; use crate::clients::{RemoteTransactionProver, RpcClient, RpcError}; use crate::db::NtxDbReader; use crate::{COMPONENT, LOG_TARGET}; @@ -178,6 +177,10 @@ pub struct NtxContext { /// [`ExponentialBuilder`] used to back off retries on transient request failures. request_backoff: ExponentialBuilder, + + /// Mirrors the selection-time sponsorship gate: when set, a feature note selected together with + /// sponsorships must not execute after the consumability checker eliminated all of them. + require_sponsorship: bool, } impl NtxContext { @@ -195,6 +198,7 @@ impl NtxContext { tx_args: TransactionArgs, request_backoff_initial: Duration, request_backoff_max: Duration, + require_sponsorship: bool, ) -> Self { let request_backoff = request_backoff(request_backoff_initial, request_backoff_max); Self { @@ -205,6 +209,7 @@ impl NtxContext { max_cycles, tx_args, request_backoff, + require_sponsorship, } } @@ -262,6 +267,8 @@ impl NtxContext { self, tx: TransactionCandidate, ) -> impl FutureMaybeSend> { + let num_notes = tx.num_notes(); + let group_index = GroupIndex::new(&tx.notes, self.require_sponsorship); let TransactionCandidate { account, notes, @@ -271,14 +278,20 @@ impl NtxContext { miden_span_record!( account.id = %account.id(), account.id.network_prefix = %account.id().prefix(), - notes.count = notes.len(), + notes.count = num_notes, reference_block.number = %chain_tip_header.block_num(), ); async move { Box::pin(async move { - let notes = - notes.into_iter().map(AccountTargetNetworkNote::into_note).collect::>(); + // Flatten the groups for execution; the pairing between a sponsorship and its + // feature note is by note id, so the order within the list does not matter. + let notes = notes + .into_iter() + .flat_map(|group| { + std::iter::once(group.feature.into_note()).chain(group.sponsorships) + }) + .collect::>(); // 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 @@ -301,7 +314,7 @@ impl NtxContext { handle.block_on( async { let FilteredNotes { successful, failed, deferred, oversized } = - ctx.filter_notes(&data_store, notes).await?; + ctx.filter_notes(&data_store, notes, &group_index).await?; let executed_tx = Box::pin(ctx.execute(&data_store, successful)).await?; let scripts_to_cache = data_store.take_fetched_scripts(); @@ -352,6 +365,10 @@ impl NtxContext { /// - Successful notes: notes that can be executed and are returned wrapped in [`InputNotes`]. /// - Failed notes: notes that cannot be executed. /// + /// The checker eliminates notes individually, so it can split a note group; the surviving set + /// is re-paired through [`GroupIndex::repair`] so no half of a split group (guaranteed to fail + /// on-chain) reaches execution. + /// /// # Guarantees /// /// - On success, the returned [`InputNotes`] set is guaranteed to be non-empty. @@ -371,6 +388,7 @@ impl NtxContext { &self, data_store: &NtxDataStore, notes: Vec, + group_index: &GroupIndex, ) -> NtxResult { let executor = self.create_executor(data_store); let checker = NoteConsumptionChecker::new(&executor); @@ -397,9 +415,24 @@ impl NtxContext { ); } - // Map successful notes to input notes. + // Map successful notes to input notes, dropping the halves of any group the checker + // split. Dropped notes are not penalized here: the group member that caused the + // split is already in `failed`, and its failure is attributed to the group's + // feature note by the actor. let successful_notes = successful.into_iter().map(|s| s.note().clone()).collect::>(); + let (successful_notes, dropped) = group_index.repair(successful_notes); + for note in &dropped { + tracing::info!( + target: LOG_TARGET, + { + note.id = %note.id(), + nullifier = %note.nullifier(), + }, + "note dropped by group re-pairing: the rest of its group failed the \ + consumability check", + ); + } let successful = InputNotes::from_unauthenticated_notes(successful_notes) .map_err(NtxError::InputNotes)?; diff --git a/bin/ntx-builder/src/actor/mod.rs b/bin/ntx-builder/src/actor/mod.rs index e4d62e3ccd..da0924d8b1 100644 --- a/bin/ntx-builder/src/actor/mod.rs +++ b/bin/ntx-builder/src/actor/mod.rs @@ -2,13 +2,14 @@ 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::TransactionCandidate; +use candidate::{NoteGroup, TransactionCandidate}; use futures::FutureExt; use miden_node_utils::ErrorReport; use miden_node_utils::formatting::{format_array, format_opt}; @@ -18,10 +19,11 @@ use miden_node_utils::tracing::miden_instrument; use miden_protocol::Word; use miden_protocol::account::{Account, AccountId, AccountPatch}; use miden_protocol::block::BlockNumber; -use miden_protocol::note::{NoteScript, Nullifier}; +use miden_protocol::note::{Note, NoteId, NoteScript, Nullifier}; use miden_protocol::transaction::{TransactionArgs, TransactionId}; use miden_standards::tx_script::ExpirationTransactionScript; use miden_tx::FailedNote; +use rand::seq::SliceRandom; use tokio::sync::{Semaphore, mpsc, watch}; use crate::chain_state::{ChainState, SharedChainState}; @@ -104,8 +106,14 @@ pub struct State { /// Per-actor configuration knobs. #[derive(Debug, Clone, Copy)] pub struct ActorConfig { - /// Maximum number of notes per transaction. + /// Maximum number of notes per transaction. Sponsorship notes count against this budget. pub max_notes_per_tx: NonZeroUsize, + /// Maximum number of `FEE_SPONSORSHIP` notes attached to a single feature note. When a feature + /// note has more pending sponsorships, a random subset of this size is selected. + pub max_sponsorships_per_note: NonZeroUsize, + /// When set, a feature note with no pending sponsorship is skipped (without penalty) during + /// selection instead of being executed unsponsored. Leave unset while fees are zero. + pub require_sponsorship: bool, /// Maximum number of note execution attempts before dropping a note. pub max_note_attempts: usize, /// Duration after which an idle actor will deactivate. @@ -181,6 +189,8 @@ impl AccountActorContext { }, config: ActorConfig { max_notes_per_tx: NonZeroUsize::new(1).unwrap(), + max_sponsorships_per_note: NonZeroUsize::new(3).unwrap(), + require_sponsorship: false, max_note_attempts: 1, idle_timeout: Duration::from_mins(1), max_cycles: 1 << 18, @@ -549,8 +559,60 @@ impl AccountActor { 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() { + // Attach each feature note's pending sponsorships: the group is the atomic selection unit, + // since a sponsorship may only be consumed alongside its feature note. + 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")? + }; + // A group must leave room for its feature note within the per-tx note budget. + let max_sponsorships = self.config.max_sponsorships_per_note.get().min(max_notes - 1); + + let mut selected: Vec = Vec::new(); + let mut selected_notes = 0_usize; + let mut skipped_unsponsored = 0_usize; + for feature in partitioned_notes.allowed { + let mut group_sponsorships = + sponsorships.remove(&feature.as_note().id()).unwrap_or_default(); + // More sponsorships than the cap: keep a random subset, giving every sponsor a chance + // to be consumed eventually instead of deterministically starving some. + if group_sponsorships.len() > max_sponsorships { + group_sponsorships.shuffle(&mut rand::rng()); + group_sponsorships.truncate(max_sponsorships); + } + // An unsponsored feature note is skipped without penalty (no attempt bump): its + // sponsorship may still arrive, and its arrival wakes the actor for a re-selection. + if self.config.require_sponsorship && group_sponsorships.is_empty() { + skipped_unsponsored += 1; + continue; + } + let group = NoteGroup { + feature, + sponsorships: group_sponsorships, + }; + // Group-atomic packing: a group that does not fit the remaining budget is skipped as a + // whole (never split) and re-selected in a later round. + if selected_notes + group.num_notes() > max_notes { + continue; + } + selected_notes += group.num_notes(); + selected.push(group); + } + if skipped_unsponsored > 0 { + tracing::info!( + target: LOG_TARGET, + %account_id, + skipped = skipped_unsponsored, + "skipping feature notes with no pending sponsorship", + ); + } + + if selected.is_empty() { // Notes just marked failed re-enter eligibility via backoff; re-check on the next block // so the actor does not deactivate while it still has notes aging through their budget. let next_retry_block = if rejected_any { @@ -569,7 +631,7 @@ impl AccountActor { Some(TransactionCandidate { // Cheap: bumps the `Arc` refcount instead of deep-copying the account/storage. account: Arc::clone(account), - notes, + notes: selected, chain_tip_header, chain_mmr, }), @@ -612,16 +674,27 @@ impl AccountActor { self.state.tx_args.clone(), self.config.request_backoff_initial, self.config.request_backoff_max, + self.config.require_sponsorship, ); - let notes = tx_candidate.notes.clone(); + let groups = tx_candidate.notes.clone(); + // Failures of a sponsorship note are attributed to the feature note of its group: + // sponsorship notes have no row in the `notes` table, so the feature note carries the + // attempt tracking for its whole group. + let sponsor_to_feature = tx_candidate.sponsor_to_feature_nullifier(); let account_id = tx_candidate.account.id(); - let note_ids: Vec<_> = notes.iter().map(|n| n.as_note().id()).collect(); + let note_ids: Vec<_> = groups + .iter() + .flat_map(|group| { + std::iter::once(group.feature.as_note().id()) + .chain(group.sponsorships.iter().map(Note::id)) + }) + .collect(); tracing::info!( target: LOG_TARGET, %account_id, note_ids = %format_array(¬e_ids), - num_notes = notes.len(), + num_notes = note_ids.len(), "executing network transaction", ); @@ -656,10 +729,21 @@ impl AccountActor { log_deferred_notes(deferred_notes); - let failed_notes = log_failed_notes(failed_notes); + // Only feature notes are discarded permanently. An oversized sponsorship (its + // isolated re-check runs the reclaim path, so this is unexpected) is charged to its + // feature note as a regular failure instead: the feature itself may still be + // consumable with a different sponsorship. + 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_notes); + let nullifiers = log_oversized_notes(oversized_features); self.discard_notes(&nullifiers, block_num).await; // A non-empty successful set is guaranteed by `filter_notes` (it returns @@ -690,24 +774,29 @@ impl AccountActor { let submission_rejected = matches!(err, execute::NtxError::Submission(_)); // For `AllNotesFailed`, use the per-note errors which contain the specific reason - // each note failed (e.g. consumability check details). + // each note failed (e.g. consumability check details). Whole-transaction errors are + // recorded against the feature notes only: sponsorships have no row in the `notes` + // table. let failed_notes: Vec<_> = match err { - execute::NtxError::AllNotesFailed(per_note) => log_failed_notes(per_note), + execute::NtxError::AllNotesFailed(per_note) => { + attribute_failed_notes(per_note, &sponsor_to_feature) + }, other => { let error: NoteError = Arc::new(other); - notes + groups .iter() - .map(|note| { + .map(|group| { + let feature = group.feature.as_note(); tracing::info!( target: LOG_TARGET, { - note.id = %note.as_note().id(), - nullifier = %note.as_note().nullifier(), + note.id = %feature.id(), + nullifier = %feature.nullifier(), err = %error_msg, }, "note failed: transaction execution error", ); - (note.as_note().nullifier(), error.clone()) + (feature.nullifier(), error.clone()) }) .collect() }, @@ -850,25 +939,38 @@ fn log_deferred_notes(deferred: Vec) { } } -/// Logs each failed note and returns a vec of `(nullifier, error)` pairs. -fn log_failed_notes(failed: Vec) -> 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", - ); +/// Logs each failed note and returns `(nullifier, error)` pairs keyed by the nullifier the failure +/// is recorded under: a feature note fails under its own nullifier, while a sponsorship's failure +/// is charged to the feature note of its group (sponsorship notes have no row in the `notes` +/// table). Multiple failures attributed to the same feature note collapse to a single entry, so a +/// group never burns more than one attempt per round. +fn attribute_failed_notes( + failed: Vec, + sponsor_to_feature: &HashMap, +) -> Vec<(Nullifier, NoteError)> { + let mut seen = HashSet::new(); + let mut attributed = Vec::new(); + for f in failed { + 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 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)); - (f.note().nullifier(), error) - }) - .collect() + attributed.push((nullifier, error)); + } + } + attributed } #[cfg(test)] @@ -1133,6 +1235,162 @@ mod tests { notifier.abort(); } + // SPONSORSHIP-AWARE SELECTION + // --------------------------------------------------------------------------------------------- + + use crate::test_utils::{ + mock_network_account_update, + mock_single_target_note, + mock_sponsorship, + }; + + /// Seeds a committed network account (with a populated allowlist) and returns its id together + /// with the account itself. + 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) + } + + /// Each selected group carries exactly the pending sponsorships of its feature note. + #[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 groups are viable"); + + assert_eq!(candidate.notes.len(), 2); + for group in &candidate.notes { + if group.feature.as_note().id() == feature_a.as_note().id() { + assert_eq!(group.sponsorships.len(), 2, "feature A carries its sponsorships"); + } else { + assert!(group.sponsorships.is_empty(), "feature B has no sponsorships"); + } + } + } + + /// With `require_sponsorship` set, an unsponsored feature note is skipped without burning an + /// attempt: its sponsorship may still arrive, and its arrival wakes the actor. + #[tokio::test] + async fn select_candidate_skips_unsponsored_without_penalty_when_required() { + let (db, _dir) = crate::db::test_setup().await; + let (account_id, account) = seed_selection_account(&db).await; + + let sponsored = mock_single_target_note(account_id, 1); + let unsponsored = mock_single_target_note(account_id, 2); + db.insert_network_notes(vec![sponsored.clone(), unsponsored.clone()]) + .await + .unwrap(); + db.insert_sponsorship_notes(vec![mock_sponsorship( + account_id, + sponsored.as_note().id(), + 3, + )]) + .await + .unwrap(); + + let mut ctx = AccountActorContext::test(&db); + ctx.config.max_notes_per_tx = NonZeroUsize::new(20).unwrap(); + ctx.config.require_sponsorship = true; + 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 sponsored group is viable"); + + assert_eq!(candidate.notes.len(), 1); + assert_eq!(candidate.notes[0].feature.as_note().id(), sponsored.as_note().id()); + + let status = db.get_note_status(unsponsored.as_note().id()).await.unwrap().unwrap(); + assert_eq!(status.attempt_count, 0, "a skipped unsponsored note must not be penalized"); + } + + /// A feature note with more pending sponsorships than the cap gets a subset of exactly the cap. + #[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(account_id, feature.as_note().id(), 10 + i)) + .collect(), + ) + .await + .unwrap(); + + let mut ctx = AccountActorContext::test(&db); + ctx.config.max_notes_per_tx = NonZeroUsize::new(20).unwrap(); + ctx.config.max_sponsorships_per_note = NonZeroUsize::new(2).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 group is viable"); + + assert_eq!(candidate.notes.len(), 1); + assert_eq!(candidate.notes[0].sponsorships.len(), 2); + } + + /// Groups are packed atomically against the per-tx note budget: a group that does not fit is + /// skipped as a whole, never split. + #[tokio::test] + async fn select_candidate_packs_groups_atomically() { + let (db, _dir) = crate::db::test_setup().await; + let (account_id, account) = seed_selection_account(&db).await; + + // Group A is three notes (feature + 2 sponsorships), group B is two: only one of them fits + // a three-note budget. + 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 group fits the budget"); + + assert_eq!(candidate.notes.len(), 1, "only one whole group fits three note slots"); + assert!(candidate.num_notes() <= 3, "a group must never be split to fit"); + let group = &candidate.notes[0]; + assert!(!group.sponsorships.is_empty(), "the selected group keeps its sponsorships"); + } + /// The canonical expiration script carries its delta in `TX_SCRIPT_ARGS`, so every delta shares /// a single script root (the one network accounts allowlist), while the args word encodes the /// delta in its first element. diff --git a/bin/ntx-builder/src/builder.rs b/bin/ntx-builder/src/builder.rs index dd5726d50b..de03f014d1 100644 --- a/bin/ntx-builder/src/builder.rs +++ b/bin/ntx-builder/src/builder.rs @@ -187,9 +187,9 @@ impl NetworkTransactionBuilder { SteadyStateAction::Block(block) => { let (block, committed_tip) = (*block).context("block stream ended")?.context("block stream failed")?; - let effects = + let (effects, sponsored_accounts) = self.apply_committed_block_with_effects(block, committed_tip).await?; - self.coordinator.handle_committed_block(&effects).await?; + self.coordinator.handle_committed_block(&effects, &sponsored_accounts).await?; }, SteadyStateAction::Request(request) => { let Some(request) = request else { @@ -234,9 +234,10 @@ impl NetworkTransactionBuilder { self.apply_committed_block_with_effects(block, committed_tip).await.map(drop) } - /// Applies a committed block and returns the computed `CommittedBlockEffects` so the - /// steady-state loop can hand them to the coordinator without re-deriving from the signed - /// block. + /// Applies a committed block and returns the computed `CommittedBlockEffects`, plus the + /// accounts whose pending feature notes gained a sponsorship in this block (one entry per + /// sponsorship), so the steady-state loop can hand both to the coordinator without re-deriving + /// them from the signed block. #[miden_instrument( name = "ntx.builder.apply_committed_block", fields( @@ -248,7 +249,7 @@ impl NetworkTransactionBuilder { &mut self, block: SignedBlock, committed_tip: BlockNumber, - ) -> anyhow::Result { + ) -> anyhow::Result<(CommittedBlockEffects, Vec)> { let header = block.header().clone(); let block_num = header.block_num(); @@ -260,14 +261,15 @@ impl NetworkTransactionBuilder { let next_mmr = self.chain.current_mmr(); let effects_for_db = effects.clone(); - self.db + let sponsored_accounts = self + .db .apply_committed_block(effects_for_db, next_mmr) .await .context("failed to apply committed block to DB")?; self.last_applied_block = block_num; - Ok(effects) + Ok((effects, sponsored_accounts)) } } diff --git a/bin/ntx-builder/src/commands/mod.rs b/bin/ntx-builder/src/commands/mod.rs index e2275db0c9..0fda7402d8 100644 --- a/bin/ntx-builder/src/commands/mod.rs +++ b/bin/ntx-builder/src/commands/mod.rs @@ -25,11 +25,14 @@ const ENV_SCRIPT_CACHE_SIZE: &str = "MIDEN_NODE_NTX_BUILDER_SCRIPT_CACHE_SIZE"; const ENV_MAX_CYCLES: &str = "MIDEN_NODE_NTX_BUILDER_MAX_CYCLES"; const ENV_TX_EXPIRATION_DELTA: &str = "MIDEN_NODE_NTX_BUILDER_TX_EXPIRATION_DELTA"; const ENV_SQLITE_CONNECTION_POOL_SIZE: &str = "MIDEN_NODE_NTX_BUILDER_SQLITE_CONNECTION_POOL_SIZE"; +const ENV_REQUIRE_SPONSORSHIP: &str = "MIDEN_NODE_NTX_BUILDER_REQUIRE_SPONSORSHIP"; +const ENV_MAX_SPONSORSHIPS_PER_NOTE: &str = "MIDEN_NODE_NTX_BUILDER_MAX_SPONSORSHIPS_PER_NOTE"; const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_mins(5); const DEFAULT_SCRIPT_CACHE_SIZE: NonZeroUsize = NonZeroUsize::new(1000).unwrap(); const DEFAULT_MAX_CYCLES: u32 = 1 << 18; const DEFAULT_TX_EXPIRATION_DELTA: NonZeroU16 = NonZeroU16::new(30).unwrap(); +const DEFAULT_MAX_SPONSORSHIPS_PER_NOTE: NonZeroUsize = NonZeroUsize::new(3).unwrap(); #[derive(Parser)] #[command(version, about, long_about = None)] @@ -120,6 +123,30 @@ pub enum NtxBuilderCommand { )] sqlite_connection_pool_size: NonZeroUsize, + /// Require a `FEE_SPONSORSHIP` note before including a network note in a transaction. + /// + /// When set, a network note with no pending sponsorship is skipped (without penalty) until + /// one arrives. Leave unset while fees are zero: available sponsorships are attached either + /// way. + #[arg( + long = "require-sponsorship", + env = ENV_REQUIRE_SPONSORSHIP, + default_value_t = false, + value_name = "BOOL" + )] + require_sponsorship: bool, + + /// Maximum number of `FEE_SPONSORSHIP` notes attached to a single network note. + /// + /// When a note has more pending sponsorships, a random subset of this size is selected. + #[arg( + long = "max-sponsorships-per-note", + env = ENV_MAX_SPONSORSHIPS_PER_NOTE, + default_value_t = DEFAULT_MAX_SPONSORSHIPS_PER_NOTE, + value_name = "NUM" + )] + max_sponsorships_per_note: NonZeroUsize, + /// Directory for the ntx-builder's persistent database. #[arg(long = "data-directory", env = ENV_DATA_DIRECTORY, value_name = "DIR")] data_directory: PathBuf, @@ -223,6 +250,8 @@ impl NtxBuilderCommand { max_tx_cycles, tx_expiration_delta, sqlite_connection_pool_size, + require_sponsorship, + max_sponsorships_per_note, data_directory, } = self else { @@ -242,6 +271,8 @@ impl NtxBuilderCommand { ntx_builder.idle_timeout = %humantime::Duration::from(idle_timeout), ntx_builder.max_cycles = max_tx_cycles, ntx_builder.tx_expiration_delta = tx_expiration_delta.get(), + ntx_builder.require_sponsorship = require_sponsorship, + ntx_builder.max_sponsorships_per_note = max_sponsorships_per_note.get(), sqlite.connection_pool_size = sqlite_connection_pool_size.get(), }, "Starting NTX builder", @@ -260,6 +291,8 @@ impl NtxBuilderCommand { .with_max_account_crashes(max_account_crashes) .with_max_cycles(max_tx_cycles) .with_tx_expiration_delta(tx_expiration_delta) + .with_require_sponsorship(require_sponsorship) + .with_max_sponsorships_per_note(max_sponsorships_per_note) .with_sqlite_connection_pool_size(sqlite_connection_pool_size); let config = match rpc_auth_header_value { Some(value) => config.with_rpc_auth_header(value), diff --git a/bin/ntx-builder/src/committed_block.rs b/bin/ntx-builder/src/committed_block.rs index 5370f892ae..7194645ed5 100644 --- a/bin/ntx-builder/src/committed_block.rs +++ b/bin/ntx-builder/src/committed_block.rs @@ -7,6 +7,7 @@ use miden_protocol::transaction::{OutputNote, TransactionId}; use miden_standards::note::AccountTargetNetworkNote; use crate::db::queries::account_effect::NetworkAccountEffect; +use crate::sponsorship::SponsorshipNote; /// Network-relevant state extracted from a committed [`SignedBlock`]. /// @@ -16,6 +17,9 @@ use crate::db::queries::account_effect::NetworkAccountEffect; pub struct CommittedBlockEffects { pub header: BlockHeader, pub network_notes: Vec, + /// `FEE_SPONSORSHIP` notes created by this block. Indexed by feature note id so transaction + /// selection can include each sponsorship in the same transaction as its feature note. + pub sponsorship_notes: Vec, pub nullifiers: Vec, pub network_account_updates: Vec<(AccountId, AccountUpdateDetails)>, /// Transaction id paired with the account it updated, for every transaction in the block. @@ -26,20 +30,27 @@ pub struct CommittedBlockEffects { impl CommittedBlockEffects { /// Filters the committed block down to the slice the ntx-builder cares about: public network - /// notes, network-account updates, and all created nullifiers. + /// notes, `FEE_SPONSORSHIP` notes, network-account updates, and all created nullifiers. /// /// Private output notes cannot be network notes (which must be public) and are skipped. Non- - /// network output notes and non-network account updates are also dropped. + /// network output notes and non-network account updates are also dropped. `FEE_SPONSORSHIP` + /// notes carry no attachments, so they are recognized by script root before the attachment + /// check that classifies network notes. pub fn from_signed_block(block: &SignedBlock) -> Self { let header = block.header().clone(); let body = block.body(); let mut network_notes = Vec::new(); + let mut sponsorship_notes = Vec::new(); for batch in body.output_note_batches() { for (_idx, output_note) in batch { - if let OutputNote::Public(public) = output_note - && let Ok(network_note) = - AccountTargetNetworkNote::new(public.as_note().clone()) + let OutputNote::Public(public) = output_note else { + continue; + }; + if let Some(sponsorship) = SponsorshipNote::try_from_note(public.as_note()) { + sponsorship_notes.push(sponsorship); + } else if let Ok(network_note) = + AccountTargetNetworkNote::new(public.as_note().clone()) { network_notes.push(network_note); } @@ -73,6 +84,7 @@ impl CommittedBlockEffects { Self { header, network_notes, + sponsorship_notes, nullifiers, network_account_updates, account_transactions, @@ -103,3 +115,53 @@ impl CommittedBlockEffects { self.account_transactions.iter().copied().collect() } } + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use miden_protocol::block::{BlockBody, BlockNumber, BlockSignatures, SignedBlock}; + use miden_protocol::transaction::{OrderedTransactionHeaders, PublicOutputNote}; + + use super::*; + use crate::test_utils::{ + mock_block_header, + mock_network_account_id, + mock_single_target_note, + mock_sponsorship_note, + }; + + /// `FEE_SPONSORSHIP` notes are extracted by script root, everything else keeps going through + /// the attachment-based network-note classification. + #[test] + fn from_signed_block_splits_network_and_sponsorship_notes() { + let account_id = mock_network_account_id(); + let feature = mock_single_target_note(account_id, 1); + let sponsorship = mock_sponsorship_note(account_id, feature.as_note().id(), 2); + + let batch = vec![ + (0, OutputNote::Public(PublicOutputNote::new(feature.as_note().clone()).unwrap())), + (1, OutputNote::Public(PublicOutputNote::new(sponsorship.clone()).unwrap())), + ]; + let body = BlockBody::new_unchecked( + Vec::new(), + vec![batch], + Vec::new(), + OrderedTransactionHeaders::new_unchecked(Vec::new()), + ); + let block = SignedBlock::new_unchecked( + mock_block_header(BlockNumber::from(1)), + body, + BlockSignatures::new(Vec::new()).unwrap(), + ); + + let effects = CommittedBlockEffects::from_signed_block(&block); + + assert_eq!(effects.network_notes.len(), 1); + assert_eq!(effects.network_notes[0].as_note().id(), feature.as_note().id()); + assert_eq!(effects.sponsorship_notes.len(), 1); + assert_eq!(effects.sponsorship_notes[0].id(), sponsorship.id()); + assert_eq!(effects.sponsorship_notes[0].feature_note_id(), feature.as_note().id()); + } +} diff --git a/bin/ntx-builder/src/coordinator.rs b/bin/ntx-builder/src/coordinator.rs index cae9c7499b..5aa2d72345 100644 --- a/bin/ntx-builder/src/coordinator.rs +++ b/bin/ntx-builder/src/coordinator.rs @@ -230,9 +230,15 @@ impl Coordinator { /// committed state exists (deferring the rest until their creation commits), releases deferred /// spawns for accounts created by this block, and pushes a fresh [`AccountView`] to every /// active actor so it can re-evaluate its state in memory. + /// + /// `sponsored_accounts` names the accounts whose pending feature notes gained a sponsorship in + /// this block (one entry per sponsorship, resolved by `apply_committed_block`). They are woken + /// exactly like accounts targeted by a new network note: a feature note that selection skipped + /// for lacking a sponsorship becomes viable when its sponsorship arrives. pub async fn handle_committed_block( &mut self, effects: &CommittedBlockEffects, + sponsored_accounts: &[AccountId], ) -> anyhow::Result<()> { // Accounts created by this block release any spawn deferred on their creation. for account_id in effects.created_network_accounts() { @@ -241,11 +247,12 @@ impl Coordinator { } } - let targeted: HashSet = effects + let mut targeted: HashSet = effects .network_notes .iter() .map(AccountTargetNetworkNote::target_account_id) .collect(); + targeted.extend(sponsored_accounts.iter().copied()); for account_id in &targeted { self.spawn_actor_when_committed(*account_id).await?; } @@ -253,13 +260,17 @@ impl Coordinator { // Push the block's effects to every active actor. The latest transaction per account is the // same map `apply_committed_block` uses for `accounts.last_tx_id`, so the pushed // `last_committed_tx` agrees with the persisted state; the per-account note counts feed the - // `notes_seen` work counter. + // `notes_seen` work counter. A sponsorship for a pending feature note counts as work just + // like a new note. let chain_tip = effects.header.block_num(); let latest_tx = effects.latest_tx_per_account(); let mut new_notes: HashMap = HashMap::new(); for note in &effects.network_notes { *new_notes.entry(note.target_account_id()).or_default() += 1; } + for account_id in sponsored_accounts { + *new_notes.entry(*account_id).or_default() += 1; + } for (account_id, handle) in &self.actor_registry { let committed_tx = latest_tx.get(account_id).copied(); @@ -401,12 +412,13 @@ mod tests { let effects = CommittedBlockEffects { header: mock_block_header(1_u32.into()), network_notes: vec![note], + sponsorship_notes: vec![], nullifiers: vec![], network_account_updates: vec![], account_transactions: vec![], }; - coordinator.handle_committed_block(&effects).await.unwrap(); + coordinator.handle_committed_block(&effects, &[]).await.unwrap(); assert!( coordinator.actor_registry.contains_key(&target_id), @@ -426,11 +438,12 @@ mod tests { let effects = CommittedBlockEffects { header: mock_block_header(1_u32.into()), network_notes: vec![note], + sponsorship_notes: vec![], nullifiers: vec![], network_account_updates: vec![], account_transactions: vec![], }; - coordinator.handle_committed_block(&effects).await.unwrap(); + coordinator.handle_committed_block(&effects, &[]).await.unwrap(); assert!( !coordinator.actor_registry.contains_key(&account_id), @@ -449,11 +462,12 @@ mod tests { let effects = CommittedBlockEffects { header: mock_block_header(2_u32.into()), network_notes: vec![], + sponsorship_notes: vec![], nullifiers: vec![], network_account_updates: vec![(account_id, details)], account_transactions: vec![], }; - coordinator.handle_committed_block(&effects).await.unwrap(); + coordinator.handle_committed_block(&effects, &[]).await.unwrap(); assert!( coordinator.actor_registry.contains_key(&account_id), @@ -473,6 +487,7 @@ mod tests { let effects = CommittedBlockEffects { header: mock_block_header(1_u32.into()), network_notes: vec![], + sponsorship_notes: vec![], nullifiers: vec![], network_account_updates: vec![( updated_id, @@ -481,7 +496,7 @@ mod tests { account_transactions: vec![], }; - coordinator.handle_committed_block(&effects).await.unwrap(); + coordinator.handle_committed_block(&effects, &[]).await.unwrap(); assert!( !coordinator.actor_registry.contains_key(&updated_id), @@ -536,12 +551,13 @@ mod tests { let effects = CommittedBlockEffects { header: mock_block_header(1_u32.into()), network_notes: vec![note], + sponsorship_notes: vec![], nullifiers: vec![], network_account_updates: vec![], account_transactions: vec![], }; - coordinator.handle_committed_block(&effects).await.unwrap(); + coordinator.handle_committed_block(&effects, &[]).await.unwrap(); assert!( bystander_rx.has_changed().unwrap(), @@ -575,12 +591,13 @@ mod tests { let effects = CommittedBlockEffects { header: mock_block_header(3_u32.into()), network_notes: vec![note], + sponsorship_notes: vec![], nullifiers: vec![], network_account_updates: vec![], account_transactions: vec![(account_id, tx_id)], }; - coordinator.handle_committed_block(&effects).await.unwrap(); + coordinator.handle_committed_block(&effects, &[]).await.unwrap(); let view = rx.borrow_and_update(); assert_eq!(view.chain_tip, 3_u32.into()); @@ -591,4 +608,34 @@ mod tests { ); assert_eq!(view.notes_seen, 1, "one note targeting the account bumps the work counter"); } + + /// A sponsorship arriving for an account's pending feature note counts as new work: the feature + /// note may have been skipped for lacking a sponsorship, and this wakes the actor for a + /// re-selection. + #[tokio::test] + async fn handle_committed_block_sponsorship_wakeup_bumps_notes_seen() { + let (mut coordinator, _db, _dir, _rx) = Coordinator::test().await; + + let account_id = mock_network_account_id(); + let mut rx = register_dummy_actor(&mut coordinator, account_id); + let _ = rx.borrow_and_update(); + + // The block carries no network note for the account; only a sponsorship resolved to it. + let effects = CommittedBlockEffects { + header: mock_block_header(1_u32.into()), + network_notes: vec![], + sponsorship_notes: vec![], + nullifiers: vec![], + network_account_updates: vec![], + account_transactions: vec![], + }; + + coordinator.handle_committed_block(&effects, &[account_id]).await.unwrap(); + + let view = rx.borrow_and_update(); + assert_eq!( + view.notes_seen, 1, + "a sponsorship for a pending feature note bumps the work counter", + ); + } } diff --git a/bin/ntx-builder/src/db/migrations.rs b/bin/ntx-builder/src/db/migrations.rs index 96447cd4df..d495c0f54c 100644 --- a/bin/ntx-builder/src/db/migrations.rs +++ b/bin/ntx-builder/src/db/migrations.rs @@ -67,9 +67,10 @@ mod tests { use super::*; - const EXPECTED_SCHEMA_HASHES: [SchemaHash; 2] = [ + const EXPECTED_SCHEMA_HASHES: [SchemaHash; 3] = [ SchemaHash::from_hex("c631b773787903a3dd5ea4df5e7374119b3f02b35bacf14d11eacd8d8500e3d9"), SchemaHash::from_hex("26b17298444f674b06327ae7289516fe75b59926741b1221ebf36735822d116a"), + SchemaHash::from_hex("3732195aa92de6b246e01638f284be9afaa558eaa9db7090132338f3fa0425ef"), ]; #[test] diff --git a/bin/ntx-builder/src/db/migrations/003_sponsorship_notes.sql b/bin/ntx-builder/src/db/migrations/003_sponsorship_notes.sql new file mode 100644 index 0000000000..52266ad446 --- /dev/null +++ b/bin/ntx-builder/src/db/migrations/003_sponsorship_notes.sql @@ -0,0 +1,29 @@ +-- FEE_SPONSORSHIP notes, indexed by the feature note they pay the fee for. +-- +-- Sponsorship notes have no backoff lifecycle of their own: execution failures are attributed to +-- the feature note, whose row in `notes` carries the attempt tracking. +CREATE TABLE sponsorship_notes ( + -- Nullifier bytes (32 bytes). Primary key. + nullifier BLOB PRIMARY KEY, + -- Note ID bytes. + note_id BLOB NOT NULL, + -- Note ID of the feature note this sponsorship pays for. Joins against `notes.note_id`. + feature_note_id BLOB NOT NULL, + -- Serialized Note. + note_data BLOB NOT NULL, + -- Block height at or after which the reclaimer may reclaim the note. NULL when reclaim is + -- disabled. + reclaim_height BIGINT, + -- Block number in which the note's nullifier was observed in a committed block. NULL while + -- the note is still pending consumption. + committed_at BIGINT, + + CONSTRAINT sponsorship_notes_reclaim_height_is_u32 + CHECK (reclaim_height BETWEEN 0 AND 0xFFFFFFFF), + CONSTRAINT sponsorship_notes_committed_at_is_u32 + CHECK (committed_at BETWEEN 0 AND 0xFFFFFFFF) +) WITHOUT ROWID; + +-- Partial index covering the selection-time join (`feature_note_id = ? AND committed_at IS NULL`). +CREATE INDEX idx_sponsorship_notes_feature ON sponsorship_notes(feature_note_id) + WHERE committed_at IS NULL; diff --git a/bin/ntx-builder/src/db/mod.rs b/bin/ntx-builder/src/db/mod.rs index 47e32fa1ea..1138dd53cc 100644 --- a/bin/ntx-builder/src/db/mod.rs +++ b/bin/ntx-builder/src/db/mod.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; @@ -9,7 +10,7 @@ use miden_protocol::Word; use miden_protocol::account::{Account, AccountId}; use miden_protocol::block::{BlockHeader, BlockNumber, SignedBlock, ValidatorKeys}; use miden_protocol::crypto::merkle::mmr::PartialMmr; -use miden_protocol::note::{NoteId, NoteScript, Nullifier}; +use miden_protocol::note::{Note, NoteId, NoteScript, Nullifier}; #[cfg(test)] use miden_protocol::transaction::TransactionId; #[cfg(test)] @@ -19,6 +20,8 @@ use tracing::info; use crate::committed_block::CommittedBlockEffects; use crate::db::migrations::{bootstrap_database, migrate_database, verify_latest_schema}; use crate::db::queries::NoteStatusRow; +#[cfg(test)] +use crate::sponsorship::SponsorshipNote; use crate::{COMPONENT, NoteError, db}; pub(crate) mod queries; @@ -155,6 +158,20 @@ impl NtxDbReader { .read("get_note_status", move |tx| crate::db::queries::get_note_status(tx, note_id)) .await } + + /// Returns the unconsumed `FEE_SPONSORSHIP` notes bound to the account's unconsumed feature + /// notes, grouped by feature note id. Used by transaction selection to attach each feature + /// note's sponsorships to its group. + pub(crate) async fn sponsorships_for_pending_notes( + &self, + account_id: AccountId, + ) -> Result>, DatabaseError> { + self.reader + .read("sponsorships_for_pending_notes", move |tx| { + queries::sponsorships_for_pending_notes(tx, account_id) + }) + .await + } } /// Write handle to the ntx-builder database. @@ -194,11 +211,14 @@ impl NtxDbWriter { .await } + /// Applies a committed block's effects and returns the accounts whose pending feature notes + /// gained a sponsorship in this block (one entry per sponsorship), so the coordinator can wake + /// their actors. pub(crate) async fn apply_committed_block( &self, effects: CommittedBlockEffects, chain_mmr: PartialMmr, - ) -> Result<(), DatabaseError> { + ) -> Result, DatabaseError> { self.writer .write("apply_committed_block", move |tx| { queries::apply_committed_block(tx, &effects, &chain_mmr) @@ -381,6 +401,10 @@ impl NtxDbReader { pub(crate) async fn count_chain_state(&self) -> i64 { self.count("SELECT COUNT(*) FROM chain_state").await } + + pub(crate) async fn count_sponsorship_notes(&self) -> i64 { + self.count("SELECT COUNT(*) FROM sponsorship_notes").await + } } /// Test-only write helpers. @@ -426,6 +450,29 @@ impl NtxDbWriter { .await } + pub(crate) async fn insert_sponsorship_notes( + &self, + notes: Vec, + ) -> Result<(), DatabaseError> { + self.writer + .write("insert_sponsorship_notes", move |tx| { + queries::insert_sponsorship_notes(tx, ¬es) + }) + .await + } + + pub(crate) async fn mark_sponsorships_consumed( + &self, + nullifiers: Vec, + block_num: BlockNumber, + ) -> Result<(), DatabaseError> { + self.writer + .write("mark_sponsorships_consumed", move |tx| { + queries::mark_sponsorships_consumed(tx, &nullifiers, block_num) + }) + .await + } + pub(crate) async fn update_chain_state_tip( &self, block_header: BlockHeader, diff --git a/bin/ntx-builder/src/db/queries/insert_sponsorship_notes/insert_sponsorship_note.sql b/bin/ntx-builder/src/db/queries/insert_sponsorship_notes/insert_sponsorship_note.sql new file mode 100644 index 0000000000..7761b8a3f0 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/insert_sponsorship_notes/insert_sponsorship_note.sql @@ -0,0 +1,5 @@ +-- Inserts a FEE_SPONSORSHIP note from a committed block. Uses `INSERT OR IGNORE` so re-applying +-- the same block (e.g. on a redelivery from the subscription stream) is a no-op rather than a +-- constraint violation. `committed_at` defaults to NULL (pending consumption). +INSERT OR IGNORE INTO sponsorship_notes (nullifier, note_id, feature_note_id, note_data, reclaim_height) +VALUES (?1, ?2, ?3, ?4, ?5) diff --git a/bin/ntx-builder/src/db/queries/insert_sponsorship_notes/mod.rs b/bin/ntx-builder/src/db/queries/insert_sponsorship_notes/mod.rs new file mode 100644 index 0000000000..5b81d712c7 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/insert_sponsorship_notes/mod.rs @@ -0,0 +1,33 @@ +//! Inserts `FEE_SPONSORSHIP` notes from a committed block. + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::WriteTx; + +use crate::sponsorship::SponsorshipNote; + +const SQL: &str = include_str!("insert_sponsorship_note.sql"); + +/// Inserts `FEE_SPONSORSHIP` notes from a committed block. Uses `INSERT OR IGNORE` so re-applying +/// the same block (e.g. on a redelivery from the subscription stream) is a no-op rather than a +/// constraint violation. +/// +/// The feature note the sponsorship is bound to does not have to be known yet: the binding is +/// resolved at selection time by joining `feature_note_id` against `notes.note_id`. +pub fn insert_sponsorship_notes( + tx: &WriteTx<'_>, + notes: &[SponsorshipNote], +) -> Result<(), DatabaseError> { + for note in notes { + tx.execute( + SQL, + &[ + ¬e.nullifier(), + ¬e.id(), + ¬e.feature_note_id(), + note.as_note(), + ¬e.reclaim_height(), + ], + )?; + } + Ok(()) +} diff --git a/bin/ntx-builder/src/db/queries/mark_sponsorships_consumed/mark_sponsorship_consumed.sql b/bin/ntx-builder/src/db/queries/mark_sponsorships_consumed/mark_sponsorship_consumed.sql new file mode 100644 index 0000000000..dc41432bf2 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/mark_sponsorships_consumed/mark_sponsorship_consumed.sql @@ -0,0 +1,7 @@ +-- Marks FEE_SPONSORSHIP notes as consumed by setting `committed_at` to the block number whose +-- committed body contained their nullifiers. This covers both consumption alongside the feature +-- note and an external reclaim. Nullifiers we never inserted are silently skipped (no match). Rows +-- are kept around (not deleted), mirroring the `notes` table lifecycle. +UPDATE sponsorship_notes +SET committed_at = ?2 +WHERE nullifier IN (SELECT value FROM rarray(?1)) AND committed_at IS NULL diff --git a/bin/ntx-builder/src/db/queries/mark_sponsorships_consumed/mod.rs b/bin/ntx-builder/src/db/queries/mark_sponsorships_consumed/mod.rs new file mode 100644 index 0000000000..fe1f079ca7 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/mark_sponsorships_consumed/mod.rs @@ -0,0 +1,26 @@ +//! Marks `FEE_SPONSORSHIP` notes as consumed by the block that contained their nullifier. + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::{InList, WriteTx}; +use miden_protocol::block::BlockNumber; +use miden_protocol::note::Nullifier; +use miden_protocol::utils::serde::Serializable; + +const SQL: &str = include_str!("mark_sponsorship_consumed.sql"); + +/// Marks `FEE_SPONSORSHIP` notes as consumed by setting `committed_at` to the block number whose +/// committed body contained their nullifier. This covers both consumption alongside the feature +/// note and an external reclaim; either way the note is spent and no longer attachable. Nullifiers +/// we never inserted are silently skipped. +pub fn mark_sponsorships_consumed( + tx: &WriteTx<'_>, + nullifiers: &[Nullifier], + block_num: BlockNumber, +) -> Result<(), DatabaseError> { + // The bound blobs must outlive the query, so they are materialized before building the list. + let serialized: Vec> = nullifiers.iter().map(Serializable::to_bytes).collect(); + let nullifiers = InList::from_blobs(serialized.iter().map(Vec::as_slice)); + + tx.execute(SQL, &[&nullifiers, &block_num])?; + Ok(()) +} diff --git a/bin/ntx-builder/src/db/queries/mod.rs b/bin/ntx-builder/src/db/queries/mod.rs index 31356ce20d..5f606f7625 100644 --- a/bin/ntx-builder/src/db/queries/mod.rs +++ b/bin/ntx-builder/src/db/queries/mod.rs @@ -8,6 +8,7 @@ use miden_node_db::DatabaseError; use miden_node_db::sqlite::WriteTx; use miden_protocol::Word; +use miden_protocol::account::AccountId; use miden_protocol::block::BlockNumber; use miden_protocol::crypto::merkle::mmr::PartialMmr; use miden_protocol::transaction::TransactionId; @@ -55,12 +56,18 @@ pub use insert_network_notes::insert_network_notes; mod insert_note_scripts; pub use insert_note_scripts::insert_note_script; +mod insert_sponsorship_notes; +pub use insert_sponsorship_notes::insert_sponsorship_notes; + mod lookup_note_script; pub use lookup_note_script::lookup_note_script; mod mark_notes_consumed; pub use mark_notes_consumed::mark_notes_consumed; +mod mark_sponsorships_consumed; +pub use mark_sponsorships_consumed::mark_sponsorships_consumed; + mod notes_failed; pub use notes_failed::notes_failed; @@ -73,6 +80,12 @@ pub use select_genesis_commitment::select_genesis_commitment; mod select_genesis_validator_keys; pub use select_genesis_validator_keys::select_genesis_validator_keys; +mod sponsored_accounts; +pub use sponsored_accounts::sponsored_accounts; + +mod sponsorships_for_pending_notes; +pub use sponsorships_for_pending_notes::sponsorships_for_pending_notes; + mod update_chain_state_tip; pub use update_chain_state_tip::update_chain_state_tip; @@ -89,13 +102,18 @@ mod tests; /// /// - Upserts each touched network account: new full-state path insert, partial patches apply to /// the existing committed row. -/// - Inserts each network note (`INSERT OR IGNORE` to tolerate redeliveries). -/// - Marks any of our pending notes whose nullifiers appear in this block as `committed_at = -/// block_num`, preserving the row so the `GetNetworkNoteStatus` endpoint can report the full -/// lifecycle. +/// - Inserts each network note and `FEE_SPONSORSHIP` note (`INSERT OR IGNORE` to tolerate +/// redeliveries). +/// - Marks any of our pending notes (feature and sponsorship alike) whose nullifiers appear in +/// this block as `committed_at = block_num`, preserving the row so the `GetNetworkNoteStatus` +/// endpoint can report the full lifecycle. /// - Updates the singleton `chain_state` row's tip with the new block header and the /// post-application chain MMR. /// +/// Returns the accounts whose pending feature notes gained a sponsorship in this block (one entry +/// per sponsorship), so the coordinator can wake their actors: a feature note skipped for lacking a +/// sponsorship becomes viable when its sponsorship arrives later. +/// /// The account upserts apply each block's network-account effects to the local store so an actor's /// post-expiry reload sees the authoritative committed state. The recorded `accounts.last_tx_id` and /// the `last_committed_tx` the coordinator pushes to actors both derive from the block's @@ -104,7 +122,7 @@ pub fn apply_committed_block( tx: &WriteTx<'_>, effects: &CommittedBlockEffects, chain_mmr: &PartialMmr, -) -> Result<(), DatabaseError> { +) -> Result, DatabaseError> { // The latest transaction in this block per account, from the same source the coordinator uses // for each `AccountView`'s `last_committed_tx`, so the persisted `accounts.last_tx_id` and the // pushed landing state agree. For block-producer output every committed account update @@ -149,10 +167,16 @@ pub fn apply_committed_block( } insert_network_notes(tx, &effects.network_notes)?; + insert_sponsorship_notes(tx, &effects.sponsorship_notes)?; mark_notes_consumed(tx, &effects.nullifiers, effects.header.block_num())?; + mark_sponsorships_consumed(tx, &effects.nullifiers, effects.header.block_num())?; + + // Resolved after the consumption marks so a feature note consumed in this same block does not + // produce a wakeup. + let sponsored = sponsored_accounts(tx, &effects.sponsorship_notes)?; update_chain_state_tip(tx, effects.header.block_num(), &effects.header, chain_mmr)?; - Ok(()) + Ok(sponsored) } diff --git a/bin/ntx-builder/src/db/queries/sponsored_accounts/mod.rs b/bin/ntx-builder/src/db/queries/sponsored_accounts/mod.rs new file mode 100644 index 0000000000..231fd046a0 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/sponsored_accounts/mod.rs @@ -0,0 +1,26 @@ +//! Resolves the accounts whose pending feature notes just gained a sponsorship. + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::WriteTx; +use miden_protocol::account::AccountId; + +use crate::sponsorship::SponsorshipNote; + +const SQL: &str = include_str!("sponsored_account.sql"); + +/// Returns, for each sponsorship, the account targeted by the pending feature note it is bound to. +/// +/// The result may name the same account several times (once per sponsorship); the coordinator +/// counts every occurrence towards the account's work counter. +pub fn sponsored_accounts( + tx: &WriteTx<'_>, + sponsorships: &[SponsorshipNote], +) -> Result, DatabaseError> { + let mut accounts = Vec::new(); + for sponsorship in sponsorships { + let rows = + tx.query(SQL, &[&sponsorship.feature_note_id()], |row| row.get::(0))?; + accounts.extend(rows); + } + Ok(accounts) +} diff --git a/bin/ntx-builder/src/db/queries/sponsored_accounts/sponsored_account.sql b/bin/ntx-builder/src/db/queries/sponsored_accounts/sponsored_account.sql new file mode 100644 index 0000000000..9048ae35d9 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/sponsored_accounts/sponsored_account.sql @@ -0,0 +1,4 @@ +-- Resolves the account targeted by the (still unconsumed) feature note a FEE_SPONSORSHIP note is +-- bound to. No row matches when the feature note is unknown or already consumed. +SELECT account_id FROM notes +WHERE note_id = ?1 AND committed_at IS NULL diff --git a/bin/ntx-builder/src/db/queries/sponsorships_for_pending_notes/mod.rs b/bin/ntx-builder/src/db/queries/sponsorships_for_pending_notes/mod.rs new file mode 100644 index 0000000000..ad2fb7d3d1 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/sponsorships_for_pending_notes/mod.rs @@ -0,0 +1,26 @@ +//! Selects the unconsumed `FEE_SPONSORSHIP` notes bound to an account's pending feature notes. + +use std::collections::HashMap; + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::ReadTx; +use miden_protocol::account::AccountId; +use miden_protocol::note::{Note, NoteId}; + +const SQL: &str = include_str!("sponsorships_for_pending_notes.sql"); + +/// Returns the unconsumed `FEE_SPONSORSHIP` notes bound to the given account's unconsumed feature +/// notes, grouped by feature note id. +pub fn sponsorships_for_pending_notes( + tx: &ReadTx<'_>, + account_id: AccountId, +) -> Result>, DatabaseError> { + let rows = + tx.query(SQL, &[&account_id], |row| Ok((row.get::(0)?, row.get::(1)?)))?; + + let mut sponsorships: HashMap> = HashMap::new(); + for (feature_note_id, note) in rows { + sponsorships.entry(feature_note_id).or_default().push(note); + } + Ok(sponsorships) +} diff --git a/bin/ntx-builder/src/db/queries/sponsorships_for_pending_notes/sponsorships_for_pending_notes.sql b/bin/ntx-builder/src/db/queries/sponsorships_for_pending_notes/sponsorships_for_pending_notes.sql new file mode 100644 index 0000000000..c537546151 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/sponsorships_for_pending_notes/sponsorships_for_pending_notes.sql @@ -0,0 +1,6 @@ +-- Selects unconsumed FEE_SPONSORSHIP notes bound to the given account's unconsumed feature notes. +-- The binding is by feature note id (the sponsorship's tag is only a discovery hint), so +-- sponsorships whose feature note is unknown, consumed, or targets another account do not match. +SELECT s.feature_note_id, s.note_data FROM sponsorship_notes s +JOIN notes n ON n.note_id = s.feature_note_id +WHERE n.account_id = ?1 AND n.committed_at IS NULL AND s.committed_at IS NULL diff --git a/bin/ntx-builder/src/db/queries/tests.rs b/bin/ntx-builder/src/db/queries/tests.rs index 2c431c0602..6312137f2e 100644 --- a/bin/ntx-builder/src/db/queries/tests.rs +++ b/bin/ntx-builder/src/db/queries/tests.rs @@ -7,13 +7,16 @@ use std::sync::Arc; use miden_protocol::Word; +use miden_protocol::account::AccountId; use miden_protocol::block::BlockNumber; use miden_protocol::crypto::merkle::mmr::PartialMmr; +use miden_protocol::note::NoteId; use miden_protocol::transaction::TransactionId; use crate::NoteError; use crate::committed_block::CommittedBlockEffects; use crate::db::test_setup; +use crate::sponsorship::SponsorshipNote; use crate::test_utils::*; // TEST HARNESS @@ -130,6 +133,159 @@ async fn available_notes_excludes_consumed_notes() { ); } +// SPONSORSHIP NOTES +// ================================================================================================ + +/// Builds a [`SponsorshipNote`](crate::sponsorship::SponsorshipNote) bound to the given feature +/// note id. +fn sponsorship_for(target: AccountId, feature_note_id: NoteId, seed: u8) -> SponsorshipNote { + let note = mock_sponsorship_note(target, feature_note_id, seed); + SponsorshipNote::try_from_note(¬e).expect("mock sponsorship note must decode") +} + +#[tokio::test] +async fn insert_sponsorship_notes_is_idempotent() { + let (db, _dir) = test_setup().await; + let account_id = mock_network_account_id(); + let feature = mock_single_target_note(account_id, 1); + let sponsorship = sponsorship_for(account_id, feature.as_note().id(), 2); + + db.insert_sponsorship_notes(vec![sponsorship.clone()]).await.unwrap(); + // Re-applying the same block (e.g. on a subscription redelivery) must not error or duplicate. + db.insert_sponsorship_notes(vec![sponsorship]).await.unwrap(); + + assert_eq!(db.count_sponsorship_notes().await, 1); +} + +/// The binding is resolved at selection time, so insertion order between a sponsorship and its +/// feature note must not matter. +#[tokio::test] +async fn sponsorships_for_pending_notes_resolves_sponsorship_inserted_before_feature_note() { + let (db, _dir) = test_setup().await; + let account_id = mock_network_account_id(); + let feature = mock_single_target_note(account_id, 1); + let sponsorship = sponsorship_for(account_id, feature.as_note().id(), 2); + + // The sponsorship commits first: it is stored, but unresolved (no feature note row to join). + db.insert_sponsorship_notes(vec![sponsorship]).await.unwrap(); + assert!(db.sponsorships_for_pending_notes(account_id).await.unwrap().is_empty()); + + // Once the feature note commits, the join finds the pair. + db.insert_network_notes(vec![feature.clone()]).await.unwrap(); + let pending = db.sponsorships_for_pending_notes(account_id).await.unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[&feature.as_note().id()].len(), 1); +} + +/// A feature note may have any number of sponsorships; all unconsumed ones are returned together. +#[tokio::test] +async fn sponsorships_for_pending_notes_groups_multiple_per_feature_note() { + let (db, _dir) = test_setup().await; + let account_id = mock_network_account_id(); + let feature = mock_single_target_note(account_id, 1); + let feature_id = feature.as_note().id(); + + db.insert_network_notes(vec![feature]).await.unwrap(); + db.insert_sponsorship_notes(vec![ + sponsorship_for(account_id, feature_id, 2), + sponsorship_for(account_id, feature_id, 3), + ]) + .await + .unwrap(); + + let pending = db.sponsorships_for_pending_notes(account_id).await.unwrap(); + assert_eq!(pending[&feature_id].len(), 2); +} + +/// A consumed sponsorship (spent alongside its feature note or reclaimed externally) must never be +/// attached again; a consumed feature note must not pull its sponsorships either. +#[tokio::test] +async fn sponsorships_for_pending_notes_excludes_consumed_rows() { + let (db, _dir) = test_setup().await; + let account_id = mock_network_account_id(); + let feature_a = mock_single_target_note(account_id, 1); + let feature_b = mock_single_target_note(account_id, 2); + let sponsorship_a = sponsorship_for(account_id, feature_a.as_note().id(), 3); + let sponsorship_b = sponsorship_for(account_id, feature_b.as_note().id(), 4); + + db.insert_network_notes(vec![feature_a.clone(), feature_b.clone()]) + .await + .unwrap(); + db.insert_sponsorship_notes(vec![sponsorship_a.clone(), sponsorship_b]) + .await + .unwrap(); + assert_eq!(db.sponsorships_for_pending_notes(account_id).await.unwrap().len(), 2); + + // Sponsorship A is reclaimed externally: only the pair around feature B remains. + db.mark_sponsorships_consumed(vec![sponsorship_a.nullifier()], BlockNumber::from(7)) + .await + .unwrap(); + let pending = db.sponsorships_for_pending_notes(account_id).await.unwrap(); + assert_eq!(pending.len(), 1); + assert!(pending.contains_key(&feature_b.as_note().id())); + + // Feature B is consumed: nothing is pending, but the rows are retained for status reporting. + db.mark_notes_consumed(vec![feature_b.as_note().nullifier()], BlockNumber::from(8)) + .await + .unwrap(); + assert!(db.sponsorships_for_pending_notes(account_id).await.unwrap().is_empty()); + assert_eq!(db.count_sponsorship_notes().await, 2); +} + +/// A sponsorship bound to a feature note targeting a different account must not leak into this +/// account's pending set: the join goes through `notes.account_id`, not the sponsorship's tag. +#[tokio::test] +async fn sponsorships_for_pending_notes_binds_by_feature_note_not_tag() { + let (db, _dir) = test_setup().await; + let alice = mock_network_account_id(); + let bob = mock_network_account_id_seeded(42); + let feature = mock_single_target_note(bob, 1); + // Tagged for alice, but bound to a feature note targeting bob. + let sponsorship = sponsorship_for(alice, feature.as_note().id(), 2); + + db.insert_network_notes(vec![feature.clone()]).await.unwrap(); + db.insert_sponsorship_notes(vec![sponsorship]).await.unwrap(); + + assert!(db.sponsorships_for_pending_notes(alice).await.unwrap().is_empty()); + let pending = db.sponsorships_for_pending_notes(bob).await.unwrap(); + assert_eq!(pending[&feature.as_note().id()].len(), 1); +} + +/// `apply_committed_block` reports one wakeup per sponsorship whose feature note is known and still +/// pending; sponsorships for consumed or unknown feature notes wake nobody. +#[tokio::test] +async fn apply_committed_block_returns_sponsored_account_wakeups() { + let (db, _dir) = test_setup().await; + let account_id = mock_network_account_id(); + let pending = mock_single_target_note(account_id, 1); + let consumed = mock_single_target_note(account_id, 2); + db.insert_network_notes(vec![pending.clone(), consumed.clone()]).await.unwrap(); + db.mark_notes_consumed(vec![consumed.as_note().nullifier()], BlockNumber::from(1)) + .await + .unwrap(); + + let effects = CommittedBlockEffects { + header: mock_block_header(BlockNumber::from(2)), + network_notes: vec![], + sponsorship_notes: vec![ + sponsorship_for(account_id, pending.as_note().id(), 3), + sponsorship_for(account_id, consumed.as_note().id(), 4), + sponsorship_for(account_id, NoteId::from_raw(Word::from([9, 9, 9, 9u32])), 5), + ], + nullifiers: vec![], + network_account_updates: vec![], + account_transactions: vec![], + }; + + let wakeups = db.apply_committed_block(effects, PartialMmr::default()).await.unwrap(); + + assert_eq!( + wakeups, + vec![account_id], + "only the sponsorship bound to the pending feature note wakes its account", + ); +} + // AVAILABLE NOTES + BACKOFF // ================================================================================================ @@ -307,6 +463,7 @@ fn genesis_effects() -> CommittedBlockEffects { CommittedBlockEffects { header: mock_block_header(BlockNumber::GENESIS), network_notes: vec![], + sponsorship_notes: vec![], nullifiers: vec![], network_account_updates: vec![(account.id(), details)], account_transactions: vec![], diff --git a/bin/ntx-builder/src/lib.rs b/bin/ntx-builder/src/lib.rs index cbad45e55d..8513f8011e 100644 --- a/bin/ntx-builder/src/lib.rs +++ b/bin/ntx-builder/src/lib.rs @@ -29,6 +29,7 @@ mod committed_block; mod coordinator; pub(crate) mod db; pub mod server; +mod sponsorship; #[cfg(test)] pub(crate) mod test_utils; @@ -91,6 +92,10 @@ pub const LOG_TARGET: &str = "user::miden-ntx-builder"; const DEFAULT_MAX_NOTES_PER_TX: NonZeroUsize = NonZeroUsize::new(20).expect("literal is non-zero"); const _: () = assert!(DEFAULT_MAX_NOTES_PER_TX.get() <= miden_tx::MAX_NUM_CHECKER_NOTES); +/// Default maximum number of `FEE_SPONSORSHIP` notes attached to a single feature note. +const DEFAULT_MAX_SPONSORSHIPS_PER_NOTE: NonZeroUsize = + NonZeroUsize::new(3).expect("literal is non-zero"); + /// Default maximum number of network transactions which should be in progress concurrently. /// /// This only counts transactions which are being computed locally and does not include @@ -164,9 +169,19 @@ pub struct NtxBuilderConfig { /// account actors. pub max_concurrent_txs: usize, - /// Maximum number of network notes a single transaction is allowed to consume. + /// Maximum number of network notes a single transaction is allowed to consume. Sponsorship + /// notes count against this budget. pub max_notes_per_tx: NonZeroUsize, + /// Maximum number of `FEE_SPONSORSHIP` notes attached to a single feature note. When a feature + /// note has more pending sponsorships, a random subset of this size is selected. + pub max_sponsorships_per_note: NonZeroUsize, + + /// When set, a feature note with no pending sponsorship is skipped (without penalty) during + /// selection instead of being executed unsponsored. Leave unset while fees are zero: available + /// sponsorships are attached either way. + pub require_sponsorship: bool, + /// Maximum number of attempts to execute a failing note before dropping it. Notes use /// exponential backoff between attempts. pub max_note_attempts: usize, @@ -224,6 +239,8 @@ impl NtxBuilderConfig { script_cache_size: DEFAULT_SCRIPT_CACHE_SIZE, max_concurrent_txs: DEFAULT_MAX_CONCURRENT_TXS, max_notes_per_tx: DEFAULT_MAX_NOTES_PER_TX, + max_sponsorships_per_note: DEFAULT_MAX_SPONSORSHIPS_PER_NOTE, + require_sponsorship: false, max_note_attempts: DEFAULT_MAX_NOTE_ATTEMPTS, max_block_count: DEFAULT_MAX_BLOCK_COUNT, account_channel_capacity: DEFAULT_ACCOUNT_CHANNEL_CAPACITY, @@ -276,6 +293,21 @@ impl NtxBuilderConfig { self } + /// Sets the maximum number of `FEE_SPONSORSHIP` notes attached to a single feature note. + #[must_use] + pub fn with_max_sponsorships_per_note(mut self, max: NonZeroUsize) -> Self { + self.max_sponsorships_per_note = max; + self + } + + /// Sets whether a feature note without a pending sponsorship is skipped during selection + /// instead of being executed unsponsored. + #[must_use] + pub fn with_require_sponsorship(mut self, require: bool) -> Self { + self.require_sponsorship = require; + self + } + /// Sets the maximum number of note execution attempts. #[must_use] pub fn with_max_note_attempts(mut self, max: usize) -> Self { @@ -493,6 +525,8 @@ impl NtxBuilderConfig { }, config: ActorConfig { max_notes_per_tx: self.max_notes_per_tx, + max_sponsorships_per_note: self.max_sponsorships_per_note, + require_sponsorship: self.require_sponsorship, max_note_attempts: self.max_note_attempts, idle_timeout: self.idle_timeout, max_cycles: self.max_cycles, diff --git a/bin/ntx-builder/src/sponsorship.rs b/bin/ntx-builder/src/sponsorship.rs new file mode 100644 index 0000000000..a798c2433f --- /dev/null +++ b/bin/ntx-builder/src/sponsorship.rs @@ -0,0 +1,105 @@ +//! Detection and decoding of `FEE_SPONSORSHIP` notes. + +use miden_protocol::block::BlockNumber; +use miden_protocol::note::{Note, NoteId, Nullifier}; +use miden_standards::note::{FeeSponsorshipNote, FeeSponsorshipNoteStorage}; + +// SPONSORSHIP NOTE +// ================================================================================================ + +/// A committed `FEE_SPONSORSHIP` note together with its decoded note storage. +/// +/// Sponsorship notes carry no attachments, so they are not [`AccountTargetNetworkNote`]s; they are +/// recognized purely by their script root. +#[derive(Debug, Clone)] +pub struct SponsorshipNote { + note: Note, + storage: FeeSponsorshipNoteStorage, +} + +impl SponsorshipNote { + /// Attempts to interpret `note` as a `FEE_SPONSORSHIP` note. + /// + /// Returns `None` if the note's script root is not the `FEE_SPONSORSHIP` script root, its note + /// storage does not decode as `FEE_SPONSORSHIP` storage, or it does not carry exactly one asset. + /// The note script asserts all of these itself, so a note rejected here could never be + /// consumed as a sponsorship anyway. + pub fn try_from_note(note: &Note) -> Option { + if note.script().root() != FeeSponsorshipNote::script_root() { + return None; + } + let storage = FeeSponsorshipNoteStorage::try_from(note.storage().items()).ok()?; + if note.assets().num_assets() != 1 { + return None; + } + Some(Self { note: note.clone(), storage }) + } + + /// Returns the ID of the feature note this sponsorship pays the fee for. + pub fn feature_note_id(&self) -> NoteId { + self.storage.feature_note_id() + } + + /// Returns the block height at or after which the reclaimer may reclaim the note, if reclaim is + /// enabled. + pub fn reclaim_height(&self) -> Option { + self.storage.reclaim_height() + } + + /// Returns the ID of the underlying note. + pub fn id(&self) -> NoteId { + self.note.id() + } + + /// Returns the nullifier of the underlying note. + pub fn nullifier(&self) -> Nullifier { + self.note.nullifier() + } + + /// Returns a reference to the underlying [`Note`]. + pub fn as_note(&self) -> &Note { + &self.note + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use miden_protocol::Word; + use miden_protocol::note::NoteId; + + use super::*; + use crate::test_utils::{ + mock_network_account_id, + mock_single_target_note, + mock_sponsorship_note, + }; + + fn feature_note_id() -> NoteId { + NoteId::from_raw(Word::from([7, 8, 9, 10u32])) + } + + /// A `FEE_SPONSORSHIP` note round-trips through detection with its storage intact. + #[test] + fn try_from_note_accepts_sponsorship_note() { + let note = mock_sponsorship_note(mock_network_account_id(), feature_note_id(), 1); + + let detected = + SponsorshipNote::try_from_note(¬e).expect("a FEE_SPONSORSHIP note must be detected"); + + assert_eq!(detected.feature_note_id(), feature_note_id()); + assert_eq!(detected.id(), note.id()); + assert_eq!(detected.nullifier(), note.nullifier()); + assert_eq!(detected.reclaim_height(), None); + } + + /// A regular network note (different script root) is not a sponsorship. + #[test] + fn try_from_note_rejects_other_scripts() { + let network_note = mock_single_target_note(mock_network_account_id(), 1); + + assert!(SponsorshipNote::try_from_note(network_note.as_note()).is_none()); + } +} diff --git a/bin/ntx-builder/src/test_utils.rs b/bin/ntx-builder/src/test_utils.rs index 1b0ab8d0aa..e852b27ec3 100644 --- a/bin/ntx-builder/src/test_utils.rs +++ b/bin/ntx-builder/src/test_utils.rs @@ -3,7 +3,7 @@ use miden_protocol::Word; use miden_protocol::account::{Account, AccountComponent, AccountId, AccountType}; use miden_protocol::block::BlockNumber; -use miden_protocol::note::NoteScriptRoot; +use miden_protocol::note::{NoteId, NoteScriptRoot}; use miden_protocol::testing::account_id::{ ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, AccountIdBuilder, @@ -63,6 +63,46 @@ pub fn mock_single_target_note_with_code( AccountTargetNetworkNote::try_from(note).expect("note should be single-target network note") } +/// Creates a `FEE_SPONSORSHIP` [`Note`](miden_protocol::note::Note) sponsoring `feature_note_id`, +/// tagged for `target_account_id`. Reclaim is left disabled. +pub fn mock_sponsorship_note( + target_account_id: AccountId, + feature_note_id: NoteId, + seed: u8, +) -> miden_protocol::note::Note { + use miden_protocol::asset::FungibleAsset; + use miden_standards::note::FeeSponsorshipNote; + + let mut rng = ChaCha20Rng::from_seed([seed; 32]); + let sender = AccountIdBuilder::new() + .account_type(AccountType::Private) + .build_with_rng(&mut rng); + let asset = FungibleAsset::new(FungibleAsset::mock_issuer(), 100) + .expect("mock fungible asset should be valid"); + + FeeSponsorshipNote::builder() + .sender(sender) + .target_account(target_account_id) + .feature_note_id(feature_note_id) + .asset(asset) + .serial_number(Word::from([u32::from(seed), 0, 0, 1])) + .build() + .expect("sponsorship note should build for a public target") + .into() +} + +/// Creates a decoded [`SponsorshipNote`](crate::sponsorship::SponsorshipNote) sponsoring +/// `feature_note_id`, tagged for `target_account_id`. +pub fn mock_sponsorship( + target_account_id: AccountId, + feature_note_id: miden_protocol::note::NoteId, + seed: u8, +) -> crate::sponsorship::SponsorshipNote { + let note = mock_sponsorship_note(target_account_id, feature_note_id, seed); + crate::sponsorship::SponsorshipNote::try_from_note(¬e) + .expect("mock sponsorship note must decode") +} + /// Creates a mock `Account` for a network account. /// /// Uses `AccountBuilder` with minimal components needed for serialization.