Skip to main content

miden_note_transport_node/node/grpc/
mod.rs

1mod streaming;
2
3use std::collections::BTreeSet;
4use std::net::SocketAddr;
5use std::sync::Arc;
6use std::time::Duration;
7
8use chrono::Utc;
9use miden_note_transport_proto::miden_note_transport::miden_note_transport_server::MidenNoteTransportServer;
10use miden_note_transport_proto::miden_note_transport::{
11    FetchNotesRequest,
12    FetchNotesResponse,
13    SendNoteRequest,
14    SendNoteResponse,
15    StatsResponse,
16    StreamNotesRequest,
17    TransportNote,
18};
19use miden_protocol::utils::serde::Deserializable;
20use rand::Rng;
21use tokio::sync::mpsc;
22use tonic::Status;
23use tonic_web::GrpcWebLayer;
24use tower::limit::GlobalConcurrencyLimitLayer;
25use tower::timeout::TimeoutLayer;
26use tower_http::cors::{Any, CorsLayer};
27
28use self::streaming::{NoteStreamer, StreamerMessage, Sub, Subface};
29use crate::database::Database;
30use crate::metrics::MetricsGrpc;
31
32/// Upper bound on the number of tags a client may include in a single
33/// `fetch_notes` request. Guards against two concerns:
34///   - Server CPU: deduplicating `request_data.tags` via `BTreeSet` is `O(n log n)`; a client
35///     sending millions of tags can burn a worker.
36///   - `SQLite` `IN (...)`: the underlying driver caps bound variables at
37///     `SQLITE_MAX_VARIABLE_NUMBER` (32766 on recent builds, lower on older); blow that and the
38///     query errors. Well below the `SQLite` cap so we have headroom for future query-plan changes.
39///
40/// A realistic wallet tracks O(10) to O(100) tags; 128 is generous without
41/// being an attack surface.
42const MAX_TAGS_PER_FETCH_REQUEST: usize = 128;
43
44/// Miden Note Transport gRPC server
45pub struct GrpcServer {
46    database: Arc<Database>,
47    config: GrpcServerConfig,
48    streamer: StreamerCtx,
49    metrics: MetricsGrpc,
50}
51
52/// [`GrpcServer`] configuration
53#[derive(Clone, Debug)]
54pub struct GrpcServerConfig {
55    /// Server host
56    pub host: String,
57    /// Server port
58    pub port: u16,
59    /// Maximum note size to be stored
60    pub max_note_size: usize,
61    /// Maximum number of concurrent connections
62    pub max_connections: usize,
63    /// Connection timeout in seconds
64    pub request_timeout: usize,
65}
66
67/// Streaming task interface context
68pub(super) struct StreamerCtx {
69    tx: mpsc::Sender<StreamerMessage>,
70    handle: tokio::task::JoinHandle<()>,
71}
72
73impl Default for GrpcServerConfig {
74    fn default() -> Self {
75        Self {
76            host: "127.0.0.1".to_string(),
77            port: 57292,
78            max_note_size: 512_000,
79            max_connections: 4096,
80            request_timeout: 4,
81        }
82    }
83}
84
85impl GrpcServer {
86    /// gRPC server constructor
87    pub fn new(database: Arc<Database>, config: GrpcServerConfig, metrics: MetricsGrpc) -> Self {
88        let streamer = StreamerCtx::spawn(database.clone());
89        Self { database, config, streamer, metrics }
90    }
91
92    /// Convert into a service
93    pub fn into_service(self) -> MidenNoteTransportServer<Self> {
94        MidenNoteTransportServer::new(self)
95    }
96
97    /// gRPC server running-task
98    pub async fn serve(self) -> crate::Result<()> {
99        let (health_reporter, health_svc) = tonic_health::server::health_reporter();
100        health_reporter.set_serving::<MidenNoteTransportServer<Self>>().await;
101
102        let addr = format!("{}:{}", self.config.host, self.config.port)
103            .parse::<SocketAddr>()
104            .map_err(|e| crate::Error::Internal(format!("Invalid address: {e}")))?;
105
106        let cors = CorsLayer::new().allow_origin(Any).allow_headers(Any).allow_methods(Any);
107
108        tonic::transport::Server::builder()
109            .accept_http1(true)
110            .layer(cors)
111            .layer(GrpcWebLayer::new())
112            .layer(GlobalConcurrencyLimitLayer::new(self.config.max_connections))
113            .layer(TimeoutLayer::new(Duration::from_secs(self.config.request_timeout as u64)))
114            .add_service(health_svc)
115            .add_service(self.into_service())
116            .serve(addr)
117            .await
118            .map_err(|e| crate::Error::Internal(format!("Server error: {e}")))
119    }
120}
121
122impl StreamerCtx {
123    /// Spawn a [`NoteStreamer`] task
124    ///
125    /// Returns related context composed of the handle and `mpsc::Sender` `tx` for control messages.
126    pub(super) fn spawn(database: Arc<Database>) -> Self {
127        let (tx, rx) = mpsc::channel(128);
128        let handle = tokio::spawn(NoteStreamer::new(database, rx).stream());
129        Self { tx, handle }
130    }
131}
132
133#[tonic::async_trait]
134impl miden_note_transport_proto::miden_note_transport::miden_note_transport_server::MidenNoteTransport
135    for GrpcServer
136{
137    #[tracing::instrument(skip(self, request), fields(
138        operation = "grpc.send_note.request",
139        note_size = tracing::field::Empty,
140    ))]
141    async fn send_note(
142        &self,
143        request: tonic::Request<SendNoteRequest>,
144    ) -> Result<tonic::Response<SendNoteResponse>, tonic::Status> {
145        let request_data = request.into_inner();
146        let pnote = request_data.note.ok_or_else(|| Status::invalid_argument("Missing note"))?;
147
148        // `header` + `details` are the stored payload; the cap, the metric, and
149        // the span field all use the same number so accept and reject report
150        // the same size.
151        let note_size = pnote.header.len() + pnote.details.len();
152        let span = tracing::Span::current();
153        span.record("note_size", note_size);
154
155        let timer = self.metrics.grpc_send_note_request(note_size as u64);
156
157        // Validate note size
158        if note_size > self.config.max_note_size {
159            tracing::warn!(reason = "note_too_large", size = note_size, max = self.config.max_note_size, "send_note rejected");
160            return Err(Status::resource_exhausted(format!("Note too large ({note_size})")));
161        }
162
163        // Convert protobuf request to internal types
164        let header = miden_protocol::note::NoteHeader::read_from_bytes(&pnote.header)
165            .map_err(|e| {
166                tracing::warn!(reason = "invalid_header", "send_note rejected");
167                Status::invalid_argument(format!("Invalid header: {e:?}"))
168            })?;
169
170        tracing::debug!(
171            note_id = %header.id(),
172            tag = header.metadata().tag().as_u32(),
173            has_after_block_num = pnote.after_block_num.is_some(),
174            "send_note accepted"
175        );
176
177        // Create note for database
178        let note_for_db = crate::types::StoredNote {
179            header,
180            details: pnote.details,
181            created_at: Utc::now(),
182            // Ignored on INSERT: the DB assigns seq via AUTOINCREMENT.
183            seq: 0,
184            after_block_num: pnote.after_block_num,
185        };
186
187        self.database
188            .store_note(&note_for_db)
189            .await.map_err(|e| tonic::Status::internal(format!("Failed to store note: {e:?}")))?;
190
191        timer.finish("ok");
192
193        Ok(tonic::Response::new(SendNoteResponse {}))
194    }
195
196    #[tracing::instrument(skip(self, request), fields(
197        operation = "grpc.fetch_notes.request",
198        tag_count = tracing::field::Empty,
199        cursor = tracing::field::Empty,
200        notes_returned = tracing::field::Empty,
201        response_cursor = tracing::field::Empty,
202    ))]
203    async fn fetch_notes(
204        &self,
205        request: tonic::Request<FetchNotesRequest>,
206    ) -> Result<tonic::Response<FetchNotesResponse>, tonic::Status> {
207        let timer = self.metrics.grpc_fetch_notes_request();
208
209        let request_data = request.into_inner();
210
211        // Reject requests with too many tags BEFORE any allocation /
212        // deduplication work. A client sending `[0u32; 1_000_000]` would
213        // otherwise force an O(n log n) BTreeSet build and then either blow
214        // through `SQLITE_MAX_VARIABLE_NUMBER` or return a pathologically
215        // expensive query plan.
216        if request_data.tags.len() > MAX_TAGS_PER_FETCH_REQUEST {
217            tracing::warn!(
218                reason = "too_many_tags",
219                tag_count = request_data.tags.len(),
220                max = MAX_TAGS_PER_FETCH_REQUEST,
221                "fetch_notes rejected"
222            );
223            return Err(Status::invalid_argument(format!(
224                "Too many tags in fetch_notes request: {} (max {})",
225                request_data.tags.len(),
226                MAX_TAGS_PER_FETCH_REQUEST
227            )));
228        }
229
230        // Deduplicate incoming tags — the DB query is more efficient without repeats
231        // and the previous per-tag loop happened to dedupe via BTreeSet.
232        let tag_set: BTreeSet<_> = request_data.tags.into_iter().collect();
233        let tags: Vec<crate::types::NoteTag> = tag_set.into_iter().map(Into::into).collect();
234        let cursor = request_data.cursor;
235
236        let span = tracing::Span::current();
237        span.record("tag_count", tags.len());
238        span.record("cursor", cursor);
239
240        // Single-snapshot fetch across ALL tags. Running per-tag queries back
241        // to back exposed a race where a concurrent INSERT could land between
242        // two per-tag queries and get leapfrogged when rcursor advanced past
243        // its seq on the next fetch. A single `tag IN (…)` query reads all
244        // matching rows in one consistent snapshot.
245        let stored_notes = self
246            .database
247            .fetch_notes_by_tags(&tags, cursor)
248            .await
249            .map_err(|e| tonic::Status::internal(format!("Failed to fetch notes: {e:?}")))?;
250
251        let mut rcursor = cursor;
252        for stored_note in &stored_notes {
253            let seq_cursor: u64 = stored_note
254                .seq
255                .try_into()
256                .map_err(|_| tonic::Status::internal("Negative seq in stored note"))?;
257            rcursor = rcursor.max(seq_cursor);
258        }
259
260        let proto_notes: Vec<_> = stored_notes.into_iter().map(TransportNote::from).collect();
261
262        span.record("notes_returned", proto_notes.len());
263        span.record("response_cursor", rcursor);
264
265        timer.finish("ok");
266
267        let proto_notes_size = proto_notes.iter().map(|pnote| (pnote.header.len() + pnote.details.len()) as u64).sum();
268        self.metrics.grpc_fetch_notes_response(
269            proto_notes.len() as u64,
270            proto_notes_size,
271        );
272
273        Ok(tonic::Response::new(FetchNotesResponse { notes: proto_notes, cursor: rcursor }))
274    }
275
276    type StreamNotesStream = Sub;
277    #[tracing::instrument(skip(self, request), fields(
278        operation = "grpc.stream_notes.request",
279        subscription_id = tracing::field::Empty,
280    ))]
281    async fn stream_notes(
282        &self,
283        request: tonic::Request<StreamNotesRequest>,
284    ) -> Result<tonic::Response<Self::StreamNotesStream>, tonic::Status> {
285        let request_data = request.into_inner();
286        let tag = request_data.tag.into();
287        let id: u64 = rand::rng().random();
288
289        let span = tracing::Span::current();
290        span.record("subscription_id", id);
291
292        tracing::debug!(tag = crate::types::NoteTag::as_u32(&tag), cursor = request_data.cursor, "stream_notes subscribe");
293
294        let (sub_tx, sub_rx) = mpsc::channel(32);
295        let sub = Sub::new(id, tag, sub_rx, self.streamer.tx.clone());
296        let subf = Subface::new(id, tag, sub_tx);
297        self.streamer.tx.try_send(StreamerMessage::AddSub(subf))
298                    .map_err(|e| tonic::Status::internal(format!("Failed sending internal streamer message: {e}")))?;
299
300        Ok(tonic::Response::new(sub))
301    }
302
303    #[tracing::instrument(skip(self), fields(operation = "grpc.stats.request"))]
304    async fn stats(
305        &self,
306        _request: tonic::Request<()>,
307    ) -> Result<tonic::Response<StatsResponse>, tonic::Status> {
308        let (total_notes, total_tags) = self
309            .database
310            .get_stats()
311            .await.map_err(|e| tonic::Status::internal(format!("Failed to get stats: {e:?}")))?;
312
313        let response = StatsResponse {
314            total_notes,
315            total_tags,
316            notes_per_tag: Vec::new(), // TODO: Implement notes_per_tag
317        };
318
319        Ok(tonic::Response::new(response))
320    }
321}
322
323impl Drop for StreamerCtx {
324    fn drop(&mut self) {
325        if let Err(e) = self.tx.try_send(StreamerMessage::Shutdown) {
326            tracing::error!("Streamer shutdown message sending failure: {e}");
327            self.handle.abort();
328        }
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use std::sync::Arc;
335
336    use miden_note_transport_proto::miden_note_transport::FetchNotesRequest;
337    use miden_note_transport_proto::miden_note_transport::miden_note_transport_server::MidenNoteTransport;
338
339    use super::*;
340    use crate::database::{Database, DatabaseConfig};
341    use crate::metrics::Metrics;
342
343    async fn test_server() -> GrpcServer {
344        let metrics = Metrics::default();
345        let db = Arc::new(
346            Database::connect(DatabaseConfig::default(), metrics.db.clone()).await.unwrap(),
347        );
348        GrpcServer::new(db, GrpcServerConfig::default(), metrics.grpc)
349    }
350
351    /// A client sending more tags than `MAX_TAGS_PER_FETCH_REQUEST` is rejected
352    /// with `InvalidArgument` BEFORE any `BTreeSet` or DB work. Guards against
353    /// both the O(n log n) dedup cost and the `SQLITE_MAX_VARIABLE_NUMBER`
354    /// ceiling.
355    #[tokio::test]
356    async fn test_fetch_notes_rejects_too_many_tags() {
357        let server = test_server().await;
358
359        let tags = vec![0u32; MAX_TAGS_PER_FETCH_REQUEST + 1];
360        let request = tonic::Request::new(FetchNotesRequest { tags, cursor: 0 });
361        let result = server.fetch_notes(request).await;
362
363        let status = result.expect_err("expected InvalidArgument");
364        assert_eq!(status.code(), tonic::Code::InvalidArgument);
365        assert!(
366            status.message().contains("Too many tags"),
367            "unexpected error message: {}",
368            status.message()
369        );
370    }
371
372    /// A client sending exactly `MAX_TAGS_PER_FETCH_REQUEST` tags is accepted.
373    /// (Using the same tag value many times is fine — the handler dedups via
374    /// `BTreeSet` before issuing the query.)
375    #[tokio::test]
376    async fn test_fetch_notes_accepts_max_tags_at_limit() {
377        let server = test_server().await;
378
379        let tags = vec![0u32; MAX_TAGS_PER_FETCH_REQUEST];
380        let request = tonic::Request::new(FetchNotesRequest { tags, cursor: 0 });
381        let result = server.fetch_notes(request).await;
382
383        let response = result.expect("request at the cap must succeed").into_inner();
384        assert_eq!(response.notes.len(), 0, "DB is empty, no notes returned");
385        assert_eq!(response.cursor, 0);
386    }
387}