Skip to main content

simulator_client/
subscriptions.rs

1use std::{future::Future, time::Duration};
2
3use futures::{SinkExt, StreamExt};
4use serde::Deserialize;
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// ── Action subscription ──────────────────────────────────────────────────────
870
871/// Handle for a running scheduled-action subscription background task.
872///
873/// Send `true` on `stop` to request a clean shutdown, then await `join_handle`.
874pub struct ActionSubscriptionHandle {
875    pub join_handle: SubscriptionTaskHandle,
876    pub stop: watch::Sender<bool>,
877}
878
879#[derive(Deserialize)]
880struct ActionMessage {
881    method: String,
882    params: ActionParams,
883}
884
885#[derive(Deserialize)]
886struct ActionParams {
887    #[allow(dead_code)]
888    subscription: u64,
889    result: ActionResultNotification,
890}
891
892fn parse_action_message(text: &str) -> Option<ActionResultNotification> {
893    let msg: ActionMessage = serde_json::from_str(text).ok()?;
894    (msg.method == "actionNotification").then_some(msg.params.result)
895}
896
897/// Subscribe to scheduled-action results streamed over `actionSubscribe`.
898///
899/// The session automatically runs the `ScheduledAction`s registered at creation;
900/// each result is delivered to `on_notification` as an [`ActionResultNotification`].
901/// This is a single, keyless subscription — every action shares one stream, so the
902/// callback distinguishes them via `action_index` / `label`.
903///
904/// A websocket disconnect is treated as a fatal error — the handle's `join_handle`
905/// resolves with a [`SubscriptionRuntimeError`].
906///
907/// ## Example
908///
909/// ```no_run
910/// use simulator_client::subscribe_actions;
911///
912/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
913/// let handle = subscribe_actions(
914///     "http://localhost:8900/session/abc",
915///     |result| async move {
916///         println!("slot={} label={:?}", result.slot, result.label);
917///     },
918/// )
919/// .await?;
920///
921/// handle.stop.send(true).ok();
922/// handle.join_handle.await.ok();
923/// # Ok(())
924/// # }
925/// ```
926pub async fn subscribe_actions<F, Fut>(
927    rpc_endpoint: &str,
928    on_notification: F,
929) -> Result<ActionSubscriptionHandle, SubscriptionError>
930where
931    F: Fn(ActionResultNotification) -> Fut + Send + Sync + 'static,
932    Fut: Future<Output = ()> + Send + 'static,
933{
934    let ws_url = http_to_ws_url(rpc_endpoint)?;
935
936    let (ready_tx, ready_rx) = oneshot::channel::<Result<(), SubscriptionError>>();
937    let (stop_tx, mut stop_rx) = watch::channel(false);
938
939    let join_handle = tokio::spawn(async move {
940        let (notification_tx, mut notification_rx) = tokio::sync::mpsc::unbounded_channel();
941        let callback_handle = tokio::spawn(async move {
942            while let Some(notification) = notification_rx.recv().await {
943                on_notification(notification).await;
944            }
945        });
946
947        let (mut ws, _) = match tokio_tungstenite::connect_async(&ws_url).await {
948            Ok(connection) => connection,
949            Err(e) => {
950                let _ = ready_tx.send(Err(SubscriptionError::Connect {
951                    url: ws_url,
952                    source: Box::new(e),
953                }));
954                return Ok(());
955            }
956        };
957
958        if let Err(error) = send_action_subscribe(&mut ws).await {
959            let _ = ready_tx.send(Err(error));
960            return Ok(());
961        }
962
963        let _ = ready_tx.send(Ok(()));
964
965        if let Err(error) = drive_action_stream(&mut ws, &notification_tx, &mut stop_rx).await {
966            drop(notification_tx);
967            if let Err(source) = callback_handle.await {
968                return Err(callback_worker_failed("action", "actions", source));
969            }
970            return Err(error);
971        }
972
973        drop(notification_tx);
974        if let Err(source) = callback_handle.await {
975            return Err(callback_worker_failed("action", "actions", source));
976        }
977
978        Ok(())
979    });
980
981    match ready_rx.await {
982        Ok(Ok(())) => Ok(ActionSubscriptionHandle {
983            join_handle,
984            stop: stop_tx,
985        }),
986        Ok(Err(e)) => {
987            join_handle.abort();
988            Err(e)
989        }
990        Err(_) => {
991            join_handle.abort();
992            Err(SubscriptionError::TaskDropped)
993        }
994    }
995}
996
997async fn send_action_subscribe(ws: &mut AccountDiffWs) -> Result<(), SubscriptionError> {
998    #[derive(Deserialize)]
999    struct SubscriptionConfirmation {
1000        result: Option<u64>,
1001    }
1002
1003    // Keyless: index 0 is an unused placeholder, index 1 the (empty) config object.
1004    let req = serde_json::json!({
1005        "jsonrpc": "2.0",
1006        "id": 1,
1007        "method": "actionSubscribe",
1008        "params": [serde_json::Value::Null, {}]
1009    });
1010
1011    ws.send(Message::Text(req.to_string()))
1012        .await
1013        .map_err(|source| SubscriptionError::Subscribe {
1014            source: Box::new(source),
1015        })?;
1016
1017    loop {
1018        match ws.next().await {
1019            Some(Ok(Message::Text(text))) => {
1020                match serde_json::from_str::<SubscriptionConfirmation>(&text) {
1021                    Ok(SubscriptionConfirmation { result: Some(_) }) => return Ok(()),
1022                    Ok(_) => continue,
1023                    Err(source) => {
1024                        return Err(SubscriptionError::Subscribe {
1025                            source: Box::new(source),
1026                        });
1027                    }
1028                }
1029            }
1030            Some(Ok(_)) => continue,
1031            _ => return Err(SubscriptionError::TaskDropped),
1032        }
1033    }
1034}
1035
1036async fn drive_action_stream(
1037    ws: &mut AccountDiffWs,
1038    notification_tx: &tokio::sync::mpsc::UnboundedSender<ActionResultNotification>,
1039    stop_rx: &mut watch::Receiver<bool>,
1040) -> Result<(), SubscriptionRuntimeError> {
1041    loop {
1042        if *stop_rx.borrow() {
1043            let drain_deadline = tokio::time::Instant::now() + SUBSCRIPTION_DRAIN_MAX_DURATION;
1044            loop {
1045                match tokio::time::timeout_at(
1046                    drain_deadline,
1047                    tokio::time::timeout(SUBSCRIPTION_DRAIN_IDLE_TIMEOUT, ws.next()),
1048                )
1049                .await
1050                {
1051                    Ok(Ok(Some(Ok(Message::Text(text))))) => {
1052                        if let Some(result) = parse_action_message(&text) {
1053                            let _ = notification_tx.send(result);
1054                        }
1055                    }
1056                    _ => return Ok(()),
1057                }
1058            }
1059        }
1060
1061        let msg = tokio::select! {
1062            m = ws.next() => m,
1063            _ = stop_rx.changed() => continue,
1064        };
1065
1066        match msg {
1067            Some(Ok(Message::Text(text))) => {
1068                if let Some(result) = parse_action_message(&text) {
1069                    let _ = notification_tx.send(result);
1070                }
1071            }
1072            Some(Ok(_)) => {}
1073            _ => return Err(subscription_runtime_closed("action", "actions")),
1074        }
1075    }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use super::*;
1081
1082    #[test]
1083    fn parse_account_diff_notification_ignores_other_messages() {
1084        let confirmation = r#"{"jsonrpc":"2.0","result":1,"id":1}"#;
1085        assert!(parse_account_diff_message(confirmation).is_none());
1086    }
1087
1088    #[test]
1089    fn parse_account_diff_notification_extracts_payload() {
1090        let text = r#"{
1091            "jsonrpc":"2.0",
1092            "method":"accountDiffNotification",
1093            "params":{
1094                "subscription":7,
1095                "result":{
1096                    "context":{"slot":123},
1097                    "signature":"sig",
1098                    "pre":{"a":1},
1099                    "post":{"a":2}
1100                }
1101            }
1102        }"#;
1103
1104        let notification = parse_account_diff_message(text)
1105            .expect("notification")
1106            .params
1107            .result;
1108        assert_eq!(notification.context.slot, 123);
1109        assert_eq!(notification.signature.as_deref(), Some("sig"));
1110        assert_eq!(notification.pre, Some(serde_json::json!({"a": 1})));
1111        assert_eq!(notification.post, Some(serde_json::json!({"a": 2})));
1112    }
1113
1114    #[test]
1115    fn parse_routed_account_diff_notification_extracts_subscription_account() {
1116        let text = r#"{
1117            "jsonrpc":"2.0",
1118            "method":"accountDiffNotification",
1119            "params":{
1120                "subscription":42,
1121                "result":{
1122                    "context":{"slot":456},
1123                    "signature":"sig",
1124                    "pre":null,
1125                    "post":{"a":2}
1126                }
1127            }
1128        }"#;
1129        let subscriptions = std::collections::HashMap::from([(42_u64, "acct".to_string())]);
1130
1131        let notification =
1132            parse_routed_account_diff_notification(text, &subscriptions).expect("notification");
1133        assert_eq!(notification.account, "acct");
1134        assert_eq!(notification.notification.context.slot, 456);
1135    }
1136
1137    #[test]
1138    fn dedup_accounts_preserves_first_seen_order() {
1139        let accounts = dedup_accounts([
1140            "b".to_string(),
1141            "a".to_string(),
1142            "b".to_string(),
1143            "c".to_string(),
1144        ]);
1145        assert_eq!(accounts, vec!["b", "a", "c"]);
1146    }
1147}