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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
* [BREAKING][param][store] `Store::insert_block_header` now takes a `nodes` argument and persists the header with its MMR authentication nodes in a single transaction; the standalone `Store::insert_partial_blockchain_nodes` is removed. Header-only inserts (e.g. genesis) pass an empty slice ([#2294](https://github.com/0xMiden/rust-sdk/pull/2294)).
* [BREAKING][behavior][store] The `ConsumedExternal` note-metadata layout added in [#2308](https://github.com/0xMiden/rust-sdk/pull/2308) is now the only supported serialized format. The backward-compatible decoding of the older metadata-less layout is removed, so existing stores are not compatible and must be recreated ([#2313](https://github.com/0xMiden/rust-sdk/pull/2313)).

### Features

* [FEATURE][rust] `InputNoteReader` now returns the erased notes a followed account consumed, surfaced as header-only records in the new `InputNoteState::ConsumedExternalErased` state ([#2293](https://github.com/0xMiden/miden-client/pull/2293)).

### Fixes

* [FIX][rust] Storing an authenticated block header now persists the header and its MMR authentication nodes in a single store transaction, so an interrupted write can no longer leave a tracked block without the MMR nodes needed to rebuild the `PartialMmr` ([#2294](https://github.com/0xMiden/rust-sdk/pull/2294)).
Expand Down
139 changes: 138 additions & 1 deletion bin/integration-tests/src/tests/onchain.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::collections::BTreeMap;

use anyhow::{Context, Result};
use miden_client::account::{AccountType, build_wallet_id};
use miden_client::account::{AccountId, AccountType, build_wallet_id};
use miden_client::asset::{Asset, FungibleAsset};
use miden_client::auth::RPO_FALCON_SCHEME_ID;
use miden_client::keystore::Keystore;
Expand All @@ -11,6 +11,7 @@ use miden_client::note::{
NoteAttachmentScheme,
NoteAttachments,
NoteFile,
NoteId,
NoteType,
P2idNote,
};
Expand Down Expand Up @@ -716,6 +717,142 @@ pub async fn test_consumed_note_ordering(client_config: ClientConfig) -> Result<
Ok(())
}

/// Two-client proof that a pure importer (follower) of an account reads BOTH erased and committed
/// notes the account consumed, intercalated in consumption order.
///
/// Client A owns the faucet and the consumer and drives all activity; client B only
/// `import_account_by_id`s the consumer. Across increasing blocks the consumer consumes an erased
/// note (minted and consumed in the same batch, so it never lands in a block), then a committed
/// note, then another erased note. B syncs and its `InputNoteReader` returns all three in order,
/// with the erased ones surfacing as header-only records (their headers ride the consumer's
/// transaction as unauthenticated input commitments) and the committed one in full. This exercises
/// the consumer-side header path end to end, and confirms the node populates the header for
/// unauthenticated inputs.
pub async fn test_importer_note_reader_finds_erased_and_committed_interleaved(
client_config: ClientConfig,
) -> Result<()> {
let (mut client_a, keystore_a) = client_config.clone().into_client().await?;
let (mut client_b, _keystore_b) = ClientConfig::default()
.with_rpc_endpoint(client_config.rpc_endpoint())
.into_client()
.await?;
wait_for_node(&mut client_a).await;

let (faucet, _) = insert_new_fungible_faucet(
&mut client_a,
AccountType::Public,
&keystore_a,
RPO_FALCON_SCHEME_ID,
)
.await?;
let (consumer, ..) =
insert_new_wallet(&mut client_a, AccountType::Public, &keystore_a, RPO_FALCON_SCHEME_ID)
.await?;
let consumer_id = consumer.id();
let faucet_id = faucet.id();
client_a.sync_state().await?;

// Put the consumer on-chain and let client B import it (registering its note tag).
let bootstrap_tx =
mint_and_consume(&mut client_a, consumer_id, faucet_id, NoteType::Public).await;
wait_for_tx(&mut client_a, bootstrap_tx).await?;
client_a.sync_state().await?;
client_b.import_account_by_id(consumer_id).await?;
client_b.sync_state().await?;

// Consumption order is recorded here as each note is consumed in its own (increasing) block.
let mut expected_ids = Vec::new();

// (1) ERASED: minted to and consumed by the consumer in the same batch.
expected_ids.push(mint_and_consume_erased_note(&mut client_a, faucet_id, consumer_id).await?);

// (2) COMMITTED: minted to the consumer and committed; B must see it while still unspent to
// capture its full details, then the consumer consumes it.
let (mint_tx, committed_note) =
mint_note(&mut client_a, consumer_id, faucet_id, NoteType::Public).await;
wait_for_tx(&mut client_a, mint_tx).await?;
let committed_commitment = committed_note.details_commitment();
for _ in 0..10 {
client_b.sync_state().await?;
let tracked = client_b.get_input_notes(NoteFilter::All).await?;
if tracked.iter().any(|n| n.details_commitment() == committed_commitment) {
break;
}
wait_for_blocks(&mut client_b, 1).await;
}
let consume_tx =
consume_notes(&mut client_a, consumer_id, std::slice::from_ref(&committed_note)).await;
wait_for_tx(&mut client_a, consume_tx).await?;
expected_ids.push(committed_note.id());

// (3) ERASED again.
expected_ids.push(mint_and_consume_erased_note(&mut client_a, faucet_id, consumer_id).await?);

// B syncs until its reader surfaces all three of our notes, then we check their order.
let mut ordered = Vec::new();
for _ in 0..20 {
client_b.sync_state().await?;
let mut reader = client_b.input_note_reader(consumer_id);
ordered.clear();
while let Some(note) = reader.next().await? {
if note.id().is_some_and(|id| expected_ids.contains(&id)) {
ordered.push(note);
}
}
if ordered.len() == expected_ids.len() {
break;
}
wait_for_blocks(&mut client_b, 1).await;
}

let ordered_ids: Vec<_> = ordered.iter().filter_map(|n| n.id()).collect();
assert_eq!(
ordered_ids, expected_ids,
"follower's reader should return erased and committed notes intercalated in consumption order",
);

// The erased notes are header-only; the committed note carries full details.
assert!(!ordered[0].has_details(), "first note (erased) should be header-only");
assert!(ordered[1].has_details(), "second note (committed) should carry full details");
assert!(!ordered[2].has_details(), "third note (erased) should be header-only");
for note in &ordered {
assert_eq!(note.consumer_account(), Some(consumer_id));
}

Ok(())
}

/// Mints a note to `consumer_id` and consumes it unauthenticated in the same batch, so the note is
/// erased (never committed to a block). Returns the erased note's id. Waits for the batch to land
/// in a block so callers can sequence consumptions into distinct, increasing blocks.
async fn mint_and_consume_erased_note(
client: &mut TestClient,
faucet_id: AccountId,
consumer_id: AccountId,
) -> Result<NoteId> {
let mint_request = TransactionRequestBuilder::new().build_mint_fungible_asset(
FungibleAsset::new(faucet_id, 100).unwrap(),
consumer_id,
NoteType::Public,
client.rng(),
)?;
let erased_note = mint_request
.expected_output_own_notes()
.pop()
.context("mint request should produce exactly one output note")?;
let note_id = erased_note.id();
let consume_request =
TransactionRequestBuilder::new().build_consume_notes(vec![erased_note])?;

let mut batch = client.new_transaction_batch();
batch = batch.push(faucet_id, mint_request).await?;
batch = batch.push(consumer_id, consume_request).await?;
batch.submit().await?;
wait_for_blocks(client, 2).await;

Ok(note_id)
}

/// Verifies syncing and consuming notes with attachments, for both a public and a private note.
/// 1. Client 1 mints a public and a private P2ID note, each with an attachment, targeting client 2.
/// 2. Client 2 syncs and discovers both notes via `sync_notes`.
Expand Down
94 changes: 93 additions & 1 deletion crates/rust-client/src/note/note_update_tracker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use miden_protocol::note::{
NoteMetadata,
Nullifier,
};
use miden_protocol::transaction::InputNoteCommitment;
use miden_standards::note::NetworkAccountTarget;
use miden_tx::utils::serde::{
ByteReader,
Expand Down Expand Up @@ -513,6 +514,50 @@ impl NoteUpdateTracker {
Ok(())
}

/// Inserts a header-only consumed input note for a tracked account's transaction.
///
/// Header-bearing input commitments are unauthenticated inputs (typically erased notes). When
/// the client tracks neither an input nor an
/// output record for the note, this stores its header under the consuming account so the
/// consumed-note queries can return it. No-op for authenticated commitments (which carry no
/// header) and for notes already tracked.
pub(crate) fn insert_consumed_unauthenticated_note(
&mut self,
commitment: &InputNoteCommitment,
consumer: AccountId,
block_num: BlockNumber,
) {
let Some(header) = commitment.header() else {
return;
};
let note_id = header.id();
// Skip when the note is already tracked in any form, whether as a metadata-bearing input
// (`input_notes_by_id`), an output note, or a metadata-less expected note imported from
// bare details (`expected_note_matching`, which `input_notes_by_id` does not
// cover). In that last case a header-only placeholder would land under a different
// (placeholder) details commitment, leaving a duplicate row alongside the stale
// expected one.
//
// For that metadata-less case this only prevents the duplicate; the existing expected
// record is left as-is and keeps showing as unspent. Evolving it straight to a consumed
// state from this consumption event is a deferred follow-up, and skipping matches a
// client without erased-note recovery, which never recovers this note either.
if self.input_notes_by_id.contains_key(&note_id)
|| self.output_notes.contains_key(&note_id)
|| self.expected_note_matching(note_id, header.metadata()).is_some()
{
return;
}

// Fall back to 0 when the block position is unknown, since `get_input_note_by_offset`
// excludes notes without a consumption order.
let order = self.get_nullifier_order(commitment.nullifier()).or(Some(0));
let mut record =
InputNoteRecord::from_header(header, commitment.nullifier(), block_num, Some(consumer));
record.set_consumed_tx_order(order);
self.insert_input_note(record, NoteUpdateType::Insert);
}

/// Builds a consumed input note record from a tracked output note and inserts it.
///
/// Used when an output note is consumed externally and the client should also surface
Expand Down Expand Up @@ -817,15 +862,17 @@ mod tests {
NoteAssets,
NoteAttachments,
NoteDetails,
NoteHeader,
NoteId,
NoteMetadata,
NoteRecipient,
NoteStorage,
NoteType,
Nullifier,
PartialNoteMetadata,
};
use miden_protocol::testing::account_id::ACCOUNT_ID_SENDER;
use miden_protocol::transaction::TransactionId;
use miden_protocol::transaction::{InputNoteCommitment, TransactionId};
use miden_protocol::utils::serde::{Deserializable, Serializable};
use miden_protocol::{Felt, Word, ZERO};
use miden_standards::note::StandardNote;
Expand Down Expand Up @@ -939,6 +986,51 @@ mod tests {
assert_eq!(tracker.updated_input_notes().count(), 1);
}

#[test]
fn header_only_record_carries_the_transaction_nullifier() {
// A header-only erased record stores placeholder details, so its nullifier cannot be
// recomputed from them. It must instead carry the nullifier from the consuming
// transaction's input commitment, unchanged and surviving a serialization round-trip.
let sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
let details = note_details(20);
let metadata = note_metadata(sender);
let header = NoteHeader::new(details.commitment(), metadata);
let nullifier = Nullifier::from_details_and_metadata(&details, &metadata);

let record =
InputNoteRecord::from_header(&header, nullifier, BlockNumber::from(7u32), Some(sender));

assert_eq!(record.nullifier(), Some(nullifier));
assert_ne!(
record.nullifier(),
Some(Nullifier::from_details_and_metadata(record.details(), &metadata)),
"the nullifier must be the stored one, not one derived from the placeholder details"
);

let restored = InputNoteRecord::read_from_bytes(&record.to_bytes()).unwrap();
assert_eq!(restored.nullifier(), Some(nullifier));
}

#[test]
fn consumed_unauthenticated_note_skips_tracked_metadata_less_note() {
// A metadata-less expected note (imported from bare details) is tracked by its details
// commitment and absent from `input_notes_by_id`. Recording the same note as a consumed
// unauthenticated input must recognize it and not insert a second, header-only placeholder.
let sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
let expected = expected_note(30);
let details_commitment = expected.details_commitment();
let metadata = note_metadata(sender);
let header = NoteHeader::new(details_commitment, metadata);
let nullifier = Nullifier::from_details_and_metadata(expected.details(), &metadata);
let commitment = InputNoteCommitment::from_parts_unchecked(nullifier, Some(header));

let mut tracker = NoteUpdateTracker::new(vec![expected], vec![]);
tracker.insert_consumed_unauthenticated_note(&commitment, sender, BlockNumber::from(5u32));

// The existing expected note is recognized, so no new (placeholder) row is inserted.
assert_eq!(tracker.updated_input_notes().count(), 0);
}

#[test]
fn external_consumption_retains_note_id() {
let sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
Expand Down
10 changes: 9 additions & 1 deletion crates/rust-client/src/rpc/domain/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,15 @@ fn convert_transaction_header(
.ok_or(RpcError::ExpectedDataMissing("nullifier".into()))?
.try_into()
.map_err(|e: RpcConversionError| RpcError::InvalidResponse(e.to_string()))?;
Ok(InputNoteCommitment::from(Nullifier::from_raw(word)))
// Unauthenticated input notes (typically erased notes) carry their full header here;
// preserve it so the consuming account's sync can record the note even when the client
// never held its details.
let header = d
.header
.map(NoteHeader::try_from)
.transpose()
.map_err(|e: RpcConversionError| RpcError::InvalidResponse(e.to_string()))?;
Ok(InputNoteCommitment::from_parts_unchecked(Nullifier::from_raw(word), header))
})
.collect::<Result<Vec<_>, RpcError>>()?;
let input_notes = InputNotes::new_unchecked(note_commitments);
Expand Down
Loading
Loading