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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
* [BREAKING][store] The SQLite store now stores account IDs as serialized `BLOB` columns instead of hex `TEXT` ([#2309](https://github.com/0xMiden/rust-sdk/pull/2309)).
* [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)).
* [BREAKING][rust] `From<InputNoteRecord> for NoteDetails` is now `TryFrom`, since the header-only records introduced by the new `ConsumedExternalErased` state carry no note details ([#2293](https://github.com/0xMiden/rust-sdk/pull/2293)).

### 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)).

### Changes

Expand Down
8 changes: 4 additions & 4 deletions bin/integration-tests/src/tests/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ pub async fn test_import_expected_notes(client_config: ClientConfig) -> Result<(
client_2.add_note_tag(note.metadata().unwrap().tag()).await.unwrap();
client_2
.import_notes(&[NoteFile::ExpectedNote {
details: note.clone().into(),
details: note.clone().try_into().expect("note record carries full details"),
sync_hint: NoteSyncHint::new(
client_1.get_sync_height().await.unwrap(),
note.metadata().unwrap().tag(),
Expand Down Expand Up @@ -362,7 +362,7 @@ pub async fn test_import_expected_note_uncommitted(client_config: ClientConfig)
// If the verification is requested before execution then the import should fail
let imported_commitment = client_2
.import_notes(&[NoteFile::ExpectedNote {
details: note.clone().into(),
details: note.clone().try_into().expect("note record carries full details"),
sync_hint: NoteSyncHint::new(0.into(), note.metadata().unwrap().tag()),
}])
.await?[0];
Expand Down Expand Up @@ -412,7 +412,7 @@ pub async fn test_import_expected_notes_from_the_past_as_committed(
// importing the note before client_2 is synced will result in a note with `Expected` state
let commitment = client_2
.import_notes(&[NoteFile::ExpectedNote {
details: note.clone().into(),
details: note.clone().try_into().expect("note record carries full details"),
sync_hint: NoteSyncHint::new(block_height_before, note.metadata().unwrap().tag()),
}])
.await?[0];
Expand All @@ -431,7 +431,7 @@ pub async fn test_import_expected_notes_from_the_past_as_committed(
assert!(
client_2
.import_notes(&[NoteFile::ExpectedNote {
details: note.clone().into(),
details: note.clone().try_into().expect("note record carries full details"),
sync_hint: NoteSyncHint::new(block_height_before, note.metadata().unwrap().tag()),
}])
.await?
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, AssetAmount, FungibleAsset};
use miden_client::auth::RPO_FALCON_SCHEME_ID;
use miden_client::keystore::Keystore;
Expand All @@ -13,6 +13,7 @@ use miden_client::note::{
NoteAttachmentScheme,
NoteAttachments,
NoteFile,
NoteId,
NoteType,
P2idNote,
};
Expand Down Expand Up @@ -724,6 +725,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
84 changes: 45 additions & 39 deletions bin/miden-cli/src/commands/notes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ async fn show_note<AUTH: Keystore + Sync>(

// Identify if this is a standard note type by script root
let script_root_word = match (&input_note_record, &output_note_record) {
(Some(record), _) => Some(record.details().script().root()),
(Some(record), _) if record.has_details() => Some(record.details().script().root()),
(_, Some(record)) => record.recipient().map(|r| r.script().root()),
_ => None,
};
Expand All @@ -231,54 +231,56 @@ async fn show_note<AUTH: Keystore + Sync>(
println!("{table}");

let inputs = match (&input_note_record, &output_note_record) {
(Some(record), _) => {
let details = record.details();
Some(details.storage().items().to_vec())
(Some(record), _) if record.has_details() => {
Some(record.details().storage().items().to_vec())
},
(_, Some(record)) => {
record.recipient().map(|recipient| recipient.storage().items().to_vec())
},
(Some(_), None) => None,
(None, None) => {
panic!("One of the two records should be Some")
},
};

let assets = input_note_record
.clone()
.filter(InputNoteRecord::has_details)
.map(|record| record.assets().clone())
.or(output_note_record.clone().map(|record| record.assets().clone()))
.expect("One of the two records should be Some");
.or(output_note_record.clone().map(|record| record.assets().clone()));

// print note vault
let mut table = create_dynamic_table(&["Note Assets"]);
table
.load_preset(presets::UTF8_HORIZONTAL_ONLY)
.set_content_arrangement(ContentArrangement::DynamicFullWidth);
if let Some(assets) = assets {
let mut table = create_dynamic_table(&["Note Assets"]);
table
.load_preset(presets::UTF8_HORIZONTAL_ONLY)
.set_content_arrangement(ContentArrangement::DynamicFullWidth);

table.add_row(vec![
Cell::new("Type").add_attribute(Attribute::Bold),
Cell::new("Faucet ID").add_attribute(Attribute::Bold),
Cell::new("Amount").add_attribute(Attribute::Bold),
]);
let resolver = load_faucet_metadata_resolver()?;
let assets = assets.iter();

for asset in assets {
let (asset_type, faucet, amount) = match asset {
Asset::Fungible(fungible_asset) => {
let (faucet, amount) =
resolver.format_fungible_asset(client, fungible_asset).await?;
("Fungible Asset", faucet, amount)
},
Asset::NonFungible(non_fungible_asset) => (
"Non Fungible Asset",
non_fungible_asset.faucet_id().prefix().to_hex(),
1.0.to_string(),
),
};
table.add_row(vec![asset_type, &faucet, &amount.clone()]);
table.add_row(vec![
Cell::new("Type").add_attribute(Attribute::Bold),
Cell::new("Faucet ID").add_attribute(Attribute::Bold),
Cell::new("Amount").add_attribute(Attribute::Bold),
]);
let resolver = load_faucet_metadata_resolver()?;
let assets = assets.iter();

for asset in assets {
let (asset_type, faucet, amount) = match asset {
Asset::Fungible(fungible_asset) => {
let (faucet, amount) =
resolver.format_fungible_asset(client, fungible_asset).await?;
("Fungible Asset", faucet, amount)
},
Asset::NonFungible(non_fungible_asset) => (
"Non Fungible Asset",
non_fungible_asset.faucet_id().prefix().to_hex(),
1.0.to_string(),
),
};
table.add_row(vec![asset_type, &faucet, &amount.clone()]);
}
println!("{table}");
}
println!("{table}");

if let Some(inputs) = inputs {
let inputs = NoteStorage::new(inputs.clone()).map_err(ClientError::NoteError)?;
Expand All @@ -300,12 +302,15 @@ async fn show_note<AUTH: Keystore + Sync>(
if with_code {
let mut table = create_dynamic_table(&["Note Code"]);
let code = match (&input_note_record, &output_note_record) {
(Some(record), _) => record.details().script().to_pretty_string(),
(Some(record), _) if record.has_details() => {
record.details().script().to_pretty_string()
},
(_, Some(record)) => {
record.state().recipient().map_or("Code unavailable".to_string(), |recipient| {
recipient.script().to_pretty_string()
})
},
(Some(_), None) => "Code unavailable".to_string(),
(None, None) => {
panic!("One of the two records should be Some")
},
Expand Down Expand Up @@ -460,29 +465,30 @@ fn note_summary(
.expect("One of the two records should be Some");

let assets_commitment_str = input_note_record
.filter(|record| record.has_details())
.map(|record| record.assets().commitment().to_string())
.or(output_note_record.map(|record| record.assets().commitment().to_string()))
.expect("One of the two records should be Some");
.or_else(|| output_note_record.map(|record| record.assets().commitment().to_string()))
.unwrap_or_else(|| "-".to_string());

let (inputs_commitment_str, serial_num, script_root_str) =
match (input_note_record, output_note_record) {
(Some(record), _) => {
(Some(record), _) if record.has_details() => {
let details = record.details();
(
details.storage().commitment().to_string(),
details.serial_num().to_string(),
details.script().root().to_string(),
)
},
(None, Some(record)) if record.recipient().is_some() => {
(_, Some(record)) if record.recipient().is_some() => {
let recipient = record.recipient().expect("output record should have recipient");
(
recipient.storage().commitment().to_string(),
recipient.serial_num().to_string(),
recipient.script().root().to_string(),
)
},
(None, Some(_record)) => ("-".to_string(), "-".to_string(), "-".to_string()),
(Some(_), _) | (_, Some(_)) => ("-".to_string(), "-".to_string(), "-".to_string()),
(None, None) => panic!("One of the two records should be Some"),
};

Expand Down
Loading
Loading