Skip to main content

simulator_client/
subscriptions.rs

1use std::{future::Future, time::Duration};
2
3use futures::{SinkExt, StreamExt};
4use serde::{Deserialize, de::DeserializeOwned};
5use simulator_api::EncodedBinary;
6use solana_client::{
7    nonblocking::pubsub_client::PubsubClient,
8    rpc_response::{Response, RpcLogsResponse},
9};
10use solana_commitment_config::CommitmentConfig;
11use solana_rpc_client_api::config::{RpcTransactionLogsConfig, RpcTransactionLogsFilter};
12use thiserror::Error;
13use tokio::{
14    sync::{oneshot, watch},
15    task::JoinHandle,
16};
17use tokio_tungstenite::tungstenite::Message;
18
19use crate::urls::{UrlError, http_to_ws_url};
20
21/// Error establishing a PubSub log subscription.
22#[derive(Debug, Error)]
23pub enum SubscriptionError {
24    #[error(transparent)]
25    InvalidUrl(#[from] UrlError),
26
27    #[error("pubsub connect to {url} failed: {source}")]
28    Connect {
29        url: String,
30        #[source]
31        source: Box<dyn std::error::Error + Send + Sync>,
32    },
33
34    #[error("logs_subscribe failed: {source}")]
35    Subscribe {
36        #[source]
37        source: Box<dyn std::error::Error + Send + Sync>,
38    },
39
40    #[error("subscription task exited unexpectedly before signaling ready")]
41    TaskDropped,
42
43    #[error("session has no rpc_endpoint (was the session created?)")]
44    NoRpcEndpoint,
45}
46
47#[derive(Debug, Error)]
48pub enum SubscriptionRuntimeError {
49    #[error("{kind} subscription for {target} closed unexpectedly")]
50    Closed { kind: &'static str, target: String },
51
52    #[error("{kind} subscription callback worker for {target} failed: {source}")]
53    CallbackWorker {
54        kind: &'static str,
55        target: String,
56        #[source]
57        source: tokio::task::JoinError,
58    },
59}
60
61const SUBSCRIPTION_DRAIN_IDLE_TIMEOUT: Duration = Duration::from_millis(250);
62const SUBSCRIPTION_DRAIN_MAX_DURATION: Duration = Duration::from_secs(5);
63
64type SubscriptionTaskHandle = JoinHandle<Result<(), SubscriptionRuntimeError>>;
65type AccountDiffWs =
66    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
67
68/// A unified subscription handle that can represent any subscription type.
69pub struct SubscriptionHandle {
70    pub join_handle: SubscriptionTaskHandle,
71    pub stop: watch::Sender<bool>,
72}
73
74impl From<LogSubscriptionHandle> for SubscriptionHandle {
75    fn from(h: LogSubscriptionHandle) -> Self {
76        Self {
77            join_handle: h.join_handle,
78            stop: h.stop,
79        }
80    }
81}
82
83impl From<AccountDiffSubscriptionHandle> for SubscriptionHandle {
84    fn from(h: AccountDiffSubscriptionHandle) -> Self {
85        Self {
86            join_handle: h.join_handle,
87            stop: h.stop,
88        }
89    }
90}
91
92impl From<ActionSubscriptionHandle> for SubscriptionHandle {
93    fn from(h: ActionSubscriptionHandle) -> Self {
94        Self {
95            join_handle: h.join_handle,
96            stop: h.stop,
97        }
98    }
99}
100
101/// Handle for a running log subscription background task.
102pub struct LogSubscriptionHandle {
103    /// Background task that drives the subscription and spawns per-notification callbacks.
104    ///
105    /// Resolves after `stop.send(true)` is called, remaining buffered
106    /// notifications are drained, and all spawned callback tasks complete.
107    pub join_handle: SubscriptionTaskHandle,
108
109    /// Send `true` to signal the background task to stop accepting new
110    /// notifications, drain remaining buffered ones, and exit cleanly.
111    pub stop: watch::Sender<bool>,
112}
113
114/// Subscribe to program log notifications and invoke a callback for each one.
115///
116/// Spawns a background task that:
117/// 1. Connects to the PubSub endpoint derived from `rpc_endpoint`.
118/// 2. Subscribes to logs mentioning `program_id`.
119/// 3. For each notification, spawns `on_notification(notification)` as a Tokio task.
120/// 4. When `handle.stop.send(true)` is called, drains remaining buffered
121///    notifications (up to 1s), waits for all spawned tasks, then returns.
122///
123/// Returns after the subscription is established. If setup fails, an error is
124/// returned before any background task is left running.
125///
126/// ## Example
127///
128/// ```no_run
129/// use std::sync::{Arc, Mutex};
130/// use simulator_client::subscribe_program_logs;
131/// use solana_commitment_config::CommitmentConfig;
132///
133/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
134/// let handle = subscribe_program_logs(
135///     "https://api.mainnet-beta.solana.com",
136///     "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
137///     CommitmentConfig::confirmed(),
138///     |notification| async move {
139///         println!("sig: {}", notification.value.signature);
140///     },
141/// )
142/// .await?;
143///
144/// // ... do other work ...
145///
146/// handle.stop.send(true).ok();
147/// handle.join_handle.await.ok();
148/// # Ok(())
149/// # }
150/// ```
151pub async fn subscribe_program_logs<F, Fut>(
152    rpc_endpoint: &str,
153    program_id: &str,
154    commitment: CommitmentConfig,
155    on_notification: F,
156) -> Result<LogSubscriptionHandle, SubscriptionError>
157where
158    F: Fn(Response<RpcLogsResponse>) -> Fut + Send + Sync + 'static,
159    Fut: Future<Output = ()> + Send + 'static,
160{
161    let ws_url = http_to_ws_url(rpc_endpoint)?;
162    let program_id = program_id.to_string();
163
164    let (ready_tx, ready_rx) = oneshot::channel::<Result<(), SubscriptionError>>();
165    let (stop_tx, mut stop_rx) = watch::channel(false);
166
167    // PubsubClient::logs_subscribe borrows &self, so both the client and the
168    // stream must live inside the spawned task. We report setup success/failure
169    // back through a oneshot channel before entering the notification loop.
170    let join_handle = tokio::spawn(async move {
171        let client = match PubsubClient::new(&ws_url).await {
172            Ok(c) => c,
173            Err(e) => {
174                let _ = ready_tx.send(Err(SubscriptionError::Connect {
175                    url: ws_url,
176                    source: Box::new(e),
177                }));
178                return Ok(());
179            }
180        };
181
182        let (mut stream, _unsubscribe) = match client
183            .logs_subscribe(
184                RpcTransactionLogsFilter::Mentions(vec![program_id.clone()]),
185                RpcTransactionLogsConfig {
186                    commitment: Some(commitment),
187                },
188            )
189            .await
190        {
191            Ok(s) => s,
192            Err(e) => {
193                let _ = ready_tx.send(Err(SubscriptionError::Subscribe {
194                    source: Box::new(e),
195                }));
196                return Ok(());
197            }
198        };
199
200        let _ = ready_tx.send(Ok(()));
201
202        let mut tasks: Vec<JoinHandle<()>> = Vec::new();
203        let kind = "program logs";
204
205        loop {
206            if *stop_rx.borrow() {
207                let drain_deadline = tokio::time::Instant::now() + SUBSCRIPTION_DRAIN_MAX_DURATION;
208                while let Ok(Ok(Some(notification))) = tokio::time::timeout_at(
209                    drain_deadline,
210                    tokio::time::timeout(SUBSCRIPTION_DRAIN_IDLE_TIMEOUT, stream.next()),
211                )
212                .await
213                {
214                    tasks.push(tokio::spawn(on_notification(notification)));
215                }
216                break;
217            }
218
219            let notification = tokio::select! {
220                n = stream.next() => n,
221                _ = stop_rx.changed() => continue,
222            };
223
224            match notification {
225                Some(n) => tasks.push(tokio::spawn(on_notification(n))),
226                None => return Err(subscription_runtime_closed(kind, &program_id)),
227            }
228        }
229
230        // Wait for all in-flight callback tasks to complete.
231        for task in tasks {
232            if let Err(source) = task.await {
233                return Err(callback_worker_failed(kind, &program_id, source));
234            }
235        }
236
237        Ok(())
238    });
239
240    match ready_rx.await {
241        Ok(Ok(())) => Ok(LogSubscriptionHandle {
242            join_handle,
243            stop: stop_tx,
244        }),
245        Ok(Err(e)) => {
246            join_handle.abort();
247            Err(e)
248        }
249        Err(_) => {
250            join_handle.abort();
251            Err(SubscriptionError::TaskDropped)
252        }
253    }
254}
255
256// ── Account diff subscription ────────────────────────────────────────────────
257
258/// Slot context included in every account diff notification.
259#[derive(Debug, Clone, Deserialize)]
260pub struct AccountDiffContext {
261    pub slot: u64,
262}
263
264/// A single account diff notification delivered by `accountDiffSubscribe`.
265#[derive(Debug, Clone, Deserialize)]
266pub struct AccountDiffNotification {
267    pub context: AccountDiffContext,
268    /// The address of the account that changed.
269    pub account: Option<String>,
270    /// Signature of the transaction that triggered this change, if known.
271    pub signature: Option<String>,
272    /// Position of the transaction within its slot, if known.
273    #[serde(default)]
274    pub tx_index: Option<u32>,
275    /// Unix-seconds block time of the slot, if known.
276    #[serde(default)]
277    pub block_time: Option<i64>,
278    /// Account state before the change (absent for newly created accounts).
279    pub pre: Option<serde_json::Value>,
280    /// Account state after the change (absent for deleted accounts).
281    pub post: Option<serde_json::Value>,
282}
283
284#[derive(Debug, Clone, Deserialize)]
285pub struct ActionResultContext {
286    pub slot: u64,
287}
288
289/// One scheduled-action result delivered by `actionSubscribe`.
290#[derive(Debug, Clone, Deserialize)]
291#[serde(rename_all = "camelCase")]
292pub struct ActionResultNotification {
293    pub context: ActionResultContext,
294    pub slot: u64,
295    /// Batch the action fired at; `None` for slot-boundary actions.
296    #[serde(default)]
297    pub batch_index: Option<u32>,
298    /// Index of the action in the session's `actions` list.
299    pub action_index: u32,
300    #[serde(default)]
301    pub label: Option<String>,
302    pub committed: bool,
303    /// One per transaction in the action's sequence, in order. A failing
304    /// transaction is the last entry; later transactions don't run.
305    #[serde(default)]
306    pub transaction_outcomes: Vec<ActionTransactionOutcome>,
307    /// Post-execution `UiAccount` JSON per `return_accounts` address, positional;
308    /// cumulative state after the final transaction.
309    #[serde(default)]
310    pub accounts: Vec<Option<serde_json::Value>>,
311    /// Encoded transaction whose discovery-filter match triggered this action;
312    /// absent for slot-boundary actions. Decode with [`EncodedBinary::decode`]
313    /// (base64 bincode of `TxWithMeta`) to inspect the matching transaction.
314    #[serde(default)]
315    pub matched: Option<EncodedBinary>,
316}
317
318/// One transaction's result within a scheduled action's sequence.
319#[derive(Debug, Clone, Deserialize)]
320#[serde(rename_all = "camelCase")]
321pub struct ActionTransactionOutcome {
322    /// Transaction failure message; `None` on success.
323    #[serde(default)]
324    pub err: Option<String>,
325    #[serde(default)]
326    pub logs: Vec<String>,
327    pub units_consumed: u64,
328    #[serde(default)]
329    pub fee: Option<u64>,
330    /// Program return data (`{programId, data}`), if any.
331    #[serde(default)]
332    pub return_data: Option<serde_json::Value>,
333}
334
335/// A routed account diff notification tied to the subscribed account that produced it.
336#[derive(Debug, Clone)]
337pub struct RoutedAccountDiffNotification {
338    pub account: String,
339    pub notification: AccountDiffNotification,
340}
341
342/// Handle for a running account diff subscription background task.
343///
344/// Send `true` on `stop` to request a clean shutdown, then await `join_handle`.
345pub struct AccountDiffSubscriptionHandle {
346    pub join_handle: SubscriptionTaskHandle,
347    pub stop: watch::Sender<bool>,
348}
349
350fn subscription_runtime_closed(
351    kind: &'static str,
352    target: impl Into<String>,
353) -> SubscriptionRuntimeError {
354    SubscriptionRuntimeError::Closed {
355        kind,
356        target: target.into(),
357    }
358}
359
360fn callback_worker_failed(
361    kind: &'static str,
362    target: impl Into<String>,
363    source: tokio::task::JoinError,
364) -> SubscriptionRuntimeError {
365    SubscriptionRuntimeError::CallbackWorker {
366        kind,
367        target: target.into(),
368        source,
369    }
370}
371
372/// Subscribe to account diff notifications and invoke a callback for each one.
373///
374/// Spawns a background task that:
375/// 1. Connects to the WebSocket endpoint derived from `rpc_endpoint`.
376/// 2. Subscribes to account diffs for the given filter (account or program).
377/// 3. For each notification, spawns `on_notification(notification)` as a Tokio task.
378/// 4. When `handle.stop.send(true)` is called, drains remaining buffered
379///    notifications (up to 1s), waits for all spawned tasks, then returns.
380///
381/// ## Example
382///
383/// ```no_run
384/// use simulator_client::subscribe_account_diffs;
385///
386/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
387/// let handle = subscribe_account_diffs(
388///     "http://localhost:8900/session/abc",
389///     "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
390///     |notification| async move {
391///         println!("slot={} sig={:?}", notification.context.slot, notification.signature);
392///     },
393/// )
394/// .await?;
395///
396/// handle.stop.send(true).ok();
397/// handle.join_handle.await.ok();
398/// # Ok(())
399/// # }
400/// ```
401pub async fn subscribe_account_diffs<F, Fut>(
402    rpc_endpoint: &str,
403    account: &str,
404    on_notification: F,
405) -> Result<AccountDiffSubscriptionHandle, SubscriptionError>
406where
407    F: Fn(AccountDiffNotification) -> Fut + Send + Sync + 'static,
408    Fut: Future<Output = ()> + Send + 'static,
409{
410    subscribe_account_diffs_many(rpc_endpoint, [account.to_string()], move |notification| {
411        on_notification(notification.notification)
412    })
413    .await
414}
415
416/// Subscribe to account diff notifications for many accounts over a single websocket.
417///
418/// All requested subscriptions must be acknowledged before this returns. Once the
419/// stream is live, any websocket disconnect is treated as a fatal completeness
420/// error instead of silently reconnecting and risking dropped notifications.
421pub async fn subscribe_account_diffs_many<F, Fut, I, S>(
422    rpc_endpoint: &str,
423    accounts: I,
424    on_notification: F,
425) -> Result<AccountDiffSubscriptionHandle, SubscriptionError>
426where
427    F: Fn(RoutedAccountDiffNotification) -> Fut + Send + Sync + 'static,
428    Fut: Future<Output = ()> + Send + 'static,
429    I: IntoIterator<Item = S>,
430    S: Into<String>,
431{
432    let ws_url = http_to_ws_url(rpc_endpoint)?;
433    let accounts = dedup_accounts(accounts);
434    if accounts.is_empty() {
435        let (stop_tx, stop_rx) = watch::channel(false);
436        return Ok(AccountDiffSubscriptionHandle {
437            join_handle: tokio::spawn(async move {
438                let _ = stop_rx;
439                Ok(())
440            }),
441            stop: stop_tx,
442        });
443    }
444
445    let (ready_tx, ready_rx) = oneshot::channel::<Result<(), SubscriptionError>>();
446    let (stop_tx, mut stop_rx) = watch::channel(false);
447    let target = format!("{} accounts", accounts.len());
448
449    let join_handle = tokio::spawn(async move {
450        let (notification_tx, mut notification_rx) = tokio::sync::mpsc::unbounded_channel();
451        let callback_handle = tokio::spawn(async move {
452            while let Some(notification) = notification_rx.recv().await {
453                on_notification(notification).await;
454            }
455        });
456
457        let (mut ws, _) = match tokio_tungstenite::connect_async(&ws_url).await {
458            Ok(connection) => connection,
459            Err(e) => {
460                let _ = ready_tx.send(Err(SubscriptionError::Connect {
461                    url: ws_url,
462                    source: Box::new(e),
463                }));
464                return Ok(());
465            }
466        };
467
468        let subscriptions =
469            match send_account_diff_subscribe_many(&mut ws, &accounts, &notification_tx).await {
470                Ok(subscriptions) => subscriptions,
471                Err(error) => {
472                    let _ = ready_tx.send(Err(error));
473                    return Ok(());
474                }
475            };
476
477        let _ = ready_tx.send(Ok(()));
478
479        if let Err(error) =
480            drive_account_diff_stream_many(&mut ws, &subscriptions, &notification_tx, &mut stop_rx)
481                .await
482        {
483            drop(notification_tx);
484            if let Err(source) = callback_handle.await {
485                return Err(callback_worker_failed("account diff", target, source));
486            }
487            return Err(error);
488        }
489
490        drop(notification_tx);
491        if let Err(source) = callback_handle.await {
492            return Err(callback_worker_failed("account diff", target, source));
493        }
494
495        Ok(())
496    });
497
498    match ready_rx.await {
499        Ok(Ok(())) => Ok(AccountDiffSubscriptionHandle {
500            join_handle,
501            stop: stop_tx,
502        }),
503        Ok(Err(e)) => {
504            join_handle.abort();
505            Err(e)
506        }
507        Err(_) => {
508            join_handle.abort();
509            Err(SubscriptionError::TaskDropped)
510        }
511    }
512}
513
514#[derive(Deserialize)]
515struct AccountDiffMessage {
516    method: String,
517    params: AccountDiffParams,
518}
519
520#[derive(Deserialize)]
521struct AccountDiffParams {
522    subscription: u64,
523    result: AccountDiffNotification,
524}
525
526async fn send_account_diff_subscribe_many(
527    ws: &mut AccountDiffWs,
528    accounts: &[String],
529    notification_tx: &tokio::sync::mpsc::UnboundedSender<RoutedAccountDiffNotification>,
530) -> Result<std::collections::HashMap<u64, String>, SubscriptionError> {
531    #[derive(Deserialize)]
532    struct SubscriptionConfirmation {
533        id: u64,
534        result: Option<u64>,
535    }
536
537    let mut pending: std::collections::HashMap<u64, String> = std::collections::HashMap::new();
538    let mut subscriptions = std::collections::HashMap::with_capacity(accounts.len());
539
540    for (index, account) in accounts.iter().enumerate() {
541        let request_id = (index + 1) as u64;
542        let req = serde_json::json!({
543            "jsonrpc": "2.0",
544            "id": request_id,
545            "method": "accountDiffSubscribe",
546            "params": [account]
547        });
548        ws.send(Message::Text(req.to_string()))
549            .await
550            .map_err(|source| SubscriptionError::Subscribe {
551                source: Box::new(source),
552            })?;
553        pending.insert(request_id, account.clone());
554    }
555
556    while !pending.is_empty() {
557        match ws.next().await {
558            Some(Ok(Message::Text(text))) => {
559                if let Ok(confirmation) = serde_json::from_str::<SubscriptionConfirmation>(&text) {
560                    let Some(account) = pending.remove(&confirmation.id) else {
561                        continue;
562                    };
563                    let Some(subscription_id) = confirmation.result else {
564                        return Err(SubscriptionError::TaskDropped);
565                    };
566                    subscriptions.insert(subscription_id, account);
567                    continue;
568                }
569
570                if let Some(notification) =
571                    parse_routed_account_diff_notification(&text, &subscriptions)
572                {
573                    let _ = notification_tx.send(notification);
574                }
575            }
576            Some(Ok(_)) => {}
577            _ => return Err(SubscriptionError::TaskDropped),
578        }
579    }
580
581    Ok(subscriptions)
582}
583
584async fn drive_account_diff_stream_many(
585    ws: &mut AccountDiffWs,
586    subscriptions: &std::collections::HashMap<u64, String>,
587    notification_tx: &tokio::sync::mpsc::UnboundedSender<RoutedAccountDiffNotification>,
588    stop_rx: &mut watch::Receiver<bool>,
589) -> Result<(), SubscriptionRuntimeError> {
590    loop {
591        if *stop_rx.borrow() {
592            let drain_deadline = tokio::time::Instant::now() + SUBSCRIPTION_DRAIN_MAX_DURATION;
593            loop {
594                match tokio::time::timeout_at(
595                    drain_deadline,
596                    tokio::time::timeout(SUBSCRIPTION_DRAIN_IDLE_TIMEOUT, ws.next()),
597                )
598                .await
599                {
600                    Ok(Ok(Some(Ok(Message::Text(text))))) => {
601                        if let Some(notification) =
602                            parse_routed_account_diff_notification(&text, subscriptions)
603                        {
604                            let _ = notification_tx.send(notification);
605                        }
606                    }
607                    _ => return Ok(()),
608                }
609            }
610        }
611
612        let msg = tokio::select! {
613            m = ws.next() => m,
614            _ = stop_rx.changed() => continue,
615        };
616
617        match msg {
618            Some(Ok(Message::Text(text))) => {
619                if let Some(notification) =
620                    parse_routed_account_diff_notification(&text, subscriptions)
621                {
622                    let _ = notification_tx.send(notification);
623                }
624            }
625            Some(Ok(_)) => {}
626            _ => {
627                return Err(subscription_runtime_closed(
628                    "account diff",
629                    format!("{} accounts", subscriptions.len()),
630                ));
631            }
632        }
633    }
634}
635
636fn parse_account_diff_message(text: &str) -> Option<AccountDiffMessage> {
637    let msg: AccountDiffMessage = serde_json::from_str(text).ok()?;
638    (msg.method == "accountDiffNotification").then_some(msg)
639}
640
641fn parse_routed_account_diff_notification(
642    text: &str,
643    subscriptions: &std::collections::HashMap<u64, String>,
644) -> Option<RoutedAccountDiffNotification> {
645    let msg = parse_account_diff_message(text)?;
646    let account = subscriptions.get(&msg.params.subscription)?.clone();
647    Some(RoutedAccountDiffNotification {
648        account,
649        notification: msg.params.result,
650    })
651}
652
653fn dedup_accounts<I, S>(accounts: I) -> Vec<String>
654where
655    I: IntoIterator<Item = S>,
656    S: Into<String>,
657{
658    let mut unique = std::collections::BTreeSet::new();
659    accounts
660        .into_iter()
661        .map(Into::into)
662        .filter(|account| unique.insert(account.clone()))
663        .collect()
664}
665
666// ── Program account diff subscription ────────────────────────────────────────
667
668/// Subscribe to account diff notifications for all accounts owned by a program.
669///
670/// Uses the server-side program filter (`{"address_type": "program"}`), so no
671/// RPC prefetch of program accounts is required.  The callback receives one
672/// [`AccountDiffNotification`] per changed account.
673///
674/// A websocket disconnect is treated as a fatal error — the handle's
675/// `join_handle` resolves with a [`SubscriptionRuntimeError`].
676///
677/// ## Example
678///
679/// ```no_run
680/// use simulator_client::subscribe_program_diffs;
681///
682/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
683/// let handle = subscribe_program_diffs(
684///     "http://localhost:8900/session/abc",
685///     "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
686///     |notification| async move {
687///         let account = notification.account.unwrap_or_default();
688///         println!("account={account} slot={}", notification.context.slot);
689///     },
690/// )
691/// .await?;
692///
693/// handle.stop.send(true).ok();
694/// handle.join_handle.await.ok();
695/// # Ok(())
696/// # }
697/// ```
698pub async fn subscribe_program_diffs<F, Fut>(
699    rpc_endpoint: &str,
700    program_id: &str,
701    on_notification: F,
702) -> Result<AccountDiffSubscriptionHandle, SubscriptionError>
703where
704    F: Fn(AccountDiffNotification) -> Fut + Send + Sync + 'static,
705    Fut: Future<Output = ()> + Send + 'static,
706{
707    let ws_url = http_to_ws_url(rpc_endpoint)?;
708    let program_id = program_id.to_string();
709
710    let (ready_tx, ready_rx) = oneshot::channel::<Result<(), SubscriptionError>>();
711    let (stop_tx, mut stop_rx) = watch::channel(false);
712
713    let join_handle = tokio::spawn(async move {
714        let (notification_tx, mut notification_rx) = tokio::sync::mpsc::unbounded_channel();
715        let callback_handle = tokio::spawn(async move {
716            while let Some(notification) = notification_rx.recv().await {
717                on_notification(notification).await;
718            }
719        });
720
721        let (mut ws, _) = match tokio_tungstenite::connect_async(&ws_url).await {
722            Ok(connection) => connection,
723            Err(e) => {
724                let _ = ready_tx.send(Err(SubscriptionError::Connect {
725                    url: ws_url,
726                    source: Box::new(e),
727                }));
728                return Ok(());
729            }
730        };
731
732        if let Err(error) = send_program_diff_subscribe(&mut ws, &program_id).await {
733            let _ = ready_tx.send(Err(error));
734            return Ok(());
735        }
736
737        let _ = ready_tx.send(Ok(()));
738
739        if let Err(error) =
740            drive_program_diff_stream(&mut ws, &notification_tx, &mut stop_rx, &program_id).await
741        {
742            drop(notification_tx);
743            if let Err(source) = callback_handle.await {
744                return Err(callback_worker_failed(
745                    "program account diff",
746                    &program_id,
747                    source,
748                ));
749            }
750            return Err(error);
751        }
752
753        drop(notification_tx);
754        if let Err(source) = callback_handle.await {
755            return Err(callback_worker_failed(
756                "program account diff",
757                &program_id,
758                source,
759            ));
760        }
761
762        Ok(())
763    });
764
765    match ready_rx.await {
766        Ok(Ok(())) => Ok(AccountDiffSubscriptionHandle {
767            join_handle,
768            stop: stop_tx,
769        }),
770        Ok(Err(e)) => {
771            join_handle.abort();
772            Err(e)
773        }
774        Err(_) => {
775            join_handle.abort();
776            Err(SubscriptionError::TaskDropped)
777        }
778    }
779}
780
781async fn send_program_diff_subscribe(
782    ws: &mut AccountDiffWs,
783    program_id: &str,
784) -> Result<(), SubscriptionError> {
785    #[derive(Deserialize)]
786    struct SubscriptionConfirmation {
787        result: Option<u64>,
788    }
789
790    let req = serde_json::json!({
791        "jsonrpc": "2.0",
792        "id": 1,
793        "method": "accountDiffSubscribe",
794        "params": [program_id, {"address_type": "program"}]
795    });
796    ws.send(Message::Text(req.to_string()))
797        .await
798        .map_err(|source| SubscriptionError::Subscribe {
799            source: Box::new(source),
800        })?;
801
802    loop {
803        match ws.next().await {
804            Some(Ok(Message::Text(text))) => {
805                match serde_json::from_str::<SubscriptionConfirmation>(&text) {
806                    Ok(SubscriptionConfirmation { result: Some(_) }) => return Ok(()),
807                    Ok(_) => continue,
808                    Err(source) => {
809                        return Err(SubscriptionError::Subscribe {
810                            source: Box::new(source),
811                        });
812                    }
813                }
814            }
815            Some(Ok(_)) => continue,
816            _ => return Err(SubscriptionError::TaskDropped),
817        }
818    }
819}
820
821async fn drive_program_diff_stream(
822    ws: &mut AccountDiffWs,
823    notification_tx: &tokio::sync::mpsc::UnboundedSender<AccountDiffNotification>,
824    stop_rx: &mut watch::Receiver<bool>,
825    program_id: &str,
826) -> Result<(), SubscriptionRuntimeError> {
827    loop {
828        if *stop_rx.borrow() {
829            let drain_deadline = tokio::time::Instant::now() + SUBSCRIPTION_DRAIN_MAX_DURATION;
830            loop {
831                match tokio::time::timeout_at(
832                    drain_deadline,
833                    tokio::time::timeout(SUBSCRIPTION_DRAIN_IDLE_TIMEOUT, ws.next()),
834                )
835                .await
836                {
837                    Ok(Ok(Some(Ok(Message::Text(text))))) => {
838                        if let Some(msg) = parse_account_diff_message(&text) {
839                            let _ = notification_tx.send(msg.params.result);
840                        }
841                    }
842                    _ => return Ok(()),
843                }
844            }
845        }
846
847        let msg = tokio::select! {
848            m = ws.next() => m,
849            _ = stop_rx.changed() => continue,
850        };
851
852        match msg {
853            Some(Ok(Message::Text(text))) => {
854                if let Some(msg) = parse_account_diff_message(&text) {
855                    let _ = notification_tx.send(msg.params.result);
856                }
857            }
858            Some(Ok(_)) => {}
859            _ => {
860                return Err(subscription_runtime_closed(
861                    "program account diff",
862                    program_id,
863                ));
864            }
865        }
866    }
867}
868
869// ── Keyless notification streams ─────────────────────────────────────────────
870
871/// The strings that distinguish one keyless notification stream from another.
872#[derive(Clone, Copy)]
873struct NotificationStream {
874    subscribe_method: &'static str,
875    notification_method: &'static str,
876    /// Singular label used in runtime errors.
877    kind: &'static str,
878    /// Plural label used in runtime errors.
879    target: &'static str,
880}
881
882const ACTION_STREAM: NotificationStream = NotificationStream {
883    subscribe_method: "actionSubscribe",
884    notification_method: "actionNotification",
885    kind: "action",
886    target: "actions",
887};
888
889const REROUTE_STREAM: NotificationStream = NotificationStream {
890    subscribe_method: "rerouteSubscribe",
891    notification_method: "rerouteNotification",
892    kind: "reroute",
893    target: "reroutes",
894};
895
896#[derive(Deserialize)]
897struct NotificationMessage<N> {
898    method: String,
899    params: NotificationParams<N>,
900}
901
902#[derive(Deserialize)]
903struct NotificationParams<N> {
904    #[allow(dead_code)]
905    subscription: u64,
906    result: N,
907}
908
909fn parse_notification_message<N: DeserializeOwned>(
910    text: &str,
911    notification_method: &str,
912) -> Option<N> {
913    let msg: NotificationMessage<N> = serde_json::from_str(text).ok()?;
914    (msg.method == notification_method).then_some(msg.params.result)
915}
916
917/// Drive a keyless notification stream, invoking `on_notification` for each payload.
918///
919/// Implements the protocol every single-stream subscription shares; once `stop` is set, drains
920/// whatever the server has already queued before resolving.
921async fn subscribe_notifications<N, F, Fut>(
922    rpc_endpoint: &str,
923    stream: NotificationStream,
924    on_notification: F,
925) -> Result<SubscriptionHandle, SubscriptionError>
926where
927    N: DeserializeOwned + Send + 'static,
928    F: Fn(N) -> Fut + Send + Sync + 'static,
929    Fut: Future<Output = ()> + Send + 'static,
930{
931    let ws_url = http_to_ws_url(rpc_endpoint)?;
932
933    let (ready_tx, ready_rx) = oneshot::channel::<Result<(), SubscriptionError>>();
934    let (stop_tx, mut stop_rx) = watch::channel(false);
935
936    let join_handle = tokio::spawn(async move {
937        let (notification_tx, mut notification_rx) = tokio::sync::mpsc::unbounded_channel();
938        let callback_handle = tokio::spawn(async move {
939            while let Some(notification) = notification_rx.recv().await {
940                on_notification(notification).await;
941            }
942        });
943
944        let (mut ws, _) = match tokio_tungstenite::connect_async(&ws_url).await {
945            Ok(connection) => connection,
946            Err(e) => {
947                let _ = ready_tx.send(Err(SubscriptionError::Connect {
948                    url: ws_url,
949                    source: Box::new(e),
950                }));
951                return Ok(());
952            }
953        };
954
955        if let Err(error) = send_stream_subscribe(&mut ws, stream.subscribe_method).await {
956            let _ = ready_tx.send(Err(error));
957            return Ok(());
958        }
959
960        let _ = ready_tx.send(Ok(()));
961
962        if let Err(error) =
963            drive_notification_stream::<N>(&mut ws, stream, &notification_tx, &mut stop_rx).await
964        {
965            drop(notification_tx);
966            if let Err(source) = callback_handle.await {
967                return Err(callback_worker_failed(stream.kind, stream.target, source));
968            }
969            return Err(error);
970        }
971
972        drop(notification_tx);
973        if let Err(source) = callback_handle.await {
974            return Err(callback_worker_failed(stream.kind, stream.target, source));
975        }
976
977        Ok(())
978    });
979
980    match ready_rx.await {
981        Ok(Ok(())) => Ok(SubscriptionHandle {
982            join_handle,
983            stop: stop_tx,
984        }),
985        Ok(Err(e)) => {
986            join_handle.abort();
987            Err(e)
988        }
989        Err(_) => {
990            join_handle.abort();
991            Err(SubscriptionError::TaskDropped)
992        }
993    }
994}
995
996async fn send_stream_subscribe(
997    ws: &mut AccountDiffWs,
998    subscribe_method: &str,
999) -> Result<(), SubscriptionError> {
1000    #[derive(Deserialize)]
1001    struct SubscriptionConfirmation {
1002        result: Option<u64>,
1003    }
1004
1005    // Keyless: index 0 is an unused placeholder, index 1 the (empty) config object.
1006    let req = serde_json::json!({
1007        "jsonrpc": "2.0",
1008        "id": 1,
1009        "method": subscribe_method,
1010        "params": [serde_json::Value::Null, {}]
1011    });
1012
1013    ws.send(Message::Text(req.to_string()))
1014        .await
1015        .map_err(|source| SubscriptionError::Subscribe {
1016            source: Box::new(source),
1017        })?;
1018
1019    loop {
1020        match ws.next().await {
1021            Some(Ok(Message::Text(text))) => {
1022                match serde_json::from_str::<SubscriptionConfirmation>(&text) {
1023                    Ok(SubscriptionConfirmation { result: Some(_) }) => return Ok(()),
1024                    Ok(_) => continue,
1025                    Err(source) => {
1026                        return Err(SubscriptionError::Subscribe {
1027                            source: Box::new(source),
1028                        });
1029                    }
1030                }
1031            }
1032            Some(Ok(_)) => continue,
1033            _ => return Err(SubscriptionError::TaskDropped),
1034        }
1035    }
1036}
1037
1038async fn drive_notification_stream<N: DeserializeOwned>(
1039    ws: &mut AccountDiffWs,
1040    stream: NotificationStream,
1041    notification_tx: &tokio::sync::mpsc::UnboundedSender<N>,
1042    stop_rx: &mut watch::Receiver<bool>,
1043) -> Result<(), SubscriptionRuntimeError> {
1044    loop {
1045        if *stop_rx.borrow() {
1046            let drain_deadline = tokio::time::Instant::now() + SUBSCRIPTION_DRAIN_MAX_DURATION;
1047            loop {
1048                match tokio::time::timeout_at(
1049                    drain_deadline,
1050                    tokio::time::timeout(SUBSCRIPTION_DRAIN_IDLE_TIMEOUT, ws.next()),
1051                )
1052                .await
1053                {
1054                    Ok(Ok(Some(Ok(Message::Text(text))))) => {
1055                        if let Some(result) =
1056                            parse_notification_message::<N>(&text, stream.notification_method)
1057                        {
1058                            let _ = notification_tx.send(result);
1059                        }
1060                    }
1061                    _ => return Ok(()),
1062                }
1063            }
1064        }
1065
1066        let msg = tokio::select! {
1067            m = ws.next() => m,
1068            _ = stop_rx.changed() => continue,
1069        };
1070
1071        match msg {
1072            Some(Ok(Message::Text(text))) => {
1073                if let Some(result) =
1074                    parse_notification_message::<N>(&text, stream.notification_method)
1075                {
1076                    let _ = notification_tx.send(result);
1077                }
1078            }
1079            Some(Ok(_)) => {}
1080            _ => return Err(subscription_runtime_closed(stream.kind, stream.target)),
1081        }
1082    }
1083}
1084
1085// ── Action subscription ──────────────────────────────────────────────────────
1086
1087/// Handle for a running scheduled-action subscription background task.
1088///
1089/// Send `true` on `stop` to request a clean shutdown, then await `join_handle`.
1090pub struct ActionSubscriptionHandle {
1091    pub join_handle: SubscriptionTaskHandle,
1092    pub stop: watch::Sender<bool>,
1093}
1094
1095/// Subscribe to scheduled-action results streamed over `actionSubscribe`.
1096///
1097/// The session automatically runs the `ScheduledAction`s registered at creation;
1098/// each result is delivered to `on_notification` as an [`ActionResultNotification`].
1099/// This is a single, keyless subscription — every action shares one stream, so the
1100/// callback distinguishes them via `action_index` / `label`.
1101///
1102/// A websocket disconnect is treated as a fatal error — the handle's `join_handle`
1103/// resolves with a [`SubscriptionRuntimeError`].
1104///
1105/// ## Example
1106///
1107/// ```no_run
1108/// use simulator_client::subscribe_actions;
1109///
1110/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1111/// let handle = subscribe_actions(
1112///     "http://localhost:8900/session/abc",
1113///     |result| async move {
1114///         println!("slot={} label={:?}", result.slot, result.label);
1115///     },
1116/// )
1117/// .await?;
1118///
1119/// handle.stop.send(true).ok();
1120/// handle.join_handle.await.ok();
1121/// # Ok(())
1122/// # }
1123/// ```
1124pub async fn subscribe_actions<F, Fut>(
1125    rpc_endpoint: &str,
1126    on_notification: F,
1127) -> Result<ActionSubscriptionHandle, SubscriptionError>
1128where
1129    F: Fn(ActionResultNotification) -> Fut + Send + Sync + 'static,
1130    Fut: Future<Output = ()> + Send + 'static,
1131{
1132    let SubscriptionHandle { join_handle, stop } =
1133        subscribe_notifications(rpc_endpoint, ACTION_STREAM, on_notification).await?;
1134    Ok(ActionSubscriptionHandle { join_handle, stop })
1135}
1136
1137// ── Reroute subscription ─────────────────────────────────────────────────────
1138
1139#[derive(Debug, Clone, Deserialize)]
1140pub struct RerouteContext {
1141    pub slot: u64,
1142}
1143
1144/// One re-quoted swap within a rerouted transaction's simulation.
1145#[derive(Debug, Clone, Deserialize)]
1146#[serde(rename_all = "camelCase")]
1147pub struct RerouteLegNotification {
1148    pub input_mint: String,
1149    pub output_mint: String,
1150    pub amount: u64,
1151    pub swap_mode: String,
1152    pub original_quoted_out: u64,
1153    pub metis_quoted_out: u64,
1154    pub route_summary: String,
1155    /// The raw metis `routePlan` JSON array (per-hop mints, pool `ammKey`, amounts, split
1156    /// percent), when metis returned one.
1157    #[serde(default)]
1158    pub route_plan: Option<String>,
1159}
1160
1161/// One rerouted transaction's simulation result, delivered by `rerouteSubscribe`. The historical
1162/// original still executed; this is the counterfactual, committed nowhere.
1163#[derive(Debug, Clone, Deserialize)]
1164#[serde(rename_all = "camelCase")]
1165pub struct RerouteNotification {
1166    pub context: RerouteContext,
1167    pub slot: u64,
1168    /// Batch within the slot the original sits in; the simulation ran just before it executed.
1169    pub batch_index: u32,
1170    /// Base58 signature of the historical transaction whose swap(s) were re-routed.
1171    pub original_signature: String,
1172    /// One per detected swap in the original, in order.
1173    #[serde(default)]
1174    pub legs: Vec<RerouteLegNotification>,
1175    /// The metis-routed transaction that was simulated. Decode with [`EncodedBinary::decode`]
1176    /// (base64 bincode of `TxWithMeta`) to inspect its instructions.
1177    pub routed_transaction: EncodedBinary,
1178    /// Simulation error message; `None` on success.
1179    #[serde(default)]
1180    pub err: Option<String>,
1181    #[serde(default)]
1182    pub logs: Vec<String>,
1183    pub compute_units_consumed: u64,
1184    #[serde(default)]
1185    pub fee: Option<u64>,
1186    /// What the routed swap actually produced; `None` for a multi-swap tx, a missing output
1187    /// balance record, or a reverted simulation.
1188    #[serde(default)]
1189    pub realized_output_amount: Option<u64>,
1190    /// What the historical original actually produced; `None` for a multi-swap tx, a missing
1191    /// output balance record, or a reverted original. Set independently of the simulation's
1192    /// outcome, so it is present even when `err` is.
1193    #[serde(default)]
1194    pub original_realized_output_amount: Option<u64>,
1195}
1196
1197/// Subscribe to simulated-reroute results streamed over `rerouteSubscribe`.
1198///
1199/// Each simulation is delivered to `on_notification` as a [`RerouteNotification`]. This is a
1200/// single, keyless subscription — every result shares one stream, distinguished by
1201/// `slot` / `batch_index` / `original_signature`.
1202///
1203/// A websocket disconnect is treated as a fatal error — the handle's `join_handle` resolves with
1204/// a [`SubscriptionRuntimeError`].
1205///
1206/// ## Example
1207///
1208/// ```no_run
1209/// use simulator_client::subscribe_reroutes;
1210///
1211/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1212/// let handle = subscribe_reroutes(
1213///     "http://localhost:8900/session/abc",
1214///     |result| async move {
1215///         println!("slot={} sig={}", result.slot, result.original_signature);
1216///     },
1217/// )
1218/// .await?;
1219///
1220/// handle.stop.send(true).ok();
1221/// handle.join_handle.await.ok();
1222/// # Ok(())
1223/// # }
1224/// ```
1225pub async fn subscribe_reroutes<F, Fut>(
1226    rpc_endpoint: &str,
1227    on_notification: F,
1228) -> Result<SubscriptionHandle, SubscriptionError>
1229where
1230    F: Fn(RerouteNotification) -> Fut + Send + Sync + 'static,
1231    Fut: Future<Output = ()> + Send + 'static,
1232{
1233    subscribe_notifications(rpc_endpoint, REROUTE_STREAM, on_notification).await
1234}
1235
1236#[cfg(test)]
1237mod tests {
1238    use super::*;
1239
1240    #[test]
1241    fn parse_account_diff_notification_ignores_other_messages() {
1242        let confirmation = r#"{"jsonrpc":"2.0","result":1,"id":1}"#;
1243        assert!(parse_account_diff_message(confirmation).is_none());
1244    }
1245
1246    #[test]
1247    fn parse_account_diff_notification_extracts_payload() {
1248        let text = r#"{
1249            "jsonrpc":"2.0",
1250            "method":"accountDiffNotification",
1251            "params":{
1252                "subscription":7,
1253                "result":{
1254                    "context":{"slot":123},
1255                    "signature":"sig",
1256                    "pre":{"a":1},
1257                    "post":{"a":2}
1258                }
1259            }
1260        }"#;
1261
1262        let notification = parse_account_diff_message(text)
1263            .expect("notification")
1264            .params
1265            .result;
1266        assert_eq!(notification.context.slot, 123);
1267        assert_eq!(notification.signature.as_deref(), Some("sig"));
1268        assert_eq!(notification.pre, Some(serde_json::json!({"a": 1})));
1269        assert_eq!(notification.post, Some(serde_json::json!({"a": 2})));
1270    }
1271
1272    #[test]
1273    fn parse_routed_account_diff_notification_extracts_subscription_account() {
1274        let text = r#"{
1275            "jsonrpc":"2.0",
1276            "method":"accountDiffNotification",
1277            "params":{
1278                "subscription":42,
1279                "result":{
1280                    "context":{"slot":456},
1281                    "signature":"sig",
1282                    "pre":null,
1283                    "post":{"a":2}
1284                }
1285            }
1286        }"#;
1287        let subscriptions = std::collections::HashMap::from([(42_u64, "acct".to_string())]);
1288
1289        let notification =
1290            parse_routed_account_diff_notification(text, &subscriptions).expect("notification");
1291        assert_eq!(notification.account, "acct");
1292        assert_eq!(notification.notification.context.slot, 456);
1293    }
1294
1295    #[test]
1296    fn dedup_accounts_preserves_first_seen_order() {
1297        let accounts = dedup_accounts([
1298            "b".to_string(),
1299            "a".to_string(),
1300            "b".to_string(),
1301            "c".to_string(),
1302        ]);
1303        assert_eq!(accounts, vec!["b", "a", "c"]);
1304    }
1305}