diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b18c0511b..fcce4251f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,20 @@ ### Breaking Changes +* [BREAKING][type][rust] Flattened `SyncedNote`, it now carries `note_id`, `metadata` and `inclusion_proof` directly, replacing the nested `committed: CommittedNote` field. ([#2475](https://github.com/0xMiden/rust-sdk/pull/2475)). +* [BREAKING][param][rust] `NoteObserver::observe` now takes a single `&SyncedNote` instead of a `&CommittedNote` and its `&NoteAttachments` ([#2475](https://github.com/0xMiden/rust-sdk/pull/2475)). * [BREAKING][type][rust] Added the `TransactionRequestError::SwapNoteWithZeroAsset` variant, so exhaustive matches on `TransactionRequestError` must handle it ([#2459](https://github.com/0xMiden/rust-sdk/pull/2459)). +### Features + +* [FEATURE][rust] The committed note passed to the `OnNoteReceived` callback now always reports the note's resolved attachment content, whether the `SyncNotes` response carried it verbatim or a `GetNotesById` follow-up resolved it ([#2475](https://github.com/0xMiden/rust-sdk/pull/2475)). +* [FEATURE][rust] `NetworkNotePricer` is re-exported from `miden_client`, so pricing a network note does not require a direct dependency on `miden-tx` ([#2475](https://github.com/0xMiden/rust-sdk/pull/2475)). + ### Fixes * [FIX][rust] Added validation of cached transaction encryption keys during deserialization. Unsupported encryption schemes and empty or oversized key IDs are rejected before reading the key ID bytes ([#2411](https://github.com/0xMiden/rust-sdk/pull/2411)). * [FIX][cli] `miden-client import` now rejects invocations without a file path instead of silently succeeding ([#2450](https://github.com/0xMiden/rust-sdk/pull/2450)). +* [FIX][rust] `AccountStorageDetails::validate_against_request` now checks that a partial storage map covers every key that was requested for its slot, instead of comparing key counts. A response that repeats one key while omitting another is rejected instead of accepted ([#2475](https://github.com/0xMiden/rust-sdk/pull/2475)). * [FIX][rust] `TransactionRequestBuilder::build_swap` and `build_pswap_create` now reject a zero-amount asset on either side of the exchange. A zero requested asset produced a payback P2ID note carrying nothing, and a zero offered asset produced a note whose consumer pays and receives nothing ([#2459](https://github.com/0xMiden/rust-sdk/pull/2459)). ## 0.16.0 (2026-09-07) @@ -18,11 +26,10 @@ * [BREAKING][behavior][rust,web] `TransactionRequest` serialization now carries the pinned input notes, so a request serialized by an earlier version cannot be deserialized by this one and vice versa. Rebuild any request that is stored or in flight across the upgrade ([#2437](https://github.com/0xMiden/rust-sdk/pull/2437)). * [BREAKING][removal][rust] Removed `Client::try_get_account`. Use `Client::get_account` and handle the `None` case, or `Client::account_reader` for existence checks and single-field reads that don't need the full materialized account ([#2362](https://github.com/0xMiden/rust-sdk/pull/2362)). -* [BREAKING][behavior][rpc] The `GetAccount` response no longer carries one SMT opening per requested storage map key. A slot queried with specific keys now comes back as a single partial SMT covering all of them, alongside the original unhashed keys, so the client requires a node that speaks this format ([#2362](https://github.com/0xMiden/rust-sdk/pull/2362)). -* [BREAKING][type][rust] `StorageMapEntries::EntriesWithProofs(Vec)` is replaced by `StorageMapEntries::PartialMap { map_keys, partial_smt }`, which carries the values only inside the tree: read one by hashing its raw key and calling `PartialSmt::get_value`. The enum also gained a `LimitExceeded` variant and `AccountStorageMapDetails::too_many_entries` was removed in its favor ([#2362](https://github.com/0xMiden/rust-sdk/pull/2362)). +* [BREAKING][type][rust] `StorageMapEntries::EntriesWithProofs(Vec)` is replaced by `StorageMapEntries::PartialMap { map_keys, partial_smt }`. Read a value by hashing its raw key and calling `PartialSmt::get_value`. The enum also gained a `LimitExceeded` variant, replacing `AccountStorageMapDetails::too_many_entries` ([#2431](https://github.com/0xMiden/rust-sdk/pull/2431)). * [BREAKING][type][rust] `SyncedNote` splits the content it carries into two fields, `details: Option` and `attachments: NoteAttachments`, replacing the previous `content: Option`; `SyncedNote::new` takes them as separate arguments. `ResolvedNoteContent` is removed. Attachments are no longer optional, a note whose metadata advertises none carries an empty set, so "no attachments" and "attachments not resolved" are no longer the same value ([#2431](https://github.com/0xMiden/rust-sdk/pull/2431)). * [BREAKING][param][rust] `NoteObserver::observe` takes `&NoteAttachments` instead of `Option<&NoteAttachments>`. A note that carries no attachments is reported with an empty set ([#2431](https://github.com/0xMiden/rust-sdk/pull/2431)). -* [BREAKING][removal][rust] `impl TryFrom for AccountProof` is removed. A partial map can only be checked against the keys that were requested, which that conversion has no way to receive, so it rejected every response answering a specific-key query. Use `proto::rpc::account_response::AccountDetails::into_domain` with the `AccountStorageRequirements` the request was built from ([#2431](https://github.com/0xMiden/rust-sdk/issues/2431)). +* [BREAKING][removal][rust] `impl TryFrom for AccountProof` is removed. Use `proto::rpc::account_response::AccountDetails::into_domain` with the `AccountStorageRequirements` the request was built from ([#2431](https://github.com/0xMiden/rust-sdk/pull/2431)). * [BREAKING][behavior][rust] Foreign `AccountInputs` keep a fetched asset list only when it hashes to the account header's vault root; otherwise (omitted because unchanged, capped as oversize, or malformed) they carry a root-only partial vault, and any assets the foreign code reads are resolved during execution as per-asset witnesses — served from the local store first, falling back to fetching the vault via RPC at the transaction reference block and verifying it against the required root ([#2417](https://github.com/0xMiden/rust-sdk/pull/2417)). * [BREAKING][behavior][rust] Foreign `AccountInputs` likewise keep a fetched storage-map entry list only when it hashes to the slot's root in the storage header; otherwise (capped as oversize, or malformed) the map is carried root-only and any keys the foreign code reads are resolved during execution as lazy per-key witnesses, instead of syncing an oversized map's full history from genesis before executing ([#2417](https://github.com/0xMiden/rust-sdk/pull/2417)). * [BREAKING][arch][store] The account SMT forest now persists in SQLite (new `forest_trees`, `forest_entries`, `forest_subtrees` and `forest_revision` tables) through a `LargeSmtForest` backend scoped to the store's own transaction, so forest mutations commit or roll back atomically with the account tables and opening the store no longer rebuilds the forest from account data. Tree inner nodes are persisted as packed subtree blobs, so witness reads load a single leaf plus eight blobs instead of rebuilding the account's tree, making their cost independent of the account's map size at the price of a larger store file. Tree updates are computed path-locally from the persisted leaves and subtree blobs, so committed update cost scales with the size of the change set rather than with the map size. Existing stores are not compatible and must be recreated ([#2333](https://github.com/0xMiden/rust-sdk/pull/2333)). @@ -68,8 +75,8 @@ * [FEATURE][rust] Added `AuthGuardedMultisig`, `AuthGuardedMultisigConfig`, `GuardianConfig` and `ApproverSet` to `miden_client::auth`, which previously exposed only the single- and multisig components. Building a guarded multisig account no longer means reaching past the client into `miden_standards` ([#2465](https://github.com/0xMiden/rust-sdk/pull/2465)). * [FEATURE][rust] `Client::sync_state` now issues its independent gRPC calls concurrently instead of one after another, reducing the total time a sync takes. `NodeRpcClient::sync_notes_with_content` and `NodeRpcClient::sync_transactions` are now called concurrently rather than in sequence, and the per-account `NodeRpcClient::get_account` requests are issued in parallel instead of one at a time ([#2420](https://github.com/0xMiden/rust-sdk/pull/2420)). * [store] Added `SqliteStore::database_filepath`, which returns the backing database path losslessly as a `&Path` ([#2363](https://github.com/0xMiden/rust-sdk/pull/2363)). -* [FEATURE][rust] Syncing no longer issues a `GetNotesById` request for a note whose attachments the `SyncNotes` response already carried. The node sends an attachment that fits in a single word verbatim, so the client reconstructs it locally: a private note whose attachments are all single-word is resolved with no follow-up request during state sync, as is a note of either type while checking expected notes. Both standard attachment schemes in `miden-standards` (`NetworkAccountTarget` and the PSWAP attachment) are single-word, so the round trip disappears from the common case. A caller-supplied attachment spanning more than one word arrives as a commitment and is still fetched. Public notes are unaffected during state sync, since their bodies are requested regardless ([#2360](https://github.com/0xMiden/rust-sdk/issues/2360)). -* [FEATURE][rust] `CommittedNote` now carries the attachment content its source reported. `attachments()` returns it, `needs_attachment_fetch()` reports whether the content still has to be fetched via `GetNotesById`, and `with_attachments()` records it, rejecting content that does not hash to the metadata's attachments commitment ([#2360](https://github.com/0xMiden/rust-sdk/issues/2360)). +* [FEATURE][rust] Syncing no longer issues a `GetNotesById` request for a note whose attachments the `SyncNotes` response already carried verbatim. Both standard attachment schemes in `miden-standards` fit in a single word and arrive inline, so the round trip disappears from the common case. An attachment spanning more than one word arrives as a commitment and is still fetched ([#2431](https://github.com/0xMiden/rust-sdk/pull/2431)). +* [FEATURE][rust] `CommittedNote` now carries the attachment content its source reported, exposed through `attachments()`, `needs_attachment_fetch()` and `with_attachments()`, which rejects content that does not hash to the metadata's attachments commitment ([#2431](https://github.com/0xMiden/rust-sdk/pull/2431)). * [FEATURE][rust] On a chain whose reference block charges a non-zero `verification_base_fee`, the client now commits fee conversion info paying the fee in the chain's native fee asset at rate 1/1, so a transaction against an `AuthSingleSig` account no longer has to opt in per request. `AuthMultisig` reuses the commitment's salt as its replay guard, so a request against such an account declares a fresh salt with the new `TransactionRequestBuilder::fee_conversion_salt` and the client commits the native info under it ([#2446](https://github.com/0xMiden/rust-sdk/issues/2446)). * [FEATURE][rust] Added `ChainAnchor` with `Client::execute_transaction_at` and `Client::chain_anchor_for_request` to capture and execute against a pinned reference block instead of the sync height, so a transaction summary signed at one block — which binds the reference block commitment since protocol 0.16 — can be reproduced and executed later on any client ([#2421](https://github.com/0xMiden/rust-sdk/pull/2421)). * [FEATURE][rust] A client that only watches a public account now recovers notes the account consumed authenticated, even when it never tracked them by tag. During sync it reads the note references the node attaches to the account's transactions, fetches each note body by id, and surfaces it through `InputNoteReader`. Requires node `0.15.1` ([#2300](https://github.com/0xMiden/rust-sdk/pull/2300)). diff --git a/crates/rust-client/src/lib.rs b/crates/rust-client/src/lib.rs index ffa3e1b552..41efd84a8e 100644 --- a/crates/rust-client/src/lib.rs +++ b/crates/rust-client/src/lib.rs @@ -344,7 +344,7 @@ pub use miden_protocol::{ Word, ZERO, }; -pub use miden_tx::ExecutionOptions; +pub use miden_tx::{ExecutionOptions, NetworkNotePricer}; #[cfg(feature = "tonic")] pub use remote_prover::RemoteTransactionProver; diff --git a/crates/rust-client/src/note/import.rs b/crates/rust-client/src/note/import.rs index b1241ed1df..a896717bfa 100644 --- a/crates/rust-client/src/note/import.rs +++ b/crates/rust-client/src/note/import.rs @@ -370,7 +370,7 @@ where // Notes the node has not reported as committed keep their expected record untouched. let Some(SyncedNote { - committed: committed_note, attachments, .. + metadata, inclusion_proof, attachments, .. }) = committed_notes_data.remove(¬e_record.details_commitment()) else { note_records.push(note_record); @@ -381,12 +381,14 @@ where let attachments = (!attachments.is_empty()).then_some(attachments); let block_header = self - .get_and_store_authenticated_block(committed_note.block_num(), &mut partial_mmr) + .get_and_store_authenticated_block( + inclusion_proof.location().block_num(), + &mut partial_mmr, + ) .await?; - let metadata = *committed_note.metadata(); - let mut note_changed = note_record - .inclusion_proof_received(committed_note.inclusion_proof().clone(), metadata)?; + let mut note_changed = + note_record.inclusion_proof_received(inclusion_proof, metadata)?; if let Some(attachments) = attachments { note_changed |= note_record.attachments_received(attachments); @@ -455,18 +457,16 @@ where } for sync_note in block.notes.into_values() { - let committed = &sync_note.committed; - // The note carries its own commit height in its inclusion proof, which is a // separate field from the block header checked above. Authenticating the note later // looks that height up in the partial MMR, so a height beyond our synced view has // to be dropped here rather than trusted. - if committed.block_num() > current_block_num { + if sync_note.block_num() > current_block_num { continue; } let Some((commitment, _)) = expected_notes.iter().find(|(commitment, _)| { - NoteId::new(*commitment, committed.metadata()) == *committed.note_id() + NoteId::new(*commitment, &sync_note.metadata) == sync_note.note_id }) else { continue; }; diff --git a/crates/rust-client/src/pswap/observer.rs b/crates/rust-client/src/pswap/observer.rs index 188ae2304d..29a6674515 100644 --- a/crates/rust-client/src/pswap/observer.rs +++ b/crates/rust-client/src/pswap/observer.rs @@ -14,7 +14,7 @@ use tracing::warn; use crate::ClientError; use crate::pswap::discovery::discover_pswap_rounds; use crate::pswap::lineage::ObservedPswapNote; -use crate::rpc::domain::note::CommittedNote; +use crate::rpc::domain::note::SyncedNote; use crate::store::Store; use crate::sync::NoteObserver; use crate::utils::RwLock; @@ -52,23 +52,19 @@ impl NoteObserver for PswapChainObserver { "PswapChainObserver" } - async fn observe( - &self, - committed_note: &CommittedNote, - attachments: &NoteAttachments, - ) -> Result { + async fn observe(&self, note: &SyncedNote) -> Result { // Notes without a PSWAP attachment are the common case; `extract_pswap_attachment` // fast-rejects them. Foreign-order filtering happens later in `discovery`. - let Some(attachment) = extract_pswap_attachment(attachments) else { + let Some(attachment) = extract_pswap_attachment(¬e.attachments) else { return Ok(false); }; - let inclusion_proof = committed_note.inclusion_proof().clone(); + let inclusion_proof = note.inclusion_proof.clone(); self.chain_note_updates.write().push(ObservedPswapNote { - note_id: *committed_note.note_id(), + note_id: note.note_id, attachment, - sender: committed_note.sender(), - tag: committed_note.metadata().tag(), + sender: note.metadata.sender(), + tag: note.metadata.tag(), block_num: inclusion_proof.location().block_num(), inclusion_proof, }); diff --git a/crates/rust-client/src/rpc/domain/account.rs b/crates/rust-client/src/rpc/domain/account.rs index 60407eeb76..ffe4985e88 100644 --- a/crates/rust-client/src/rpc/domain/account.rs +++ b/crates/rust-client/src/rpc/domain/account.rs @@ -351,7 +351,7 @@ impl AccountStorageDetails { /// Checks that every partial map covers exactly the keys that were requested for its slot. /// /// # Errors - /// - If a partial map covers a different number of keys than were requested for its slot. + /// - If a partial map does not cover a key that was requested for its slot. /// - If a partial map covers a key that was not requested. pub fn validate_against_request( &self, @@ -363,12 +363,11 @@ impl AccountStorageDetails { }; let requested_keys = storage_requirements.keys_for_slot(&map_detail.slot_name); - if map_keys.len() != requested_keys.len() { + if let Some(key) = requested_keys.iter().find(|key| !map_keys.contains(key)) { return Err(RpcError::InvalidResponse(format!( - "expected {} keys for storage map slot '{}', got {}", - requested_keys.len(), + "partial storage map for slot '{}' does not cover requested key {}", map_detail.slot_name, - map_keys.len(), + key.to_hex(), ))); } if let Some(key) = map_keys.iter().find(|key| !requested_keys.contains(key)) { diff --git a/crates/rust-client/src/rpc/domain/note.rs b/crates/rust-client/src/rpc/domain/note.rs index fb53e0faa6..d9d855dd0f 100644 --- a/crates/rust-client/src/rpc/domain/note.rs +++ b/crates/rust-client/src/rpc/domain/note.rs @@ -149,6 +149,26 @@ impl SequentialCommit for AttachmentCommitments<'_> { } } +/// A single attachment as a `SyncNotes` record reports it: verbatim when the node sent its content, +/// commitment-only otherwise. +#[derive(Debug)] +enum ReportedAttachment { + /// The attachment arrived verbatim, so its content is known. + Full(NoteAttachment), + /// The attachment arrived as its commitment only. + Commitment(Word), +} + +impl ReportedAttachment { + /// Returns the attachment's commitment. + fn to_commitment(&self) -> Word { + match self { + Self::Full(attachment) => attachment.to_commitment(), + Self::Commitment(commitment) => *commitment, + } + } +} + /// The attachments of a note as a `SyncNotes` record reports them. Both variants determine the /// note's attachments commitment exactly, but only one carries the content. #[derive(Debug)] @@ -161,6 +181,31 @@ enum ReportedAttachments { } impl ReportedAttachments { + /// Aggregates the per-attachment reports of one record. The content is reconstructible only as + /// a whole set, so a single commitment-only attachment demotes the entire report to + /// commitments. + fn from_reports(reports: Vec) -> Result { + let commitments: Vec = + reports.iter().map(ReportedAttachment::to_commitment).collect(); + let contents: Option> = reports + .into_iter() + .map(|report| match report { + ReportedAttachment::Full(attachment) => Some(attachment), + ReportedAttachment::Commitment(_) => None, + }) + .collect(); + + match contents { + Some(contents) => { + let attachments = NoteAttachments::new(contents).map_err(|err| { + RpcConversionError::InvalidField(format!("attachments: {err}")) + })?; + Ok(Self::Full(attachments)) + }, + None => Ok(Self::Commitments(commitments)), + } + } + /// Returns the note's attachments commitment. fn to_commitment(&self) -> Word { match self { @@ -208,10 +253,7 @@ impl TryFrom for SyncNoteMetadata { } let mut attachment_headers = [NoteAttachmentHeader::absent(); NoteAttachments::MAX_COUNT]; - let mut commitments = Vec::with_capacity(value.attachments.len()); - // Stays `Some` until an attachment arrives as a commitment only, since the content can be - // rebuilt only as a whole set. - let mut contents = Some(Vec::with_capacity(value.attachments.len())); + let mut reports = Vec::with_capacity(value.attachments.len()); for (slot, attachment) in value.attachments.into_iter().enumerate() { let raw_scheme = u16::try_from(attachment.scheme).map_err(|_| { @@ -232,29 +274,21 @@ impl TryFrom for SyncNoteMetadata { // full. A larger one is sent as a commitment to keep the sync response bounded. The // node may send a commitment even for a single-word one, so the choice is read off the // payload variant and never inferred from a word count. - match payload { + let report = match payload { proto::note::note_sync_attachment::Payload::Value(value) => { - let attachment = NoteAttachment::with_word(scheme, Word::try_from(value)?); - commitments.push(attachment.to_commitment()); - if let Some(contents) = contents.as_mut() { - contents.push(attachment); - } + ReportedAttachment::Full(NoteAttachment::with_word( + scheme, + Word::try_from(value)?, + )) }, proto::note::note_sync_attachment::Payload::Commitment(commitment) => { - commitments.push(Word::try_from(commitment)?); - contents = None; + ReportedAttachment::Commitment(Word::try_from(commitment)?) }, - } + }; + reports.push(report); } - let attachments = match contents { - Some(contents) => { - ReportedAttachments::Full(NoteAttachments::new(contents).map_err(|err| { - RpcConversionError::InvalidField(format!("attachments: {err}")) - })?) - }, - None => ReportedAttachments::Commitments(commitments), - }; + let attachments = ReportedAttachments::from_reports(reports)?; Ok(SyncNoteMetadata { metadata: NoteMetadata::from_parts( @@ -400,8 +434,12 @@ pub struct ResolvedSyncNotesBlock { /// when it was fetched via `GetNotesById`. #[derive(Debug, Clone)] pub struct SyncedNote { - /// Note identity, metadata, and inclusion proof, as reported by `SyncNotes`. - pub committed: CommittedNote, + /// Note ID of the synced note, as reported by `SyncNotes`. + pub note_id: NoteId, + /// The note's full metadata, as reported by `SyncNotes`. + pub metadata: NoteMetadata, + /// Inclusion proof for the note in the block, as reported by `SyncNotes`. + pub inclusion_proof: NoteInclusionProof, /// The public note's body, fetched via `GetNotesById`. `None` for a private note, and for a /// public note whose body was not requested or not returned. pub details: Option, @@ -446,7 +484,39 @@ impl SyncedNote { ))); } - Ok(Self { committed, details, attachments }) + let CommittedNote { + note_id, + metadata, + inclusion_proof, + attachments: _, + } = committed; + + Ok(Self { + note_id, + metadata, + inclusion_proof, + details, + attachments, + }) + } + + /// Returns the number of the block in which the note was committed. + pub fn block_num(&self) -> BlockNumber { + self.inclusion_proof.location().block_num() + } + + /// Returns the note's sync record together with the attachment content resolved for it. + /// + /// The returned record reports its attachments as resolved, so + /// [`CommittedNote::needs_attachment_fetch`] is always `false` for it. A note without + /// attachments carries an empty set. + pub fn to_committed_note(&self) -> CommittedNote { + CommittedNote { + note_id: self.note_id, + metadata: self.metadata, + inclusion_proof: self.inclusion_proof.clone(), + attachments: Some(self.attachments.clone()), + } } } diff --git a/crates/rust-client/src/sync/note_observer.rs b/crates/rust-client/src/sync/note_observer.rs index 2fde682288..2e7c600b29 100644 --- a/crates/rust-client/src/sync/note_observer.rs +++ b/crates/rust-client/src/sync/note_observer.rs @@ -3,10 +3,9 @@ use alloc::boxed::Box; use async_trait::async_trait; -use miden_protocol::note::NoteAttachments; use crate::ClientError; -use crate::rpc::domain::note::CommittedNote; +use crate::rpc::domain::note::SyncedNote; use crate::sync::StateSyncUpdate; /// Per-note + post-sync side-channel into [`crate::sync::StateSync`]. Attach via @@ -18,15 +17,13 @@ pub trait NoteObserver { fn name(&self) -> &'static str; /// Per-note hook. Runs before the screener verdict, so before the note's id is recomputed and - /// its inclusion proof verified. `attachments` is empty for a note that carries none. + /// its inclusion proof verified. `note` carries the note's identity, metadata and inclusion + /// proof from the sync record, its resolved attachments (empty for a note without any) and, for + /// a fetched public note, its body. /// /// Returns `true` to mark the enclosing block as relevant even if the screener discards it, so /// sync persists its header. - async fn observe( - &self, - committed_note: &CommittedNote, - attachments: &NoteAttachments, - ) -> Result; + async fn observe(&self, note: &SyncedNote) -> Result; /// Post-sync hook, invoked once after the sync window closes. Default impl is a no-op for /// observers that only need `observe()`. diff --git a/crates/rust-client/src/sync/state_sync.rs b/crates/rust-client/src/sync/state_sync.rs index 1f61d8fb45..c9d939864c 100644 --- a/crates/rust-client/src/sync/state_sync.rs +++ b/crates/rust-client/src/sync/state_sync.rs @@ -1168,7 +1168,7 @@ impl StateSync { /// * Tracked notes that were being processed by a transaction that got committed. /// * Tracked notes that were nullified by an external transaction. /// - /// Each [`SyncedNote`] is self-contained: inclusion proof and metadata from `committed`, + /// Each [`SyncedNote`] is self-contained: inclusion proof and metadata from the sync record, /// attachments from the sync record or a `GetNotesById` follow-up, and the body from `details`. /// /// Attachments are stored on-chain for private and public notes alike, so they are applied to @@ -1181,23 +1181,12 @@ impl StateSync { ) -> Result { let mut relevance = NoteBlockRelevance::default(); - for (_, SyncedNote { committed, details, attachments }) in notes { - // For a public note, pair its fetched body with the inclusion proof and metadata from - // `committed` (the single source of truth) to build the candidate record. - let public_note = details.map(|details| { - let state = UnverifiedNoteState { - metadata: *committed.metadata(), - inclusion_proof: committed.inclusion_proof().clone(), - } - .into(); - InputNoteRecord::new(details, attachments.clone(), None, state) - }); - + for (_, note) in notes { // Observers run BEFORE the screener: they are a side-effect channel independent of the // Commit/Insert/Discard decision, and a failing screener must not rob them of the note. if !self.note_observers.is_empty() { for obs in &self.note_observers { - match obs.observe(&committed, &attachments).await { + match obs.observe(¬e).await { Ok(true) => relevance.observer_requires_block = true, Ok(false) => {}, Err(err) => { @@ -1211,6 +1200,25 @@ impl StateSync { } } + // The screener works on the sync record, which carries the note's resolved attachments + // so that a screener implementation can read them. + let committed = note.to_committed_note(); + + let SyncedNote { + note_id: _, + metadata, + inclusion_proof, + details, + attachments, + } = note; + + // For a public note, pair its fetched body with the inclusion proof and metadata from + // the sync record (the single source of truth) to build the candidate record. + let public_note = details.map(|details| { + let state = UnverifiedNoteState { metadata, inclusion_proof }.into(); + InputNoteRecord::new(details, attachments.clone(), None, state) + }); + match self.note_screener.on_note_received(committed, public_note).await? { NoteUpdateAction::Commit(committed_note) => { // Only mark the downloaded block header as relevant if we are talking about an @@ -1471,11 +1479,7 @@ mod tests { "always-relevant" } - async fn observe( - &self, - _committed_note: &CommittedNote, - _attachments: &NoteAttachments, - ) -> Result { + async fn observe(&self, _note: &SyncedNote) -> Result { Ok(true) } } diff --git a/scripts/start-test-node.sh b/scripts/start-test-node.sh index 06407a86dd..8aec7c05c6 100755 --- a/scripts/start-test-node.sh +++ b/scripts/start-test-node.sh @@ -144,22 +144,15 @@ for mac in bridge_admin.mac ger_manager.mac bridge.mac agglayer_faucet.mac \ done # The validator's signing key and the set's shared transaction encryption key are passed on the -# command line. The genesis header commits to the signing key's public half, so the key-pair has to -# exist before the genesis block is built. A fresh pair per run is fine because `$DATA` is wiped -# above, so no earlier chain state depends on the previous one. -VALIDATOR_KEYS="$("$BIN/miden-validator" keygen)" -validator_key() { - printf '%s\n' "$VALIDATOR_KEYS" | awk -v field="$1:" '$1 == field { print $2; exit }' -} -SIGNING_KEY="$(validator_key signing-key)" -VALIDATOR_PUBLIC_KEY="$(validator_key validator-key)" -ENCRYPTION_KEY="$(validator_key encryption-key)" -for key in SIGNING_KEY VALIDATOR_PUBLIC_KEY ENCRYPTION_KEY; do - [ -n "${!key}" ] || { - echo "error: miden-validator keygen did not report a $key" >&2 - exit 1 - } -done +# command line. The genesis header commits to the signing key's public half, so the keys have to +# exist before the genesis block is built. These are hardcoded INSECURE test-only fixtures (one +# `miden-validator keygen` output, so the signing and validator keys pair up), like the +# storage-key material below. A fixed key is safe here because `$DATA` is wiped above, so no +# earlier chain state depends on it. If a node bump changes the key format, regenerate all three +# with `miden-validator keygen`. +SIGNING_KEY="9cbcf0fc18b2a4afeff56ef43ad96af92e804fae64615c9802cff2a182e9cae2" +VALIDATOR_PUBLIC_KEY="020c06515b355a62133ae98e53e4b5d3e6ee9ff60ce620a436780e4e308a3ff3e9" +ENCRYPTION_KEY="9964dbb2590adeb415d3291b64a0a9991fbcac5adacb05ee17efee5296d081d7" { # Genesis generation is separate from bootstrap: `genesis` builds the block once, then every