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
2 changes: 2 additions & 0 deletions crates/node/src/database/maintenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
82 changes: 82 additions & 0 deletions crates/node/src/database/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&note).await.unwrap();
Expand Down Expand Up @@ -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();

Expand All @@ -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();

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(&note).await.unwrap();
Expand Down Expand Up @@ -303,6 +313,8 @@ mod tests {
details: vec![1],
created_at: t,
seq: 0,
commitment_block_num: None,
note_metadata: None,
};
db.store_note(&note1).await.unwrap();

Expand All @@ -317,6 +329,8 @@ mod tests {
details: vec![2],
created_at: t,
seq: 0,
commitment_block_num: None,
note_metadata: None,
};
db.store_note(&note2).await.unwrap();

Expand Down Expand Up @@ -360,6 +374,8 @@ mod tests {
details: vec![1],
created_at: Utc::now(),
seq: 0,
commitment_block_num: None,
note_metadata: None,
})
.await
.unwrap();
Expand All @@ -378,6 +394,8 @@ mod tests {
details: vec![2],
created_at: Utc::now(),
seq: 0,
commitment_block_num: None,
note_metadata: None,
})
.await
.unwrap();
Expand All @@ -386,6 +404,8 @@ mod tests {
details: vec![3],
created_at: Utc::now(),
seq: 0,
commitment_block_num: None,
note_metadata: None,
})
.await
.unwrap();
Expand Down Expand Up @@ -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(&note).await.unwrap();

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(&note).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(&note_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"
);
}
}
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE notes ADD COLUMN commitment_block_num INTEGER;
ALTER TABLE notes ADD COLUMN note_metadata BLOB;
58 changes: 58 additions & 0 deletions crates/node/src/database/sqlite/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ 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<u8>,
pub tag: i64,
pub header: Vec<u8>,
pub details: Vec<u8>,
pub created_at: i64,
pub commitment_block_num: Option<i64>,
pub note_metadata: Option<Vec<u8>>,
}

// `seq` is omitted from `NewNote`: SQLite auto-assigns it on INSERT via
Expand All @@ -28,6 +31,8 @@ pub struct NewNote {
pub header: Vec<u8>,
pub details: Vec<u8>,
pub created_at: i64,
pub commitment_block_num: Option<i64>,
pub note_metadata: Option<Vec<u8>>,
}

impl From<&StoredNote> for NewNote {
Expand All @@ -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(),
}
}
}
Expand All @@ -62,6 +69,57 @@ impl TryFrom<Note> 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<Note> 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:?}"),
}
}
}
2 changes: 2 additions & 0 deletions crates/node/src/database/sqlite/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@ diesel::table! {
header -> Binary,
details -> Binary,
created_at -> BigInt,
commitment_block_num -> Nullable<BigInt>,
note_metadata -> Nullable<Binary>,
}
}
13 changes: 8 additions & 5 deletions crates/node/src/node/grpc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions crates/node/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,19 @@ 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<u32>,
/// Serialized `NoteMetadata` from the commitment block.
pub note_metadata: Option<Vec<u8>>,
}

impl From<StoredNote> for TransportNote {
fn from(snote: StoredNote) -> Self {
Self {
header: snote.header.to_bytes(),
details: snote.details,
commitment_block_num: snote.commitment_block_num,
note_metadata: snote.note_metadata,
}
}
}
Expand Down
26 changes: 26 additions & 0 deletions crates/proto/src/generated/miden_note_transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,32 @@ pub struct TransportNote {
/// NoteDetails, can be encrypted
#[prost(bytes = "vec", tag = "2")]
pub details: ::prost::alloc::vec::Vec<u8>,
/// 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<u32>,
/// 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<u8>>,
}
/// API request for sending a note
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
Expand Down
Loading
Loading