diff --git a/crates/node/src/database/maintenance.rs b/crates/node/src/database/maintenance.rs index f424271..7d36136 100644 --- a/crates/node/src/database/maintenance.rs +++ b/crates/node/src/database/maintenance.rs @@ -75,6 +75,8 @@ mod tests { details: vec![1, 2, 3, 4], created_at: Utc::now() - age, seq: 0, // ignored on INSERT + commitment_block_num: None, + note_metadata: None, } } diff --git a/crates/node/src/database/mod.rs b/crates/node/src/database/mod.rs index 3047a96..7924be7 100644 --- a/crates/node/src/database/mod.rs +++ b/crates/node/src/database/mod.rs @@ -142,6 +142,8 @@ mod tests { details: vec![1, 2, 3, 4], created_at: Utc::now(), seq: 0, // ignored on INSERT + commitment_block_num: None, + note_metadata: None, }; db.store_note(¬e).await.unwrap(); @@ -172,6 +174,8 @@ mod tests { details: vec![1], created_at: Utc::now(), seq: 0, + commitment_block_num: None, + note_metadata: None, }; db.store_note(&first).await.unwrap(); @@ -180,6 +184,8 @@ mod tests { details: vec![2], created_at: Utc::now(), seq: 0, + commitment_block_num: None, + note_metadata: None, }; db.store_note(&second).await.unwrap(); @@ -237,6 +243,8 @@ mod tests { details: vec![i as u8], created_at: Utc::now(), seq: 0, + commitment_block_num: None, + note_metadata: None, }) .await .unwrap(); @@ -267,6 +275,8 @@ mod tests { details: vec![1, 2, 3, 4], created_at: Utc::now(), seq: 0, // ignored on INSERT + commitment_block_num: None, + note_metadata: None, }; db.store_note(¬e).await.unwrap(); @@ -303,6 +313,8 @@ mod tests { details: vec![1], created_at: t, seq: 0, + commitment_block_num: None, + note_metadata: None, }; db.store_note(¬e1).await.unwrap(); @@ -317,6 +329,8 @@ mod tests { details: vec![2], created_at: t, seq: 0, + commitment_block_num: None, + note_metadata: None, }; db.store_note(¬e2).await.unwrap(); @@ -360,6 +374,8 @@ mod tests { details: vec![1], created_at: Utc::now(), seq: 0, + commitment_block_num: None, + note_metadata: None, }) .await .unwrap(); @@ -378,6 +394,8 @@ mod tests { details: vec![2], created_at: Utc::now(), seq: 0, + commitment_block_num: None, + note_metadata: None, }) .await .unwrap(); @@ -386,6 +404,8 @@ mod tests { details: vec![3], created_at: Utc::now(), seq: 0, + commitment_block_num: None, + note_metadata: None, }) .await .unwrap(); @@ -448,6 +468,8 @@ mod tests { details: vec![1, 2, 3, 4], created_at: Utc::now(), seq: 0, + commitment_block_num: None, + note_metadata: None, }; db.store_note(¬e).await.unwrap(); @@ -487,6 +509,8 @@ mod tests { details: vec![(i % 256) as u8], created_at: Utc::now(), seq: 0, + commitment_block_num: None, + note_metadata: None, }) .await .unwrap(); @@ -515,4 +539,62 @@ mod tests { let (total_stats, _) = db.get_stats().await.unwrap(); assert_eq!(usize::try_from(total_stats).unwrap(), total); } + + /// Block context fields (`commitment_block_num`, `note_metadata`) survive + /// the full round-trip: `StoredNote` → `NewNote` → INSERT → SELECT → `Note` → + /// `StoredNote` → `TransportNote`. + /// + /// Also verifies `u32::MAX` (the upper bound of the proto `uint32` field) + /// round-trips correctly through the `i64` `SQLite` column, confirming no + /// truncation at the `i32::MAX` boundary. + #[tokio::test] + async fn test_block_context_round_trips_through_store_and_fetch() { + use miden_note_transport_proto::miden_note_transport::TransportNote; + + let db = Database::connect(DatabaseConfig::default(), Metrics::default().db) + .await + .unwrap(); + + // Store a note with typical block context values. + let note = StoredNote { + header: test_note_header(), + details: vec![10, 20, 30], + created_at: Utc::now(), + seq: 0, + commitment_block_num: Some(12345), + note_metadata: Some(vec![1, 2, 3, 4]), + }; + db.store_note(¬e).await.unwrap(); + + let fetched = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap(); + assert_eq!(fetched.len(), 1); + assert_eq!(fetched[0].commitment_block_num, Some(12345)); + assert_eq!(fetched[0].note_metadata, Some(vec![1, 2, 3, 4])); + + // Proto conversion must preserve the fields. + let proto: TransportNote = fetched.into_iter().next().unwrap().into(); + assert_eq!(proto.commitment_block_num, Some(12345)); + assert_eq!(proto.note_metadata, Some(vec![1, 2, 3, 4])); + + // Store a second note with u32::MAX to confirm the i64 column handles + // the full u32 range without truncation at i32::MAX (2,147,483,647). + let note_max = StoredNote { + header: test_note_header(), + details: vec![99], + created_at: Utc::now(), + seq: 0, + commitment_block_num: Some(u32::MAX), + note_metadata: None, + }; + db.store_note(¬e_max).await.unwrap(); + + // Fetch all notes (cursor 0) and find the u32::MAX one by details. + let all = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap(); + let max_note = all.iter().find(|n| n.details == vec![99]).expect("u32::MAX note not found"); + assert_eq!( + max_note.commitment_block_num, + Some(u32::MAX), + "u32::MAX must survive the u32 -> i64 -> u32 round-trip" + ); + } } diff --git a/crates/node/src/database/sqlite/migrations/20260423000000_add_block_context/down.sql b/crates/node/src/database/sqlite/migrations/20260423000000_add_block_context/down.sql new file mode 100644 index 0000000..60d456d --- /dev/null +++ b/crates/node/src/database/sqlite/migrations/20260423000000_add_block_context/down.sql @@ -0,0 +1,13 @@ +CREATE TABLE notes_backup ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id BLOB NOT NULL UNIQUE, + tag INTEGER NOT NULL, + header BLOB NOT NULL, + details BLOB NOT NULL, + created_at INTEGER NOT NULL +) STRICT; +INSERT INTO notes_backup SELECT seq, id, tag, header, details, created_at FROM notes; +DROP TABLE notes; +ALTER TABLE notes_backup RENAME TO notes; +CREATE INDEX idx_notes_tag_seq ON notes(tag, seq); +CREATE INDEX idx_notes_created_at ON notes(created_at); diff --git a/crates/node/src/database/sqlite/migrations/20260423000000_add_block_context/up.sql b/crates/node/src/database/sqlite/migrations/20260423000000_add_block_context/up.sql new file mode 100644 index 0000000..3700659 --- /dev/null +++ b/crates/node/src/database/sqlite/migrations/20260423000000_add_block_context/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE notes ADD COLUMN commitment_block_num INTEGER; +ALTER TABLE notes ADD COLUMN note_metadata BLOB; diff --git a/crates/node/src/database/sqlite/models.rs b/crates/node/src/database/sqlite/models.rs index 088b11e..e5d1318 100644 --- a/crates/node/src/database/sqlite/models.rs +++ b/crates/node/src/database/sqlite/models.rs @@ -8,6 +8,7 @@ use crate::types::{NoteHeader, StoredNote}; #[derive(Queryable, Selectable, Debug, Clone)] #[diesel(table_name = notes)] +#[allow(clippy::struct_field_names)] pub struct Note { pub seq: i64, pub id: Vec, @@ -15,6 +16,8 @@ pub struct Note { pub header: Vec, pub details: Vec, pub created_at: i64, + pub commitment_block_num: Option, + pub note_metadata: Option>, } // `seq` is omitted from `NewNote`: SQLite auto-assigns it on INSERT via @@ -28,6 +31,8 @@ pub struct NewNote { pub header: Vec, pub details: Vec, pub created_at: i64, + pub commitment_block_num: Option, + pub note_metadata: Option>, } impl From<&StoredNote> for NewNote { @@ -38,6 +43,8 @@ impl From<&StoredNote> for NewNote { header: note.header.to_bytes(), details: note.details.clone(), created_at: note.created_at.timestamp_micros(), + commitment_block_num: note.commitment_block_num.map(i64::from), + note_metadata: note.note_metadata.clone(), } } } @@ -62,6 +69,57 @@ impl TryFrom for StoredNote { details: note.details, created_at, seq: note.seq, + commitment_block_num: note + .commitment_block_num + .map(|n| { + u32::try_from(n).map_err(|_| { + DatabaseError::Deserialization(format!("Invalid commitment_block_num: {n}")) + }) + }) + .transpose()?, + note_metadata: note.note_metadata, }) } } + +#[cfg(test)] +mod tests { + use chrono::Utc; + use miden_protocol::utils::serde::Serializable; + + use super::*; + use crate::database::DatabaseError; + use crate::test_utils::test_note_header; + + /// The `TryFrom for StoredNote` conversion rejects a + /// `commitment_block_num` that exceeds `u32::MAX`. This guards against + /// corrupt or tampered DB rows where the `i64` column holds a value + /// outside the `u32` domain. Without this test the conversion guard at + /// line 74-78 is dead code from a coverage perspective. + #[test] + fn test_block_context_rejects_out_of_range_value() { + let header = test_note_header(); + let raw_note = Note { + seq: 1, + id: header.id().as_bytes().to_vec(), + tag: 0, + header: header.to_bytes(), + details: vec![], + created_at: Utc::now().timestamp_micros(), + commitment_block_num: Some(i64::from(u32::MAX) + 1), + note_metadata: None, + }; + + let result = StoredNote::try_from(raw_note); + assert!(result.is_err(), "commitment_block_num above u32::MAX must be rejected"); + match result.unwrap_err() { + DatabaseError::Deserialization(msg) => { + assert!( + msg.contains("Invalid commitment_block_num"), + "unexpected error message: {msg}" + ); + }, + other => panic!("expected DatabaseError::Deserialization, got: {other:?}"), + } + } +} diff --git a/crates/node/src/database/sqlite/schema.rs b/crates/node/src/database/sqlite/schema.rs index 9cf7a0f..04ffcd7 100644 --- a/crates/node/src/database/sqlite/schema.rs +++ b/crates/node/src/database/sqlite/schema.rs @@ -8,5 +8,7 @@ diesel::table! { header -> Binary, details -> Binary, created_at -> BigInt, + commitment_block_num -> Nullable, + note_metadata -> Nullable, } } diff --git a/crates/node/src/node/grpc/mod.rs b/crates/node/src/node/grpc/mod.rs index f48aead..ce6d12c 100644 --- a/crates/node/src/node/grpc/mod.rs +++ b/crates/node/src/node/grpc/mod.rs @@ -33,9 +33,9 @@ use crate::metrics::MetricsGrpc; /// `fetch_notes` request. Guards against two concerns: /// - Server CPU: deduplicating `request_data.tags` via `BTreeSet` is `O(n log n)`; a client /// sending millions of tags can burn a worker. -/// - SQLite `IN (...)`: the underlying driver caps bound variables at +/// - `SQLite` `IN (...)`: the underlying driver caps bound variables at /// `SQLITE_MAX_VARIABLE_NUMBER` (32766 on recent builds, lower on older); blow that and the -/// query errors. Well below the SQLite cap so we have headroom for future query-plan changes. +/// query errors. Well below the `SQLite` cap so we have headroom for future query-plan changes. /// /// A realistic wallet tracks O(10) to O(100) tags; 128 is generous without /// being an attack surface. @@ -144,9 +144,10 @@ impl miden_note_transport_proto::miden_note_transport::miden_note_transport_serv let timer = self.metrics.grpc_send_note_request((pnote.header.len() + pnote.details.len()) as u64); - // Validate note size - if pnote.details.len() > self.config.max_note_size { - return Err(Status::resource_exhausted(format!("Note too large ({})", pnote.details.len()))); + // Validate note size (details + optional metadata) + let payload_size = pnote.details.len() + pnote.note_metadata.as_ref().map_or(0, Vec::len); + if payload_size > self.config.max_note_size { + return Err(Status::resource_exhausted(format!("Note too large ({payload_size})"))); } // Convert protobuf request to internal types @@ -160,6 +161,8 @@ impl miden_note_transport_proto::miden_note_transport::miden_note_transport_serv created_at: Utc::now(), // Ignored on INSERT: the DB assigns seq via AUTOINCREMENT. seq: 0, + commitment_block_num: pnote.commitment_block_num, + note_metadata: pnote.note_metadata, }; self.database diff --git a/crates/node/src/types.rs b/crates/node/src/types.rs index 05ad16b..8db7261 100644 --- a/crates/node/src/types.rs +++ b/crates/node/src/types.rs @@ -31,6 +31,10 @@ pub struct StoredNote { /// Untouched when constructing a `StoredNote` for insertion — the DB /// assigns the real value via `INTEGER PRIMARY KEY AUTOINCREMENT`. pub seq: i64, + /// Block number where the note commitment was included on-chain. + pub commitment_block_num: Option, + /// Serialized `NoteMetadata` from the commitment block. + pub note_metadata: Option>, } impl From for TransportNote { @@ -38,6 +42,8 @@ impl From for TransportNote { Self { header: snote.header.to_bytes(), details: snote.details, + commitment_block_num: snote.commitment_block_num, + note_metadata: snote.note_metadata, } } } diff --git a/crates/proto/src/generated/miden_note_transport.rs b/crates/proto/src/generated/miden_note_transport.rs index f9da3ea..f686fd8 100644 --- a/crates/proto/src/generated/miden_note_transport.rs +++ b/crates/proto/src/generated/miden_note_transport.rs @@ -9,6 +9,32 @@ pub struct TransportNote { /// NoteDetails, can be encrypted #[prost(bytes = "vec", tag = "2")] pub details: ::prost::alloc::vec::Vec, + /// Block number where the note's on-chain commitment was included. + /// + /// Sender-populated, optional. The NTL stores this verbatim and does not + /// validate, fetch, or backfill it. Population strategies: + /// + /// * Exact: set after the sender's transaction confirms (typically + /// 5-15 seconds after submit on Miden). Gives the recipient the + /// precise block to scan. + /// * Lower bound: set to the chain tip at send time, optionally minus + /// a small safety margin. Any value \<= the actual commitment block + /// works correctly - the recipient uses it as the floor for its + /// commitment scan. + /// * Unset: the recipient falls back to its own lookback heuristic + /// (currently a 20-block scan window in miden-client). + /// + /// Wallets that need deterministic note delivery should always populate + /// this field. + #[prost(uint32, optional, tag = "3")] + pub commitment_block_num: ::core::option::Option, + /// Serialized NoteMetadata from the commitment block. + /// + /// Sender-populated, optional. When present, the recipient can skip + /// sync_notes entirely and transition the note to Committed immediately + /// during import. The NTL stores this verbatim without validation. + #[prost(bytes = "vec", optional, tag = "4")] + pub note_metadata: ::core::option::Option<::prost::alloc::vec::Vec>, } /// API request for sending a note #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] diff --git a/proto/proto/miden_note_transport.proto b/proto/proto/miden_note_transport.proto index fbfc801..3d5b781 100644 --- a/proto/proto/miden_note_transport.proto +++ b/proto/proto/miden_note_transport.proto @@ -12,6 +12,31 @@ message TransportNote { bytes header = 1; // NoteDetails, can be encrypted bytes details = 2; + // Block number where the note's on-chain commitment was included. + // + // Sender-populated, optional. The NTL stores this verbatim and does not + // validate, fetch, or backfill it. Population strategies: + // + // - Exact: set after the sender's transaction confirms (typically + // 5-15 seconds after submit on Miden). Gives the recipient the + // precise block to scan. + // - Lower bound: set to the chain tip at send time, optionally minus + // a small safety margin. Any value <= the actual commitment block + // works correctly - the recipient uses it as the floor for its + // commitment scan. + // - Unset: the recipient falls back to its own lookback heuristic + // (currently a 20-block scan window in miden-client). + // + // Wallets that need deterministic note delivery should always populate + // this field. + optional uint32 commitment_block_num = 3; + + // Serialized NoteMetadata from the commitment block. + // + // Sender-populated, optional. When present, the recipient can skip + // sync_notes entirely and transition the note to Committed immediately + // during import. The NTL stores this verbatim without validation. + optional bytes note_metadata = 4; } // API request for sending a note