Browse Source

pubsub: consolidate into Spec, adopt Arc<SubscriptionId>, and wire through wallet/mint/WS/FFI

Refactor the pub/sub engine to a single Spec trait, move Event alongside it,
and propagate Arc-backed subscription IDs across the stack. This simplifies
generics, clarifies responsibilities, and preserves coalescing +
latest-on-subscribe semantics.

-   **Single source of truth:** `Spec` owns `Topic`, `Event`, `SubscriptionId`,
    `Context`, new_instance, and fetch_events.
-   **Lean & explicit API:** Remove Topic trait split;
    `Subscriber::send(Event)` carries sub-ID internally.
-   **Performance/ergonomics:** `Arc<SubscriptionId>` avoids heavy clones and
    makes channel/task hops trivial.

-   Introduce `pub_sub/typ.rs` with:
    -   trait `Spec`
    -   trait `Event` colocated with Spec.
-   Remove `pub_sub/event.rs` fold `Event` into `typ.rs`.
-   Make `Pubsub<S>` generic over `Spec` and store `Arc<S>`.
-   The subscriber holds `Arc<SubscriptionId>` and deduplicates the latest
    entry per subscription.
-   SubscriptionRequest: rename SubscriptionName → SubscriptionId; return
    `Arc<...>` from `subscription_name()`.
-   Remote consumer (Transport) now parameterized by `Spec`; control types
    updated:
    -   `StreamCtrl<S>`, `SubscribeMessage<S>`, internal caches keyed by
        `S::Topic`.
-   Mint/wallet:
    -   Mint: `MintPubSubSpec` (Context = `DynMintDatabase`),
        `PubSubManager(Pubsub<MintPubSubSpec>)`.
    -   Wallet: lightweight MintSubTopics Spec with `Context = ()`.
-   IDs go Arc end-to-end:
    -   cdk-axum WS maps `HashMap<Arc<SubId>, JoinHandle<()>>`, publisher sends
        `(Arc<SubId>, NotificationPayload)`.
    -   `subscription::{Params, WalletParams}` now use `Arc<...>`.
    -   cdk-ffi conversions & wallet glue updated.
    -   Integration tests updated for new types.

-   Coalescing unchanged: multiple local subs to the same topic are combined
    into a single remote sub.
-   Backfill via `Spec::fetch_events(topics, Subscriber)`; Subscriber enforces
    latest-only dedupe per subscription.

**Result:** a slimmer, more maintainable pub/sub core that’s easier to embed
across mint, wallet, transports, and FFI without sacrificing performance or
semantics.
Cesar Rodas 10 months ago
parent
commit
7537579078
38 changed files with 2514 additions and 1922 deletions
  1. 1 1
      Cargo.toml
  2. 2 2
      crates/cashu/Cargo.toml
  3. 0 1
      crates/cashu/src/lib.rs
  4. 19 31
      crates/cashu/src/nuts/nut17/mod.rs
  5. 4 1
      crates/cashu/src/nuts/nut17/ws.rs
  6. 4 3
      crates/cdk-axum/src/ws/mod.rs
  7. 4 6
      crates/cdk-axum/src/ws/subscribe.rs
  8. 5 0
      crates/cdk-common/Cargo.toml
  9. 52 0
      crates/cdk-common/src/pub_sub/error.rs
  10. 0 161
      crates/cdk-common/src/pub_sub/index.rs
  11. 153 58
      crates/cdk-common/src/pub_sub/mod.rs
  12. 184 0
      crates/cdk-common/src/pub_sub/pubsub.rs
  13. 961 0
      crates/cdk-common/src/pub_sub/remote_consumer.rs
  14. 159 0
      crates/cdk-common/src/pub_sub/subscriber.rs
  15. 80 0
      crates/cdk-common/src/pub_sub/types.rs
  16. 91 74
      crates/cdk-common/src/subscription.rs
  17. 3 1
      crates/cdk-common/src/ws.rs
  18. 1 2
      crates/cdk-ffi/Cargo.toml
  19. 12 20
      crates/cdk-ffi/src/types/subscription.rs
  20. 1 1
      crates/cdk-ffi/src/wallet.rs
  21. 10 14
      crates/cdk-integration-tests/tests/integration_tests_pure.rs
  22. 2 2
      crates/cdk-integration-tests/tests/regtest.rs
  23. 0 2
      crates/cdk/Cargo.toml
  24. 127 0
      crates/cdk/src/event.rs
  25. 9 8
      crates/cdk/src/lib.rs
  26. 2 2
      crates/cdk/src/mint/issue/mod.rs
  27. 2 2
      crates/cdk/src/mint/mod.rs
  28. 244 0
      crates/cdk/src/mint/subscription.rs
  29. 0 292
      crates/cdk/src/mint/subscription/manager.rs
  30. 0 12
      crates/cdk/src/mint/subscription/mod.rs
  31. 0 119
      crates/cdk/src/mint/subscription/on_subscription.rs
  32. 0 339
      crates/cdk/src/pub_sub.rs
  33. 22 20
      crates/cdk/src/wallet/mod.rs
  34. 3 2
      crates/cdk/src/wallet/multi_mint_wallet.rs
  35. 3 2
      crates/cdk/src/wallet/streams/payment.rs
  36. 0 238
      crates/cdk/src/wallet/subscription/http.rs
  37. 233 271
      crates/cdk/src/wallet/subscription/mod.rs
  38. 121 235
      crates/cdk/src/wallet/subscription/ws.rs

+ 1 - 1
Cargo.toml

@@ -70,7 +70,7 @@ futures = { version = "0.3.28", default-features = false, features = ["async-awa
 lightning-invoice = { version = "0.33.0", features = ["serde", "std"] }
 lightning = { version = "0.1.2", default-features = false, features = ["std"]}
 ldk-node = "0.6.2"
-serde = { version = "1", features = ["derive"] }
+serde = { version = "1", features = ["derive", "rc"] }
 serde_json = "1"
 thiserror = { version = "2" }
 tokio = { version = "1", default-features = false, features = ["rt", "macros", "test-util", "sync"] }

+ 2 - 2
crates/cashu/Cargo.toml

@@ -13,13 +13,13 @@ readme = "README.md"
 [features]
 default = ["mint", "wallet", "auth"]
 swagger = ["dep:utoipa"]
-mint = ["dep:uuid"]
+mint = []
 wallet = []
 auth = ["dep:strum", "dep:strum_macros", "dep:regex"]
 bench = []
 
 [dependencies]
-uuid = { workspace = true, optional = true }
+uuid.workspace = true
 bitcoin.workspace = true
 cbor-diag.workspace = true
 ciborium.workspace = true

+ 0 - 1
crates/cashu/src/lib.rs

@@ -16,7 +16,6 @@ pub use self::mint_url::MintUrl;
 pub use self::nuts::*;
 pub use self::util::SECP256K1;
 
-#[cfg(feature = "mint")]
 pub mod quote_id;
 
 #[doc(hidden)]

+ 19 - 31
crates/cashu/src/nuts/nut17/mod.rs

@@ -2,13 +2,11 @@
 use serde::de::DeserializeOwned;
 use serde::{Deserialize, Serialize};
 
-#[cfg(feature = "mint")]
 use super::PublicKey;
 use crate::nuts::{
     CurrencyUnit, MeltQuoteBolt11Response, MintQuoteBolt11Response, PaymentMethod, ProofState,
 };
-#[cfg(feature = "mint")]
-use crate::quote_id::{QuoteId, QuoteIdError};
+use crate::quote_id::QuoteIdError;
 use crate::MintQuoteBolt12Response;
 
 pub mod ws;
@@ -109,7 +107,10 @@ pub enum WsCommand {
     ProofState,
 }
 
-impl<T> From<MintQuoteBolt12Response<T>> for NotificationPayload<T> {
+impl<T> From<MintQuoteBolt12Response<T>> for NotificationPayload<T>
+where
+    T: Clone,
+{
     fn from(mint_quote: MintQuoteBolt12Response<T>) -> NotificationPayload<T> {
         NotificationPayload::MintQuoteBolt12Response(mint_quote)
     }
@@ -119,7 +120,10 @@ impl<T> From<MintQuoteBolt12Response<T>> for NotificationPayload<T> {
 #[serde(bound = "T: Serialize + DeserializeOwned")]
 #[serde(untagged)]
 /// Subscription response
-pub enum NotificationPayload<T> {
+pub enum NotificationPayload<T>
+where
+    T: Clone,
+{
     /// Proof State
     ProofState(ProofState),
     /// Melt Quote Bolt11 Response
@@ -130,38 +134,23 @@ pub enum NotificationPayload<T> {
     MintQuoteBolt12Response(MintQuoteBolt12Response<T>),
 }
 
-impl<T> From<ProofState> for NotificationPayload<T> {
-    fn from(proof_state: ProofState) -> NotificationPayload<T> {
-        NotificationPayload::ProofState(proof_state)
-    }
-}
-
-impl<T> From<MeltQuoteBolt11Response<T>> for NotificationPayload<T> {
-    fn from(melt_quote: MeltQuoteBolt11Response<T>) -> NotificationPayload<T> {
-        NotificationPayload::MeltQuoteBolt11Response(melt_quote)
-    }
-}
-
-impl<T> From<MintQuoteBolt11Response<T>> for NotificationPayload<T> {
-    fn from(mint_quote: MintQuoteBolt11Response<T>) -> NotificationPayload<T> {
-        NotificationPayload::MintQuoteBolt11Response(mint_quote)
-    }
-}
-
-#[cfg(feature = "mint")]
-#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Hash, Serialize)]
+#[serde(bound = "T: Serialize + DeserializeOwned")]
 /// A parsed notification
-pub enum Notification {
+pub enum NotificationId<T>
+where
+    T: Clone,
+{
     /// ProofState id is a Pubkey
     ProofState(PublicKey),
     /// MeltQuote id is an QuoteId
-    MeltQuoteBolt11(QuoteId),
+    MeltQuoteBolt11(T),
     /// MintQuote id is an QuoteId
-    MintQuoteBolt11(QuoteId),
+    MintQuoteBolt11(T),
     /// MintQuote id is an QuoteId
-    MintQuoteBolt12(QuoteId),
+    MintQuoteBolt12(T),
     /// MintQuote id is an QuoteId
-    MeltQuoteBolt12(QuoteId),
+    MeltQuoteBolt12(T),
 }
 
 /// Kind
@@ -187,7 +176,6 @@ impl<I> AsRef<I> for Params<I> {
 /// Parsing error
 #[derive(thiserror::Error, Debug)]
 pub enum Error {
-    #[cfg(feature = "mint")]
     #[error("Uuid Error: {0}")]
     /// Uuid Error
     QuoteId(#[from] QuoteIdError),

+ 4 - 1
crates/cashu/src/nuts/nut17/ws.rs

@@ -36,7 +36,10 @@ pub struct WsUnsubscribeResponse<I> {
 /// subscription
 #[derive(Debug, Clone, Serialize, Deserialize)]
 #[serde(bound = "T: Serialize + DeserializeOwned, I: Serialize + DeserializeOwned")]
-pub struct NotificationInner<T, I> {
+pub struct NotificationInner<T, I>
+where
+    T: Clone,
+{
     /// The subscription ID
     #[serde(rename = "subId")]
     pub sub_id: I,

+ 4 - 3
crates/cdk-axum/src/ws/mod.rs

@@ -1,9 +1,10 @@
 use std::collections::HashMap;
+use std::sync::Arc;
 
 use axum::extract::ws::{CloseFrame, Message, WebSocket};
 use cdk::mint::QuoteId;
 use cdk::nuts::nut17::NotificationPayload;
-use cdk::pub_sub::SubId;
+use cdk::subscription::SubId;
 use cdk::ws::{
     notification_to_ws_message, NotificationInner, WsErrorBody, WsMessageOrResponse,
     WsMethodRequest, WsRequest,
@@ -36,8 +37,8 @@ pub use error::WsError;
 
 pub struct WsContext {
     state: MintState,
-    subscriptions: HashMap<SubId, tokio::task::JoinHandle<()>>,
-    publisher: mpsc::Sender<(SubId, NotificationPayload<QuoteId>)>,
+    subscriptions: HashMap<Arc<SubId>, tokio::task::JoinHandle<()>>,
+    publisher: mpsc::Sender<(Arc<SubId>, NotificationPayload<QuoteId>)>,
 }
 
 /// Main function for websocket connections

+ 4 - 6
crates/cdk-axum/src/ws/subscribe.rs

@@ -1,4 +1,4 @@
-use cdk::subscription::{IndexableParams, Params};
+use cdk::subscription::Params;
 use cdk::ws::{WsResponseResult, WsSubscribeResponse};
 
 use super::{WsContext, WsError};
@@ -15,22 +15,20 @@ pub(crate) async fn handle(
         return Err(WsError::InvalidParams);
     }
 
-    let params: IndexableParams = params.into();
-
     let mut subscription = context
         .state
         .mint
         .pubsub_manager()
-        .try_subscribe(params)
-        .await
+        .subscribe(params)
         .map_err(|_| WsError::ParseError)?;
 
     let publisher = context.publisher.clone();
+    let sub_id_for_sender = sub_id.clone();
     context.subscriptions.insert(
         sub_id.clone(),
         tokio::spawn(async move {
             while let Some(response) = subscription.recv().await {
-                let _ = publisher.send(response).await;
+                let _ = publisher.try_send((sub_id_for_sender.clone(), response.into_inner()));
             }
         }),
     );

+ 5 - 0
crates/cdk-common/Cargo.toml

@@ -40,10 +40,15 @@ anyhow.workspace = true
 serde_json.workspace = true
 serde_with.workspace = true
 web-time.workspace = true
+tokio.workspace = true
 
 [target.'cfg(target_arch = "wasm32")'.dependencies]
 uuid = { workspace = true, features = ["js"], optional = true }
+getrandom = { version = "0.2", features = ["js"] }
+wasm-bindgen = "0.2"
+wasm-bindgen-futures = "0.4"
 
 [dev-dependencies]
 rand.workspace = true
 bip39.workspace = true
+wasm-bindgen-test = "0.3"

+ 52 - 0
crates/cdk-common/src/pub_sub/error.rs

@@ -0,0 +1,52 @@
+//! Error types for the pub-sub module.
+
+use tokio::sync::mpsc::error::TrySendError;
+
+#[derive(thiserror::Error, Debug)]
+/// Error
+pub enum Error {
+    /// Poison locked
+    #[error("Poisoned lock")]
+    Poison,
+
+    /// Already subscribed
+    #[error("Already subscribed")]
+    AlreadySubscribed,
+
+    /// No subscription found
+    #[error("Subscription not found")]
+    NoSubscription,
+
+    /// Parsing error
+    #[error("Parsing Error {0}")]
+    ParsingError(String),
+
+    /// Internal error
+    #[error("Internal")]
+    Internal(Box<dyn std::error::Error + Send + Sync>),
+
+    /// Internal error
+    #[error("Internal error {0}")]
+    InternalStr(String),
+
+    /// Not supported
+    #[error("Not supported")]
+    NotSupported,
+
+    /// Channel is full
+    #[error("Channel is full")]
+    ChannelFull,
+
+    /// Channel is closed
+    #[error("Channel is close")]
+    ChannelClosed,
+}
+
+impl<T> From<TrySendError<T>> for Error {
+    fn from(value: TrySendError<T>) -> Self {
+        match value {
+            TrySendError::Closed(_) => Error::ChannelClosed,
+            TrySendError::Full(_) => Error::ChannelFull,
+        }
+    }
+}

+ 0 - 161
crates/cdk-common/src/pub_sub/index.rs

@@ -1,161 +0,0 @@
-//! WS Index
-
-use std::fmt::Debug;
-use std::ops::Deref;
-use std::sync::atomic::{AtomicUsize, Ordering};
-
-use super::SubId;
-
-/// Indexable trait
-pub trait Indexable {
-    /// The type of the index, it is unknown and it is up to the Manager's
-    /// generic type
-    type Type: PartialOrd + Ord + Send + Sync + Debug;
-
-    /// To indexes
-    fn to_indexes(&self) -> Vec<Index<Self::Type>>;
-}
-
-#[derive(Debug, Ord, PartialOrd, PartialEq, Eq, Clone)]
-/// Index
-///
-/// The Index is a sorted structure that is used to quickly find matches
-///
-/// The counter is used to make sure each Index is unique, even if the prefix
-/// are the same, and also to make sure that earlier indexes matches first
-pub struct Index<T>
-where
-    T: PartialOrd + Ord + Send + Sync + Debug,
-{
-    prefix: T,
-    counter: SubscriptionGlobalId,
-    id: super::SubId,
-}
-
-impl<T> From<&Index<T>> for super::SubId
-where
-    T: PartialOrd + Ord + Send + Sync + Debug,
-{
-    fn from(val: &Index<T>) -> Self {
-        val.id.clone()
-    }
-}
-
-impl<T> Deref for Index<T>
-where
-    T: PartialOrd + Ord + Send + Sync + Debug,
-{
-    type Target = T;
-
-    fn deref(&self) -> &Self::Target {
-        &self.prefix
-    }
-}
-
-impl<T> Index<T>
-where
-    T: PartialOrd + Ord + Send + Sync + Debug,
-{
-    /// Compare the
-    pub fn cmp_prefix(&self, other: &Index<T>) -> std::cmp::Ordering {
-        self.prefix.cmp(&other.prefix)
-    }
-
-    /// Returns a globally unique id for the Index
-    pub fn unique_id(&self) -> usize {
-        self.counter.0
-    }
-}
-
-impl<T> From<(T, SubId, SubscriptionGlobalId)> for Index<T>
-where
-    T: PartialOrd + Ord + Send + Sync + Debug,
-{
-    fn from((prefix, id, counter): (T, SubId, SubscriptionGlobalId)) -> Self {
-        Self {
-            prefix,
-            id,
-            counter,
-        }
-    }
-}
-
-impl<T> From<(T, SubId)> for Index<T>
-where
-    T: PartialOrd + Ord + Send + Sync + Debug,
-{
-    fn from((prefix, id): (T, SubId)) -> Self {
-        Self {
-            prefix,
-            id,
-            counter: Default::default(),
-        }
-    }
-}
-
-impl<T> From<T> for Index<T>
-where
-    T: PartialOrd + Ord + Send + Sync + Debug,
-{
-    fn from(prefix: T) -> Self {
-        Self {
-            prefix,
-            id: Default::default(),
-            counter: SubscriptionGlobalId(0),
-        }
-    }
-}
-
-static COUNTER: AtomicUsize = AtomicUsize::new(0);
-
-/// Dummy type
-///
-/// This is only use so each Index is unique, with the same prefix.
-///
-/// The prefix is used to leverage the BTree to find things quickly, but each
-/// entry/key must be unique, so we use this dummy type to make sure each Index
-/// is unique.
-///
-/// Unique is also used to make sure that the indexes are sorted by creation order
-#[derive(Debug, Ord, PartialOrd, PartialEq, Eq, Clone, Copy)]
-pub struct SubscriptionGlobalId(usize);
-
-impl Default for SubscriptionGlobalId {
-    fn default() -> Self {
-        Self(COUNTER.fetch_add(1, Ordering::Relaxed))
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn test_index_from_tuple() {
-        let sub_id = SubId::from("test_sub_id");
-        let prefix = "test_prefix";
-        let index: Index<&str> = Index::from((prefix, sub_id.clone()));
-        assert_eq!(index.prefix, "test_prefix");
-        assert_eq!(index.id, sub_id);
-    }
-
-    #[test]
-    fn test_index_cmp_prefix() {
-        let sub_id = SubId::from("test_sub_id");
-        let index1: Index<&str> = Index::from(("a", sub_id.clone()));
-        let index2: Index<&str> = Index::from(("b", sub_id.clone()));
-        assert_eq!(index1.cmp_prefix(&index2), std::cmp::Ordering::Less);
-    }
-
-    #[test]
-    fn test_sub_id_from_str() {
-        let sub_id = SubId::from("test_sub_id");
-        assert_eq!(sub_id.0, "test_sub_id");
-    }
-
-    #[test]
-    fn test_sub_id_deref() {
-        let sub_id = SubId::from("test_sub_id");
-        assert_eq!(&*sub_id, "test_sub_id");
-    }
-}

+ 153 - 58
crates/cdk-common/src/pub_sub/mod.rs

@@ -1,77 +1,172 @@
-//! Publish–subscribe pattern.
+//! Publish–subscribe manager.
+//!
+//! This is a event-agnostic Publish-subscriber producer and consumer.
 //!
 //! This is a generic implementation for
 //! [NUT-17](<https://github.com/cashubtc/nuts/blob/main/17.md>) with a type
 //! agnostic Publish-subscribe manager.
 //!
 //! The manager has a method for subscribers to subscribe to events with a
-//! generic type that must be converted to a vector of indexes.
+//! generic type that must be converted to a vector of topics.
 //!
-//! Events are also generic that should implement the `Indexable` trait.
-use std::fmt::Debug;
-use std::ops::Deref;
-use std::str::FromStr;
-
-use serde::{Deserialize, Serialize};
-
-pub mod index;
-
-/// Default size of the remove channel
-pub const DEFAULT_REMOVE_SIZE: usize = 10_000;
-
-/// Default channel size for subscription buffering
-pub const DEFAULT_CHANNEL_SIZE: usize = 10;
-
-#[async_trait::async_trait]
-/// On New Subscription trait
-///
-/// This trait is optional and it is used to notify the application when a new
-/// subscription is created. This is useful when the application needs to send
-/// the initial state to the subscriber upon subscription
-pub trait OnNewSubscription {
-    /// Index type
-    type Index;
-    /// Subscription event type
-    type Event;
-
-    /// Called when a new subscription is created
-    async fn on_new_subscription(
-        &self,
-        request: &[&Self::Index],
-    ) -> Result<Vec<Self::Event>, String>;
-}
+//! Events are also generic that should implement the `Event` trait.
+
+mod error;
+mod pubsub;
+pub mod remote_consumer;
+mod subscriber;
+mod types;
+
+pub use self::error::Error;
+pub use self::pubsub::Pubsub;
+pub use self::subscriber::{Subscriber, SubscriptionRequest};
+pub use self::types::*;
+
+#[cfg(test)]
+mod test {
+    use std::collections::HashMap;
+    use std::sync::{Arc, RwLock};
+
+    use serde::{Deserialize, Serialize};
 
-/// Subscription Id wrapper
-///
-/// This is the place to add some sane default (like a max length) to the
-/// subscription ID
-#[derive(Debug, Clone, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
-pub struct SubId(String);
+    use super::subscriber::SubscriptionRequest;
+    use super::{Error, Event, Pubsub, Spec, Subscriber};
 
-impl From<&str> for SubId {
-    fn from(s: &str) -> Self {
-        Self(s.to_string())
+    #[derive(Clone, Debug, Serialize, Eq, PartialEq, Deserialize)]
+    pub struct Message {
+        pub foo: u64,
+        pub bar: u64,
     }
-}
 
-impl From<String> for SubId {
-    fn from(s: String) -> Self {
-        Self(s)
+    #[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
+    pub enum IndexTest {
+        Foo(u64),
+        Bar(u64),
     }
-}
 
-impl FromStr for SubId {
-    type Err = ();
+    impl Event for Message {
+        type Topic = IndexTest;
 
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        Ok(Self(s.to_string()))
+        fn get_topics(&self) -> Vec<Self::Topic> {
+            vec![IndexTest::Foo(self.foo), IndexTest::Bar(self.bar)]
+        }
     }
-}
 
-impl Deref for SubId {
-    type Target = String;
+    pub struct CustomPubSub {
+        pub storage: Arc<RwLock<HashMap<IndexTest, Message>>>,
+    }
+
+    #[async_trait::async_trait]
+    impl Spec for CustomPubSub {
+        type Topic = IndexTest;
+
+        type Event = Message;
+
+        type SubscriptionId = String;
+
+        type Context = ();
+
+        fn new_instance(_context: Self::Context) -> Arc<Self>
+        where
+            Self: Sized,
+        {
+            Arc::new(Self {
+                storage: Default::default(),
+            })
+        }
+
+        async fn fetch_events(
+            self: &Arc<Self>,
+            topics: Vec<<Self::Event as Event>::Topic>,
+            reply_to: Subscriber<Self>,
+        ) where
+            Self: Sized,
+        {
+            let storage = self.storage.read().unwrap();
+
+            for index in topics {
+                if let Some(value) = storage.get(&index) {
+                    let _ = reply_to.send(value.clone());
+                }
+            }
+        }
+    }
+
+    #[derive(Debug, Clone)]
+    pub enum SubscriptionReq {
+        Foo(u64),
+        Bar(u64),
+    }
+
+    impl SubscriptionRequest for SubscriptionReq {
+        type Topic = IndexTest;
+
+        type SubscriptionId = String;
+
+        fn try_get_topics(&self) -> Result<Vec<Self::Topic>, Error> {
+            Ok(vec![match self {
+                SubscriptionReq::Bar(n) => IndexTest::Bar(*n),
+                SubscriptionReq::Foo(n) => IndexTest::Foo(*n),
+            }])
+        }
+
+        fn subscription_name(&self) -> Arc<Self::SubscriptionId> {
+            Arc::new("test".to_owned())
+        }
+    }
+
+    #[tokio::test]
+    async fn delivery_twice_realtime() {
+        let pubsub = Pubsub::new(CustomPubSub::new_instance(()));
+
+        assert_eq!(pubsub.active_subscribers(), 0);
+
+        let mut subscriber = pubsub.subscribe(SubscriptionReq::Foo(2)).unwrap();
+
+        assert_eq!(pubsub.active_subscribers(), 1);
+
+        let _ = pubsub.publish_now(Message { foo: 2, bar: 1 });
+        let _ = pubsub.publish_now(Message { foo: 2, bar: 2 });
+
+        assert_eq!(subscriber.recv().await.map(|x| x.bar), Some(1));
+        assert_eq!(subscriber.recv().await.map(|x| x.bar), Some(2));
+        assert!(subscriber.try_recv().is_none());
+
+        drop(subscriber);
+
+        assert_eq!(pubsub.active_subscribers(), 0);
+    }
+
+    #[tokio::test]
+    async fn read_from_storage() {
+        let x = CustomPubSub::new_instance(());
+        let storage = x.storage.clone();
+
+        let pubsub = Pubsub::new(x);
+
+        {
+            // set previous value
+            let mut s = storage.write().unwrap();
+            s.insert(IndexTest::Bar(2), Message { foo: 3, bar: 2 });
+        }
+
+        let mut subscriber = pubsub.subscribe(SubscriptionReq::Bar(2)).unwrap();
+
+        // Just should receive the latest
+        assert_eq!(subscriber.recv().await.map(|x| x.foo), Some(3));
+
+        // realtime delivery test
+        let _ = pubsub.publish_now(Message { foo: 1, bar: 2 });
+        assert_eq!(subscriber.recv().await.map(|x| x.foo), Some(1));
+
+        {
+            // set previous value
+            let mut s = storage.write().unwrap();
+            s.insert(IndexTest::Bar(2), Message { foo: 1, bar: 2 });
+        }
 
-    fn deref(&self) -> &Self::Target {
-        &self.0
+        // new subscription should only get the latest state (it is up to the Topic trait)
+        let mut y = pubsub.subscribe(SubscriptionReq::Bar(2)).unwrap();
+        assert_eq!(y.recv().await.map(|x| x.foo), Some(1));
     }
 }

+ 184 - 0
crates/cdk-common/src/pub_sub/pubsub.rs

@@ -0,0 +1,184 @@
+//! Pub-sub producer
+
+use std::cmp::Ordering;
+use std::collections::{BTreeMap, HashSet};
+use std::sync::atomic::AtomicUsize;
+use std::sync::{Arc, RwLock};
+
+use tokio::sync::mpsc;
+
+use super::subscriber::{ActiveSubscription, SubscriptionRequest};
+use super::{Error, Event, Spec, Subscriber};
+
+/// Default channel size for subscription buffering
+pub const DEFAULT_CHANNEL_SIZE: usize = 10_000;
+
+/// Subscriber Receiver
+pub type SubReceiver<S> = mpsc::Receiver<(Arc<<S as Spec>::SubscriptionId>, <S as Spec>::Event)>;
+
+/// Internal Index Tree
+pub type TopicTree<T> = Arc<
+    RwLock<
+        BTreeMap<
+            // Index with a subscription unique ID
+            (<T as Spec>::Topic, usize),
+            Subscriber<T>,
+        >,
+    >,
+>;
+
+/// Manager
+pub struct Pubsub<S>
+where
+    S: Spec + 'static,
+{
+    inner: Arc<S>,
+    listeners_topics: TopicTree<S>,
+    unique_subscription_counter: AtomicUsize,
+    active_subscribers: Arc<AtomicUsize>,
+}
+
+impl<S> Pubsub<S>
+where
+    S: Spec + 'static,
+{
+    /// Create a new instance
+    pub fn new(inner: Arc<S>) -> Self {
+        Self {
+            inner,
+            listeners_topics: Default::default(),
+            unique_subscription_counter: 0.into(),
+            active_subscribers: Arc::new(0.into()),
+        }
+    }
+
+    /// Total number of active subscribers, it is not the number of active topics being subscribed
+    pub fn active_subscribers(&self) -> usize {
+        self.active_subscribers
+            .load(std::sync::atomic::Ordering::Relaxed)
+    }
+
+    /// Publish an event to all listenrs
+    #[inline(always)]
+    fn publish_internal(event: S::Event, listeners_index: &TopicTree<S>) -> Result<(), Error> {
+        let index_storage = listeners_index.read().map_err(|_| Error::Poison)?;
+
+        let mut sent = HashSet::new();
+        for topic in event.get_topics() {
+            for ((subscription_index, unique_id), sender) in
+                index_storage.range((topic.clone(), 0)..)
+            {
+                if subscription_index.cmp(&topic) != Ordering::Equal {
+                    break;
+                }
+                if sent.contains(&unique_id) {
+                    continue;
+                }
+                sent.insert(unique_id);
+                sender.send(event.clone());
+            }
+        }
+
+        Ok(())
+    }
+
+    /// Broadcast an event to all listeners
+    #[inline(always)]
+    pub fn publish<E>(&self, event: E)
+    where
+        E: Into<S::Event>,
+    {
+        let topics = self.listeners_topics.clone();
+        let event = event.into();
+
+        #[cfg(not(target_arch = "wasm32"))]
+        tokio::spawn(async move {
+            let _ = Self::publish_internal(event, &topics);
+        });
+
+        #[cfg(target_arch = "wasm32")]
+        wasm_bindgen_futures::spawn_local(async move {
+            let _ = Self::publish_internal(event, &topics);
+        });
+    }
+
+    /// Broadcast an event to all listeners right away, blocking the current thread
+    ///
+    /// This function takes an Arc to the storage struct, the event_id, the kind
+    /// and the vent to broadcast
+    #[inline(always)]
+    pub fn publish_now<E>(&self, event: E) -> Result<(), Error>
+    where
+        E: Into<S::Event>,
+    {
+        let event = event.into();
+        Self::publish_internal(event, &self.listeners_topics)
+    }
+
+    /// Subscribe proving custom sender/receiver mpsc
+    #[inline(always)]
+    pub fn subscribe_with<I>(
+        &self,
+        request: I,
+        sender: &mpsc::Sender<(Arc<I::SubscriptionId>, S::Event)>,
+        receiver: Option<SubReceiver<S>>,
+    ) -> Result<ActiveSubscription<S>, Error>
+    where
+        I: SubscriptionRequest<
+            Topic = <S::Event as Event>::Topic,
+            SubscriptionId = S::SubscriptionId,
+        >,
+    {
+        let subscription_name = request.subscription_name();
+        let sender = Subscriber::new(subscription_name.clone(), sender);
+        let mut index_storage = self.listeners_topics.write().map_err(|_| Error::Poison)?;
+        let subscription_internal_id = self
+            .unique_subscription_counter
+            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+
+        self.active_subscribers
+            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+
+        let subscribed_to = request.try_get_topics()?;
+
+        for index in subscribed_to.iter() {
+            index_storage.insert((index.clone(), subscription_internal_id), sender.clone());
+        }
+        drop(index_storage);
+
+        let inner = self.inner.clone();
+        let subscribed_to_for_spawn = subscribed_to.clone();
+
+        #[cfg(not(target_arch = "wasm32"))]
+        tokio::spawn(async move {
+            // TODO: Ignore topics broadcasted from fetch_events _if_ any real time has been broadcasted already.
+            inner.fetch_events(subscribed_to_for_spawn, sender).await;
+        });
+
+        #[cfg(target_arch = "wasm32")]
+        wasm_bindgen_futures::spawn_local(async move {
+            inner.fetch_events(subscribed_to_for_spawn, sender).await;
+        });
+
+        Ok(ActiveSubscription::new(
+            subscription_internal_id,
+            subscription_name,
+            self.active_subscribers.clone(),
+            self.listeners_topics.clone(),
+            subscribed_to,
+            receiver,
+        ))
+    }
+
+    /// Subscribe
+    pub fn subscribe<I>(&self, request: I) -> Result<ActiveSubscription<S>, Error>
+    where
+        I: SubscriptionRequest<
+            Topic = <S::Event as Event>::Topic,
+            SubscriptionId = S::SubscriptionId,
+        >,
+    {
+        let (sender, receiver) = mpsc::channel(DEFAULT_CHANNEL_SIZE);
+        self.subscribe_with(request, &sender, Some(receiver))
+    }
+}

+ 961 - 0
crates/cdk-common/src/pub_sub/remote_consumer.rs

@@ -0,0 +1,961 @@
+//! Pub-sub consumer
+//!
+//! Consumers are designed to connect to a producer, through a transport, and subscribe to events.
+use std::collections::{HashMap, VecDeque};
+use std::sync::atomic::AtomicBool;
+use std::sync::{Arc, RwLock};
+use std::time::Duration;
+
+use tokio::sync::mpsc;
+use tokio::time::{sleep, Instant};
+
+use super::subscriber::{ActiveSubscription, SubscriptionRequest};
+use super::{Error, Event, Pubsub, Spec};
+
+const STREAM_CONNECTION_BACKOFF: Duration = Duration::from_millis(2_000);
+
+const STREAM_CONNECTION_MAX_BACKOFF: Duration = Duration::from_millis(30_000);
+
+const INTERNAL_POLL_SIZE: usize = 1_000;
+
+const POLL_SLEEP: Duration = Duration::from_millis(2_000);
+
+#[cfg(target_arch = "wasm32")]
+use wasm_bindgen_futures;
+
+struct UniqueSubscription<S>
+where
+    S: Spec,
+{
+    name: S::SubscriptionId,
+    total_subscribers: usize,
+}
+
+type UniqueSubscriptions<S> = RwLock<HashMap<<S as Spec>::Topic, UniqueSubscription<S>>>;
+
+type ActiveSubscriptions<S> =
+    RwLock<HashMap<Arc<<S as Spec>::SubscriptionId>, Vec<<S as Spec>::Topic>>>;
+
+type CacheEvent<S> = HashMap<<<S as Spec>::Event as Event>::Topic, <S as Spec>::Event>;
+
+/// Subscription consumer
+pub struct Consumer<T>
+where
+    T: Transport + 'static,
+{
+    transport: T,
+    inner_pubsub: Arc<Pubsub<T::Spec>>,
+    remote_subscriptions: UniqueSubscriptions<T::Spec>,
+    subscriptions: ActiveSubscriptions<T::Spec>,
+    stream_ctrl: RwLock<Option<mpsc::Sender<StreamCtrl<T::Spec>>>>,
+    still_running: AtomicBool,
+    prefer_polling: bool,
+    /// Cached events
+    ///
+    /// The cached events are useful to share events. The cache is automatically evicted it is
+    /// disconnected from the remote source, meaning the cache is only active while there is an
+    /// active subscription to the remote source, and it remembers the latest event.
+    cached_events: Arc<RwLock<CacheEvent<T::Spec>>>,
+}
+
+/// Remote consumer
+pub struct RemoteActiveConsumer<T>
+where
+    T: Transport + 'static,
+{
+    inner: ActiveSubscription<T::Spec>,
+    previous_messages: VecDeque<<T::Spec as Spec>::Event>,
+    consumer: Arc<Consumer<T>>,
+}
+
+impl<T> RemoteActiveConsumer<T>
+where
+    T: Transport + 'static,
+{
+    /// Receives the next event
+    pub async fn recv(&mut self) -> Option<<T::Spec as Spec>::Event> {
+        if let Some(event) = self.previous_messages.pop_front() {
+            Some(event)
+        } else {
+            self.inner.recv().await
+        }
+    }
+
+    /// Try receive an event or return Noen right away
+    pub fn try_recv(&mut self) -> Option<<T::Spec as Spec>::Event> {
+        if let Some(event) = self.previous_messages.pop_front() {
+            Some(event)
+        } else {
+            self.inner.try_recv()
+        }
+    }
+
+    /// Get the subscription name
+    pub fn name(&self) -> &<T::Spec as Spec>::SubscriptionId {
+        self.inner.name()
+    }
+}
+
+impl<T> Drop for RemoteActiveConsumer<T>
+where
+    T: Transport + 'static,
+{
+    fn drop(&mut self) {
+        let _ = self.consumer.unsubscribe(self.name().clone());
+    }
+}
+
+struct InternalConversion<T>
+where
+    T: Transport + 'static,
+{
+    name: Arc<<T::Spec as Spec>::SubscriptionId>,
+    index: <T::Spec as Spec>::Topic,
+}
+
+impl<T> Clone for InternalConversion<T>
+where
+    T: Transport + 'static,
+{
+    fn clone(&self) -> Self {
+        Self {
+            name: self.name.clone(),
+            index: self.index.clone(),
+        }
+    }
+}
+
+impl<T> SubscriptionRequest for InternalConversion<T>
+where
+    T: Transport + 'static,
+{
+    type Topic = <T::Spec as Spec>::Topic;
+
+    type SubscriptionId = <T::Spec as Spec>::SubscriptionId;
+
+    fn subscription_name(&self) -> Arc<<T::Spec as Spec>::SubscriptionId> {
+        self.name.clone()
+    }
+
+    fn try_get_topics(&self) -> Result<Vec<Self::Topic>, Error> {
+        Ok(vec![self.index.clone()])
+    }
+}
+
+/// Struct to relay events from Poll and Streams from the external subscription to the local
+/// subscribers
+pub struct InternalRelay<S>
+where
+    S: Spec + 'static,
+{
+    inner: Arc<Pubsub<S>>,
+    cached_events: Arc<RwLock<CacheEvent<S>>>,
+}
+
+impl<S> InternalRelay<S>
+where
+    S: Spec + 'static,
+{
+    /// Relay a remote event locally
+    pub fn send<X>(&self, event: X)
+    where
+        X: Into<S::Event>,
+    {
+        let event = event.into();
+        let mut cached_events = self.cached_events.write().unwrap_or_else(|mut err| {
+            **err.get_mut() = HashMap::new();
+            self.cached_events.clear_poison();
+            err.into_inner()
+        });
+
+        for topic in event.get_topics() {
+            cached_events.insert(topic, event.clone());
+        }
+
+        self.inner.publish(event);
+    }
+}
+
+impl<T> Consumer<T>
+where
+    T: Transport + 'static,
+{
+    /// Creates a new instance
+    pub fn new(
+        transport: T,
+        prefer_polling: bool,
+        context: <T::Spec as Spec>::Context,
+    ) -> Arc<Self> {
+        let this = Arc::new(Self {
+            transport,
+            prefer_polling,
+            inner_pubsub: Arc::new(Pubsub::new(T::Spec::new_instance(context))),
+            subscriptions: Default::default(),
+            remote_subscriptions: Default::default(),
+            stream_ctrl: RwLock::new(None),
+            cached_events: Default::default(),
+            still_running: true.into(),
+        });
+
+        #[cfg(not(target_arch = "wasm32"))]
+        tokio::spawn(Self::stream(this.clone()));
+
+        #[cfg(target_arch = "wasm32")]
+        wasm_bindgen_futures::spawn_local(Self::stream(this.clone()));
+
+        this
+    }
+
+    async fn stream(instance: Arc<Self>) {
+        let mut stream_supported = true;
+        let mut poll_supported = true;
+
+        let mut backoff = STREAM_CONNECTION_BACKOFF;
+        let mut retry_at = None;
+
+        loop {
+            if (!stream_supported && !poll_supported)
+                || !instance
+                    .still_running
+                    .load(std::sync::atomic::Ordering::Relaxed)
+            {
+                break;
+            }
+
+            if instance
+                .remote_subscriptions
+                .read()
+                .map(|x| x.len())
+                .unwrap_or_default()
+                == 0
+            {
+                sleep(Duration::from_millis(100)).await;
+                continue;
+            }
+
+            if stream_supported
+                && !instance.prefer_polling
+                && retry_at
+                    .map(|retry_at| retry_at < Instant::now())
+                    .unwrap_or(true)
+            {
+                let (sender, receiver) = mpsc::channel(INTERNAL_POLL_SIZE);
+
+                {
+                    *(instance.stream_ctrl.write().unwrap_or_else(|mut err| {
+                        **err.get_mut() = None;
+                        instance.stream_ctrl.clear_poison();
+                        err.into_inner()
+                    })) = Some(sender);
+                }
+
+                let current_subscriptions = {
+                    if let Ok(remote_subscriptions) = instance.remote_subscriptions.read() {
+                        remote_subscriptions
+                            .iter()
+                            .map(|(key, name)| (name.name.clone(), key.clone()))
+                            .collect::<Vec<_>>()
+                    } else {
+                        vec![]
+                    }
+                };
+
+                if let Err(err) = instance
+                    .transport
+                    .stream(
+                        receiver,
+                        current_subscriptions,
+                        InternalRelay {
+                            inner: instance.inner_pubsub.clone(),
+                            cached_events: instance.cached_events.clone(),
+                        },
+                    )
+                    .await
+                {
+                    retry_at = Some(Instant::now() + backoff);
+                    backoff =
+                        (backoff + STREAM_CONNECTION_BACKOFF).min(STREAM_CONNECTION_MAX_BACKOFF);
+
+                    if matches!(err, Error::NotSupported) {
+                        stream_supported = false;
+                    }
+                    tracing::error!("Long connection failed with error {:?}", err);
+                } else {
+                    backoff = STREAM_CONNECTION_BACKOFF;
+                }
+
+                // remove sender to stream, as there is no stream
+                let _ = instance
+                    .stream_ctrl
+                    .write()
+                    .unwrap_or_else(|mut err| {
+                        **err.get_mut() = None;
+                        instance.stream_ctrl.clear_poison();
+                        err.into_inner()
+                    })
+                    .take();
+            }
+
+            if poll_supported {
+                let current_subscriptions = {
+                    if let Ok(remote_subscriptions) = instance.remote_subscriptions.read() {
+                        remote_subscriptions
+                            .iter()
+                            .map(|(key, name)| (name.name.clone(), key.clone()))
+                            .collect::<Vec<_>>()
+                    } else {
+                        vec![]
+                    }
+                };
+
+                if let Err(err) = instance
+                    .transport
+                    .poll(
+                        current_subscriptions,
+                        InternalRelay {
+                            inner: instance.inner_pubsub.clone(),
+                            cached_events: instance.cached_events.clone(),
+                        },
+                    )
+                    .await
+                {
+                    if matches!(err, Error::NotSupported) {
+                        poll_supported = false;
+                    }
+                    tracing::error!("Polling failed with error {:?}", err);
+                }
+
+                sleep(POLL_SLEEP).await;
+            }
+        }
+    }
+
+    /// Unsubscribe from a topic, this is called automatically when RemoteActiveSubscription<T> goes
+    /// out of scope
+    fn unsubscribe(
+        self: &Arc<Self>,
+        subscription_name: <T::Spec as Spec>::SubscriptionId,
+    ) -> Result<(), Error> {
+        let topics = self
+            .subscriptions
+            .write()
+            .map_err(|_| Error::Poison)?
+            .remove(&subscription_name)
+            .ok_or(Error::AlreadySubscribed)?;
+
+        let mut remote_subscriptions = self
+            .remote_subscriptions
+            .write()
+            .map_err(|_| Error::Poison)?;
+
+        for topic in topics {
+            let mut remote_subscription =
+                if let Some(remote_subscription) = remote_subscriptions.remove(&topic) {
+                    remote_subscription
+                } else {
+                    continue;
+                };
+
+            remote_subscription.total_subscribers = remote_subscription
+                .total_subscribers
+                .checked_sub(1)
+                .unwrap_or_default();
+
+            if remote_subscription.total_subscribers == 0 {
+                let mut cached_events = self.cached_events.write().unwrap_or_else(|mut err| {
+                    **err.get_mut() = HashMap::new();
+                    self.cached_events.clear_poison();
+                    err.into_inner()
+                });
+
+                cached_events.remove(&topic);
+
+                self.message_to_stream(StreamCtrl::Unsubscribe(remote_subscription.name.clone()))?;
+            } else {
+                remote_subscriptions.insert(topic, remote_subscription);
+            }
+        }
+
+        if remote_subscriptions.is_empty() {
+            self.message_to_stream(StreamCtrl::Stop)?;
+        }
+
+        Ok(())
+    }
+
+    #[inline(always)]
+    fn message_to_stream(&self, message: StreamCtrl<T::Spec>) -> Result<(), Error> {
+        let to_stream = self.stream_ctrl.read().map_err(|_| Error::Poison)?;
+
+        if let Some(to_stream) = to_stream.as_ref() {
+            Ok(to_stream.try_send(message)?)
+        } else {
+            Ok(())
+        }
+    }
+
+    /// Creates a subscription
+    ///
+    /// The subscriptions have two parts:
+    ///
+    /// 1. Will create the subscription to the remote Pubsub service, Any events will be moved to
+    ///    the internal pubsub
+    ///
+    /// 2. The internal subscription to the inner Pubsub. Because all subscriptions are going the
+    ///    transport, once events matches subscriptions, the inner_pubsub will receive the message and
+    ///    broadcasat the event.
+    pub fn subscribe<I>(self: &Arc<Self>, request: I) -> Result<RemoteActiveConsumer<T>, Error>
+    where
+        I: SubscriptionRequest<
+            Topic = <T::Spec as Spec>::Topic,
+            SubscriptionId = <T::Spec as Spec>::SubscriptionId,
+        >,
+    {
+        let subscription_name = request.subscription_name();
+        let topics = request.try_get_topics()?;
+
+        let mut remote_subscriptions = self
+            .remote_subscriptions
+            .write()
+            .map_err(|_| Error::Poison)?;
+        let mut subscriptions = self.subscriptions.write().map_err(|_| Error::Poison)?;
+
+        if subscriptions.get(&subscription_name).is_some() {
+            return Err(Error::AlreadySubscribed);
+        }
+
+        let mut previous_messages = Vec::new();
+        let cached_events = self.cached_events.read().unwrap_or_else(|e| {
+            self.cached_events.clear_poison();
+            e.into_inner()
+        });
+
+        for topic in topics.iter() {
+            if let Some(subscription) = remote_subscriptions.get_mut(topic) {
+                subscription.total_subscribers += 1;
+
+                if let Some(v) = cached_events.get(topic).cloned() {
+                    previous_messages.push(v);
+                }
+            } else {
+                let internal_sub_name = self.transport.new_name();
+                remote_subscriptions.insert(
+                    topic.clone(),
+                    UniqueSubscription {
+                        total_subscribers: 1,
+                        name: internal_sub_name.clone(),
+                    },
+                );
+
+                // new subscription is created, so the connection worker should be notified
+                self.message_to_stream(StreamCtrl::Subscribe((internal_sub_name, topic.clone())))?;
+            }
+        }
+
+        subscriptions.insert(subscription_name, topics);
+        drop(subscriptions);
+
+        Ok(RemoteActiveConsumer {
+            inner: self.inner_pubsub.subscribe(request)?,
+            previous_messages: previous_messages.into(),
+            consumer: self.clone(),
+        })
+    }
+}
+
+impl<T> Drop for Consumer<T>
+where
+    T: Transport + 'static,
+{
+    fn drop(&mut self) {
+        self.still_running
+            .store(false, std::sync::atomic::Ordering::Release);
+        if let Ok(Some(to_stream)) = self.stream_ctrl.read().map(|sender| sender.clone()) {
+            let _ = to_stream.try_send(StreamCtrl::Stop).inspect_err(|err| {
+                tracing::error!("Failed to send message LongPoll::Stop due to {err:?}")
+            });
+        }
+    }
+}
+
+/// Subscribe Message
+pub type SubscribeMessage<S> = (<S as Spec>::SubscriptionId, <S as Spec>::Topic);
+
+/// Messages sent from the [`Consumer`] to the [`Transport`] background loop.
+pub enum StreamCtrl<S>
+where
+    S: Spec + 'static,
+{
+    /// Add a subscription
+    Subscribe(SubscribeMessage<S>),
+    /// Desuscribe
+    Unsubscribe(S::SubscriptionId),
+    /// Exit the loop
+    Stop,
+}
+
+impl<S> Clone for StreamCtrl<S>
+where
+    S: Spec + 'static,
+{
+    fn clone(&self) -> Self {
+        match self {
+            Self::Subscribe(s) => Self::Subscribe(s.clone()),
+            Self::Unsubscribe(u) => Self::Unsubscribe(u.clone()),
+            Self::Stop => Self::Stop,
+        }
+    }
+}
+
+/// Transport abstracts how the consumer talks to the remote pubsub.
+///
+/// Implement this on your HTTP/WebSocket client. The transport is responsible for:
+/// - creating unique subscription names,
+/// - keeping a long connection via `stream` **or** performing on-demand `poll`,
+/// - forwarding remote events to `InternalRelay`.
+///
+/// ```ignore
+/// struct WsTransport { /* ... */ }
+/// #[async_trait::async_trait]
+/// impl Transport for WsTransport {
+///     type Topic = MyTopic;
+///     fn new_name(&self) -> <Self::Topic as Topic>::SubscriptionName { 0 }
+///     async fn stream(/* ... */) -> Result<(), Error> { Ok(()) }
+///     async fn poll(/* ... */) -> Result<(), Error> { Ok(()) }
+/// }
+/// ```
+#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
+#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
+pub trait Transport: Send + Sync {
+    /// Spec
+    type Spec: Spec;
+
+    /// Create a new subscription name
+    fn new_name(&self) -> <Self::Spec as Spec>::SubscriptionId;
+
+    /// Opens a persistent connection and continuously streams events.
+    /// For protocols that support server push (e.g. WebSocket, SSE).
+    async fn stream(
+        &self,
+        subscribe_changes: mpsc::Receiver<StreamCtrl<Self::Spec>>,
+        topics: Vec<SubscribeMessage<Self::Spec>>,
+        reply_to: InternalRelay<Self::Spec>,
+    ) -> Result<(), Error>;
+
+    /// Performs a one-shot fetch of any currently available events.
+    /// Called repeatedly by the consumer when streaming is not available.
+    async fn poll(
+        &self,
+        topics: Vec<SubscribeMessage<Self::Spec>>,
+        reply_to: InternalRelay<Self::Spec>,
+    ) -> Result<(), Error>;
+}
+
+#[cfg(test)]
+mod tests {
+    use std::sync::atomic::{AtomicUsize, Ordering};
+    use std::sync::Arc;
+
+    use tokio::sync::{mpsc, Mutex};
+    use tokio::time::{timeout, Duration};
+
+    use super::{
+        InternalRelay, RemoteActiveConsumer, StreamCtrl, SubscribeMessage, Transport,
+        INTERNAL_POLL_SIZE,
+    };
+    use crate::pub_sub::remote_consumer::Consumer;
+    use crate::pub_sub::test::{CustomPubSub, IndexTest, Message};
+    use crate::pub_sub::{Error, Spec, SubscriptionRequest};
+
+    // ===== Test Event/Topic types =====
+
+    #[derive(Clone, Debug)]
+    enum SubscriptionReq {
+        Foo(String, u64),
+        Bar(String, u64),
+    }
+
+    impl SubscriptionRequest for SubscriptionReq {
+        type Topic = IndexTest;
+
+        type SubscriptionId = String;
+
+        fn try_get_topics(&self) -> Result<Vec<Self::Topic>, Error> {
+            Ok(vec![match self {
+                SubscriptionReq::Foo(_, n) => IndexTest::Foo(*n),
+                SubscriptionReq::Bar(_, n) => IndexTest::Bar(*n),
+            }])
+        }
+
+        fn subscription_name(&self) -> Arc<Self::SubscriptionId> {
+            Arc::new(match self {
+                SubscriptionReq::Foo(n, _) => n.to_string(),
+                SubscriptionReq::Bar(n, _) => n.to_string(),
+            })
+        }
+    }
+
+    // ===== A controllable in-memory Transport used by tests =====
+
+    /// TestTransport relays messages from a broadcast channel to the Consumer via `InternalRelay`.
+    /// It also forwards Subscribe/Unsubscribe/Stop signals to an observer channel so tests can assert them.
+    struct TestTransport {
+        name_ctr: AtomicUsize,
+        // We forward all transport-loop control messages here so tests can observe them.
+        observe_ctrl_tx: mpsc::Sender<StreamCtrl<CustomPubSub>>,
+        // Whether stream / poll are supported.
+        support_long: bool,
+        support_poll: bool,
+        rx: Mutex<mpsc::Receiver<Message>>,
+    }
+
+    impl TestTransport {
+        fn new(
+            support_long: bool,
+            support_poll: bool,
+        ) -> (
+            Self,
+            mpsc::Sender<Message>,
+            mpsc::Receiver<StreamCtrl<CustomPubSub>>,
+        ) {
+            let (events_tx, rx) = mpsc::channel::<Message>(INTERNAL_POLL_SIZE);
+            let (observe_ctrl_tx, observe_ctrl_rx) =
+                mpsc::channel::<StreamCtrl<_>>(INTERNAL_POLL_SIZE);
+
+            let t = TestTransport {
+                name_ctr: AtomicUsize::new(1),
+                rx: Mutex::new(rx),
+                observe_ctrl_tx,
+                support_long,
+                support_poll,
+            };
+
+            (t, events_tx, observe_ctrl_rx)
+        }
+    }
+
+    #[async_trait::async_trait]
+    impl Transport for TestTransport {
+        type Spec = CustomPubSub;
+
+        fn new_name(&self) -> <Self::Spec as Spec>::SubscriptionId {
+            format!("sub-{}", self.name_ctr.fetch_add(1, Ordering::Relaxed))
+        }
+
+        async fn stream(
+            &self,
+            mut subscribe_changes: mpsc::Receiver<StreamCtrl<Self::Spec>>,
+            topics: Vec<SubscribeMessage<Self::Spec>>,
+            reply_to: InternalRelay<Self::Spec>,
+        ) -> Result<(), Error> {
+            if !self.support_long {
+                return Err(Error::NotSupported);
+            }
+
+            // Each invocation creates a fresh broadcast receiver
+            let mut rx = self.rx.lock().await;
+            let observe = self.observe_ctrl_tx.clone();
+
+            for topic in topics {
+                observe.try_send(StreamCtrl::Subscribe(topic)).unwrap();
+            }
+
+            loop {
+                tokio::select! {
+                    // Forward any control (Subscribe/Unsubscribe/Stop) messages so the test can assert them.
+                    Some(ctrl) = subscribe_changes.recv() => {
+                        observe.try_send(ctrl.clone()).unwrap();
+                        if matches!(ctrl, StreamCtrl::Stop) {
+                            break;
+                        }
+                    }
+                    // Relay external events into the inner pubsub
+                    Some(msg) = rx.recv() => {
+                        reply_to.send(msg);
+                    }
+                }
+            }
+
+            Ok(())
+        }
+
+        async fn poll(
+            &self,
+            _topics: Vec<SubscribeMessage<Self::Spec>>,
+            reply_to: InternalRelay<Self::Spec>,
+        ) -> Result<(), Error> {
+            if !self.support_poll {
+                return Err(Error::NotSupported);
+            }
+
+            // On each poll call, drain anything currently pending and return.
+            // (The Consumer calls this repeatedly; first call happens immediately.)
+            let mut rx = self.rx.lock().await;
+            // Non-blocking drain pass: try a few times without sleeping to keep tests snappy
+            for _ in 0..32 {
+                match rx.try_recv() {
+                    Ok(msg) => reply_to.send(msg),
+                    Err(mpsc::error::TryRecvError::Empty) => continue,
+                    Err(mpsc::error::TryRecvError::Disconnected) => break,
+                }
+            }
+            Ok(())
+        }
+    }
+
+    // ===== Helpers =====
+
+    async fn recv_next<T: Transport>(
+        sub: &mut RemoteActiveConsumer<T>,
+        dur_ms: u64,
+    ) -> Option<<T::Spec as Spec>::Event> {
+        timeout(Duration::from_millis(dur_ms), sub.recv())
+            .await
+            .ok()
+            .flatten()
+    }
+
+    async fn expect_ctrl(
+        rx: &mut mpsc::Receiver<StreamCtrl<CustomPubSub>>,
+        dur_ms: u64,
+        pred: impl Fn(&StreamCtrl<CustomPubSub>) -> bool,
+    ) -> StreamCtrl<CustomPubSub> {
+        timeout(Duration::from_millis(dur_ms), async {
+            loop {
+                if let Some(msg) = rx.recv().await {
+                    if pred(&msg) {
+                        break msg;
+                    }
+                }
+            }
+        })
+        .await
+        .expect("timed out waiting for control message")
+    }
+
+    // ===== Tests =====
+
+    #[tokio::test]
+    async fn stream_delivery_and_unsubscribe_on_drop() {
+        // stream supported, poll supported (doesn't matter; prefer long)
+        let (transport, events_tx, mut ctrl_rx) = TestTransport::new(true, true);
+
+        // prefer_polling = false so connection loop will try stream first.
+        let consumer = Consumer::new(transport, false, ());
+
+        // Subscribe to Foo(7)
+        let mut sub = consumer
+            .subscribe(SubscriptionReq::Foo("t".to_owned(), 7))
+            .expect("subscribe ok");
+
+        // We should see a Subscribe(name, topic) forwarded to transport
+        let ctrl = expect_ctrl(
+            &mut ctrl_rx,
+            1000,
+            |m| matches!(m, StreamCtrl::Subscribe((_, idx)) if *idx == IndexTest::Foo(7)),
+        )
+        .await;
+        match ctrl {
+            StreamCtrl::Subscribe((name, idx)) => {
+                assert_ne!(name, "t".to_owned());
+                assert_eq!(idx, IndexTest::Foo(7));
+            }
+            _ => unreachable!(),
+        }
+
+        // Send an event that matches Foo(7)
+        events_tx.send(Message { foo: 7, bar: 1 }).await.unwrap();
+        let got = recv_next::<TestTransport>(&mut sub, 1000)
+            .await
+            .expect("got event");
+        assert_eq!(got, Message { foo: 7, bar: 1 });
+
+        // Dropping the RemoteActiveConsumer should trigger an Unsubscribe(name)
+        drop(sub);
+        let _ctrl = expect_ctrl(&mut ctrl_rx, 1000, |m| {
+            matches!(m, StreamCtrl::Unsubscribe(_))
+        })
+        .await;
+
+        // Drop the Consumer -> Stop is sent so the transport loop exits cleanly
+        drop(consumer);
+        let _ = expect_ctrl(&mut ctrl_rx, 1000, |m| matches!(m, StreamCtrl::Stop)).await;
+    }
+
+    #[tokio::test]
+    async fn test_cache_and_invalation() {
+        // stream supported, poll supported (doesn't matter; prefer long)
+        let (transport, events_tx, mut ctrl_rx) = TestTransport::new(true, true);
+
+        // prefer_polling = false so connection loop will try stream first.
+        let consumer = Consumer::new(transport, false, ());
+
+        // Subscribe to Foo(7)
+        let mut sub_1 = consumer
+            .subscribe(SubscriptionReq::Foo("t".to_owned(), 7))
+            .expect("subscribe ok");
+
+        // We should see a Subscribe(name, topic) forwarded to transport
+        let ctrl = expect_ctrl(
+            &mut ctrl_rx,
+            1000,
+            |m| matches!(m, StreamCtrl::Subscribe((_, idx)) if *idx == IndexTest::Foo(7)),
+        )
+        .await;
+        match ctrl {
+            StreamCtrl::Subscribe((name, idx)) => {
+                assert_ne!(name, "t1".to_owned());
+                assert_eq!(idx, IndexTest::Foo(7));
+            }
+            _ => unreachable!(),
+        }
+
+        // Send an event that matches Foo(7)
+        events_tx.send(Message { foo: 7, bar: 1 }).await.unwrap();
+        let got = recv_next::<TestTransport>(&mut sub_1, 1000)
+            .await
+            .expect("got event");
+        assert_eq!(got, Message { foo: 7, bar: 1 });
+
+        // Subscribe to Foo(7), should receive the latest message and future messages
+        let mut sub_2 = consumer
+            .subscribe(SubscriptionReq::Foo("t2".to_owned(), 7))
+            .expect("subscribe ok");
+
+        let got = recv_next::<TestTransport>(&mut sub_2, 1000)
+            .await
+            .expect("got event");
+        assert_eq!(got, Message { foo: 7, bar: 1 });
+
+        // Dropping the RemoteActiveConsumer but not unsubscribe, since sub_2 is still active
+        drop(sub_1);
+
+        // Subscribe to Foo(7), should receive the latest message and future messages
+        let mut sub_3 = consumer
+            .subscribe(SubscriptionReq::Foo("t3".to_owned(), 7))
+            .expect("subscribe ok");
+
+        // receive cache message
+        let got = recv_next::<TestTransport>(&mut sub_3, 1000)
+            .await
+            .expect("got event");
+        assert_eq!(got, Message { foo: 7, bar: 1 });
+
+        // Send an event that matches Foo(7)
+        events_tx.send(Message { foo: 7, bar: 2 }).await.unwrap();
+
+        // receive new message
+        let got = recv_next::<TestTransport>(&mut sub_2, 1000)
+            .await
+            .expect("got event");
+        assert_eq!(got, Message { foo: 7, bar: 2 });
+
+        let got = recv_next::<TestTransport>(&mut sub_3, 1000)
+            .await
+            .expect("got event");
+        assert_eq!(got, Message { foo: 7, bar: 2 });
+
+        drop(sub_2);
+        drop(sub_3);
+
+        let _ctrl = expect_ctrl(&mut ctrl_rx, 1000, |m| {
+            matches!(m, StreamCtrl::Unsubscribe(_))
+        })
+        .await;
+
+        // The cache should be dropped, so no new messages
+        let mut sub_4 = consumer
+            .subscribe(SubscriptionReq::Foo("t4".to_owned(), 7))
+            .expect("subscribe ok");
+
+        assert!(
+            recv_next::<TestTransport>(&mut sub_4, 1000).await.is_none(),
+            "Should have not receive any update"
+        );
+
+        drop(sub_4);
+
+        // Drop the Consumer -> Stop is sent so the transport loop exits cleanly
+        let _ = expect_ctrl(&mut ctrl_rx, 2000, |m| matches!(m, StreamCtrl::Stop)).await;
+    }
+
+    #[tokio::test]
+    async fn falls_back_to_poll_when_stream_not_supported() {
+        // stream NOT supported, poll supported
+        let (transport, events_tx, _) = TestTransport::new(false, true);
+        // prefer_polling = true nudges the connection loop to poll first, but even if it
+        // tried stream, our transport returns NotSupported and the loop will use poll.
+        let consumer = Consumer::new(transport, true, ());
+
+        // Subscribe to Bar(5)
+        let mut sub = consumer
+            .subscribe(SubscriptionReq::Bar("t".to_owned(), 5))
+            .expect("subscribe ok");
+
+        // Inject an event; the poll path should relay it on the first poll iteration
+        events_tx.send(Message { foo: 9, bar: 5 }).await.unwrap();
+        let got = recv_next::<TestTransport>(&mut sub, 1500)
+            .await
+            .expect("event relayed via polling");
+        assert_eq!(got, Message { foo: 9, bar: 5 });
+    }
+
+    #[tokio::test]
+    async fn multiple_subscribers_share_single_remote_subscription() {
+        // This validates the "coalescing" behavior in Consumer::subscribe where multiple local
+        // subscribers to the same Topic should only create one remote subscription.
+        let (transport, events_tx, mut ctrl_rx) = TestTransport::new(true, true);
+        let consumer = Consumer::new(transport, false, ());
+
+        // Two local subscriptions to the SAME topic/name pair (different names)
+        let mut a = consumer
+            .subscribe(SubscriptionReq::Foo("t".to_owned(), 1))
+            .expect("subscribe A");
+        let _ = expect_ctrl(
+            &mut ctrl_rx,
+            1000,
+            |m| matches!(m, StreamCtrl::Subscribe((_, idx)) if  *idx == IndexTest::Foo(1)),
+        )
+        .await;
+
+        let mut b = consumer
+            .subscribe(SubscriptionReq::Foo("b".to_owned(), 1))
+            .expect("subscribe B");
+
+        // No second Subscribe should be forwarded for the same topic (coalesced).
+        // Give a little time; if one appears, we'll fail explicitly.
+        if let Ok(Some(StreamCtrl::Subscribe((_, idx)))) =
+            timeout(Duration::from_millis(400), ctrl_rx.recv()).await
+        {
+            assert_ne!(idx, IndexTest::Foo(1), "should not resubscribe same topic");
+        }
+
+        // Send one event and ensure BOTH local subscribers receive it.
+        events_tx.send(Message { foo: 1, bar: 42 }).await.unwrap();
+        let got_a = recv_next::<TestTransport>(&mut a, 1000)
+            .await
+            .expect("A got");
+        let got_b = recv_next::<TestTransport>(&mut b, 1000)
+            .await
+            .expect("B got");
+        assert_eq!(got_a, Message { foo: 1, bar: 42 });
+        assert_eq!(got_b, Message { foo: 1, bar: 42 });
+
+        // Drop B: no Unsubscribe should be sent yet (still one local subscriber).
+        drop(b);
+        if let Ok(Some(StreamCtrl::Unsubscribe(_))) =
+            timeout(Duration::from_millis(400), ctrl_rx.recv()).await
+        {
+            panic!("Should NOT unsubscribe while another local subscriber exists");
+        }
+
+        // Drop A: now remote unsubscribe should occur.
+        drop(a);
+        let _ = expect_ctrl(&mut ctrl_rx, 1000, |m| {
+            matches!(m, StreamCtrl::Unsubscribe(_))
+        })
+        .await;
+
+        let _ = expect_ctrl(&mut ctrl_rx, 1000, |m| matches!(m, StreamCtrl::Stop)).await;
+    }
+}

+ 159 - 0
crates/cdk-common/src/pub_sub/subscriber.rs

@@ -0,0 +1,159 @@
+//! Active subscription
+use std::fmt::Debug;
+use std::sync::atomic::AtomicUsize;
+use std::sync::{Arc, Mutex};
+
+use tokio::sync::mpsc;
+
+use super::pubsub::{SubReceiver, TopicTree};
+use super::{Error, Spec};
+
+/// Subscription request
+pub trait SubscriptionRequest {
+    /// Topics
+    type Topic;
+
+    /// Subscription Id
+    type SubscriptionId;
+
+    /// Try to get topics from the request
+    fn try_get_topics(&self) -> Result<Vec<Self::Topic>, Error>;
+
+    /// Get the subscription name
+    fn subscription_name(&self) -> Arc<Self::SubscriptionId>;
+}
+
+/// Active Subscription
+pub struct ActiveSubscription<S>
+where
+    S: Spec + 'static,
+{
+    id: usize,
+    name: Arc<S::SubscriptionId>,
+    active_subscribers: Arc<AtomicUsize>,
+    topics: TopicTree<S>,
+    subscribed_to: Vec<S::Topic>,
+    receiver: Option<SubReceiver<S>>,
+}
+
+impl<S> ActiveSubscription<S>
+where
+    S: Spec + 'static,
+{
+    /// Creates a new instance
+    pub fn new(
+        id: usize,
+        name: Arc<S::SubscriptionId>,
+        active_subscribers: Arc<AtomicUsize>,
+        topics: TopicTree<S>,
+        subscribed_to: Vec<S::Topic>,
+        receiver: Option<SubReceiver<S>>,
+    ) -> Self {
+        Self {
+            id,
+            name,
+            active_subscribers,
+            subscribed_to,
+            topics,
+            receiver,
+        }
+    }
+
+    /// Receives the next event
+    pub async fn recv(&mut self) -> Option<S::Event> {
+        self.receiver.as_mut()?.recv().await.map(|(_, event)| event)
+    }
+
+    /// Try receive an event or return Noen right away
+    pub fn try_recv(&mut self) -> Option<S::Event> {
+        self.receiver
+            .as_mut()?
+            .try_recv()
+            .ok()
+            .map(|(_, event)| event)
+    }
+
+    /// Get the subscription name
+    pub fn name(&self) -> &S::SubscriptionId {
+        &self.name
+    }
+}
+
+impl<S> Drop for ActiveSubscription<S>
+where
+    S: Spec + 'static,
+{
+    fn drop(&mut self) {
+        // remove the listener
+        let mut topics = self.topics.write().unwrap();
+        for index in self.subscribed_to.drain(..) {
+            topics.remove(&(index, self.id));
+        }
+
+        // decrement the number of active subscribers
+        self.active_subscribers
+            .fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
+    }
+}
+
+/// Lightweight sink used by producers to send events to subscribers.
+///
+/// You usually do not construct a `Subscriber` directly — it is provided to you in
+/// the [`Topic::fetch_events`] callback so you can backfill a new subscription.
+#[derive(Debug)]
+pub struct Subscriber<S>
+where
+    S: Spec + 'static,
+{
+    subscription: Arc<S::SubscriptionId>,
+    inner: mpsc::Sender<(Arc<S::SubscriptionId>, S::Event)>,
+    latest: Arc<Mutex<Option<S::Event>>>,
+}
+
+impl<S> Clone for Subscriber<S>
+where
+    S: Spec + 'static,
+{
+    fn clone(&self) -> Self {
+        Self {
+            subscription: self.subscription.clone(),
+            inner: self.inner.clone(),
+            latest: self.latest.clone(),
+        }
+    }
+}
+
+impl<S> Subscriber<S>
+where
+    S: Spec + 'static,
+{
+    /// Create a new instance
+    pub fn new(
+        subscription: Arc<S::SubscriptionId>,
+        inner: &mpsc::Sender<(Arc<S::SubscriptionId>, S::Event)>,
+    ) -> Self {
+        Self {
+            inner: inner.clone(),
+            subscription,
+            latest: Arc::new(Mutex::new(None)),
+        }
+    }
+
+    /// Send a message
+    pub fn send(&self, event: S::Event) {
+        let mut latest = if let Ok(reader) = self.latest.lock() {
+            reader
+        } else {
+            let _ = self.inner.try_send((self.subscription.to_owned(), event));
+            return;
+        };
+
+        if let Some(last_event) = latest.replace(event.clone()) {
+            if last_event == event {
+                return;
+            }
+        }
+
+        let _ = self.inner.try_send((self.subscription.to_owned(), event));
+    }
+}

+ 80 - 0
crates/cdk-common/src/pub_sub/types.rs

@@ -0,0 +1,80 @@
+//! Pubsub Event definition
+//!
+//! The Pubsub Event defines the Topic struct and how an event can be converted to Topics.
+
+use std::hash::Hash;
+use std::sync::Arc;
+
+use serde::de::DeserializeOwned;
+use serde::Serialize;
+
+use super::Subscriber;
+
+/// Pubsub settings
+#[async_trait::async_trait]
+pub trait Spec: Send + Sync {
+    /// Topic
+    type Topic: Send
+        + Sync
+        + Clone
+        + Eq
+        + PartialEq
+        + Ord
+        + PartialOrd
+        + Hash
+        + Send
+        + Sync
+        + DeserializeOwned
+        + Serialize;
+
+    /// Event
+    type Event: Event<Topic = Self::Topic>
+        + Send
+        + Sync
+        + Eq
+        + PartialEq
+        + DeserializeOwned
+        + Serialize;
+
+    /// Subscription Id
+    type SubscriptionId: Clone
+        + Default
+        + Eq
+        + PartialEq
+        + Ord
+        + PartialOrd
+        + Hash
+        + Send
+        + Sync
+        + DeserializeOwned
+        + Serialize;
+
+    /// Create a new context
+    type Context;
+
+    /// Create a new instance from a given context
+    fn new_instance(context: Self::Context) -> Arc<Self>
+    where
+        Self: Sized;
+
+    /// Callback function that is called on new subscriptions, to back-fill optionally the previous
+    /// events
+    async fn fetch_events(
+        self: &Arc<Self>,
+        topics: Vec<<Self::Event as Event>::Topic>,
+        reply_to: Subscriber<Self>,
+    ) where
+        Self: Sized;
+}
+
+/// Event trait
+pub trait Event: Clone + Send + Sync + Eq + PartialEq + DeserializeOwned + Serialize {
+    /// Generic Topic
+    ///
+    /// It should be serializable/deserializable to be stored in the database layer and it should
+    /// also be sorted in a BTree for in-memory matching
+    type Topic;
+
+    /// To topics
+    fn get_topics(&self) -> Vec<Self::Topic>;
+}

+ 91 - 74
crates/cdk-common/src/subscription.rs

@@ -1,98 +1,115 @@
 //! Subscription types and traits
-#[cfg(feature = "mint")]
+use std::ops::Deref;
 use std::str::FromStr;
+use std::sync::Arc;
 
-use cashu::nut17::{self};
-#[cfg(feature = "mint")]
-use cashu::nut17::{Error, Kind, Notification};
-#[cfg(feature = "mint")]
+use cashu::nut17::{self, Kind, NotificationId};
 use cashu::quote_id::QuoteId;
-#[cfg(feature = "mint")]
-use cashu::{NotificationPayload, PublicKey};
-#[cfg(feature = "mint")]
+use cashu::PublicKey;
 use serde::{Deserialize, Serialize};
 
-#[cfg(feature = "mint")]
-use crate::pub_sub::index::{Index, Indexable, SubscriptionGlobalId};
-use crate::pub_sub::SubId;
+use crate::pub_sub::{Error, SubscriptionRequest};
 
-/// Subscription parameters.
+/// CDK/Mint Subscription parameters.
 ///
 /// This is a concrete type alias for `nut17::Params<SubId>`.
-pub type Params = nut17::Params<SubId>;
+pub type Params = nut17::Params<Arc<SubId>>;
 
-/// Wrapper around `nut17::Params` to implement `Indexable` for `Notification`.
-#[cfg(feature = "mint")]
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct IndexableParams(Params);
+impl SubscriptionRequest for Params {
+    type Topic = NotificationId<QuoteId>;
 
-#[cfg(feature = "mint")]
-impl From<Params> for IndexableParams {
-    fn from(params: Params) -> Self {
-        Self(params)
+    type SubscriptionId = SubId;
+
+    fn subscription_name(&self) -> Arc<Self::SubscriptionId> {
+        self.id.clone()
+    }
+
+    fn try_get_topics(&self) -> Result<Vec<Self::Topic>, Error> {
+        self.filters
+            .iter()
+            .map(|filter| match self.kind {
+                Kind::Bolt11MeltQuote => QuoteId::from_str(filter)
+                    .map(NotificationId::MeltQuoteBolt11)
+                    .map_err(|_| Error::ParsingError(filter.to_owned())),
+                Kind::Bolt11MintQuote => QuoteId::from_str(filter)
+                    .map(NotificationId::MintQuoteBolt11)
+                    .map_err(|_| Error::ParsingError(filter.to_owned())),
+                Kind::ProofState => PublicKey::from_str(filter)
+                    .map(NotificationId::ProofState)
+                    .map_err(|_| Error::ParsingError(filter.to_owned())),
+
+                Kind::Bolt12MintQuote => QuoteId::from_str(filter)
+                    .map(NotificationId::MintQuoteBolt12)
+                    .map_err(|_| Error::ParsingError(filter.to_owned())),
+            })
+            .collect::<Result<Vec<_>, _>>()
     }
 }
 
-#[cfg(feature = "mint")]
-impl TryFrom<IndexableParams> for Vec<Index<Notification>> {
-    type Error = Error;
-    fn try_from(params: IndexableParams) -> Result<Self, Self::Error> {
-        let sub_id: SubscriptionGlobalId = Default::default();
-        let params = params.0;
-        params
-            .filters
-            .into_iter()
+/// Subscriptions parameters for the wallet
+///
+/// This is because the Wallet can subscribe to non CDK quotes, where IDs are not constraint to
+/// QuoteId
+pub type WalletParams = nut17::Params<Arc<String>>;
+
+impl SubscriptionRequest for WalletParams {
+    type Topic = NotificationId<String>;
+
+    type SubscriptionId = String;
+
+    fn subscription_name(&self) -> Arc<Self::SubscriptionId> {
+        self.id.clone()
+    }
+
+    fn try_get_topics(&self) -> Result<Vec<Self::Topic>, Error> {
+        self.filters
+            .iter()
             .map(|filter| {
-                let idx = match params.kind {
-                    Kind::Bolt11MeltQuote => {
-                        Notification::MeltQuoteBolt11(QuoteId::from_str(&filter)?)
-                    }
-                    Kind::Bolt11MintQuote => {
-                        Notification::MintQuoteBolt11(QuoteId::from_str(&filter)?)
-                    }
-                    Kind::ProofState => Notification::ProofState(PublicKey::from_str(&filter)?),
-                    Kind::Bolt12MintQuote => {
-                        Notification::MintQuoteBolt12(QuoteId::from_str(&filter)?)
-                    }
-                };
-
-                Ok(Index::from((idx, params.id.clone(), sub_id)))
+                Ok(match self.kind {
+                    Kind::Bolt11MeltQuote => NotificationId::MeltQuoteBolt11(filter.to_owned()),
+                    Kind::Bolt11MintQuote => NotificationId::MintQuoteBolt11(filter.to_owned()),
+                    Kind::ProofState => PublicKey::from_str(filter)
+                        .map(NotificationId::ProofState)
+                        .map_err(|_| Error::ParsingError(filter.to_owned()))?,
+
+                    Kind::Bolt12MintQuote => NotificationId::MintQuoteBolt12(filter.to_owned()),
+                })
             })
-            .collect::<Result<_, _>>()
+            .collect::<Result<Vec<_>, _>>()
+    }
+}
+
+/// Subscription Id wrapper
+///
+/// This is the place to add some sane default (like a max length) to the
+/// subscription ID
+#[derive(Debug, Clone, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
+pub struct SubId(String);
+
+impl From<&str> for SubId {
+    fn from(s: &str) -> Self {
+        Self(s.to_string())
     }
 }
 
-#[cfg(feature = "mint")]
-impl AsRef<SubId> for IndexableParams {
-    fn as_ref(&self) -> &SubId {
-        &self.0.id
+impl From<String> for SubId {
+    fn from(s: String) -> Self {
+        Self(s)
     }
 }
 
-#[cfg(feature = "mint")]
-impl Indexable for NotificationPayload<QuoteId> {
-    type Type = Notification;
-
-    fn to_indexes(&self) -> Vec<Index<Self::Type>> {
-        match self {
-            NotificationPayload::ProofState(proof_state) => {
-                vec![Index::from(Notification::ProofState(proof_state.y))]
-            }
-            NotificationPayload::MeltQuoteBolt11Response(melt_quote) => {
-                vec![Index::from(Notification::MeltQuoteBolt11(
-                    melt_quote.quote.clone(),
-                ))]
-            }
-            NotificationPayload::MintQuoteBolt11Response(mint_quote) => {
-                vec![Index::from(Notification::MintQuoteBolt11(
-                    mint_quote.quote.clone(),
-                ))]
-            }
-            NotificationPayload::MintQuoteBolt12Response(mint_quote) => {
-                vec![Index::from(Notification::MintQuoteBolt12(
-                    mint_quote.quote.clone(),
-                ))]
-            }
-        }
+impl FromStr for SubId {
+    type Err = ();
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(Self(s.to_string()))
+    }
+}
+
+impl Deref for SubId {
+    type Target = String;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
     }
 }

+ 3 - 1
crates/cdk-common/src/ws.rs

@@ -2,6 +2,8 @@
 //!
 //! This module extends the `cashu` crate with types and functions for the CDK, using the correct
 //! expected ID types.
+use std::sync::Arc;
+
 #[cfg(feature = "mint")]
 use cashu::nut17::ws::JSON_RPC_VERSION;
 use cashu::nut17::{self};
@@ -10,7 +12,7 @@ use cashu::quote_id::QuoteId;
 #[cfg(feature = "mint")]
 use cashu::NotificationPayload;
 
-use crate::pub_sub::SubId;
+type SubId = Arc<crate::subscription::SubId>;
 
 /// Request to unsubscribe from a websocket subscription
 pub type WsUnsubscribeRequest = nut17::ws::WsUnsubscribeRequest<SubId>;

+ 1 - 2
crates/cdk-ffi/Cargo.toml

@@ -22,7 +22,7 @@ ctor = "0.2"
 futures = { workspace = true }
 once_cell = { workspace = true }
 rand = { workspace = true }
-serde = { workspace = true, features = ["derive"] }
+serde = { workspace = true, features = ["derive", "rc"] }
 serde_json = { workspace = true }
 thiserror = { workspace = true }
 tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
@@ -41,4 +41,3 @@ postgres = ["cdk-postgres"]
 [[bin]]
 name = "uniffi-bindgen"
 path = "src/bin/uniffi-bindgen.rs"
-

+ 12 - 20
crates/cdk-ffi/src/types/subscription.rs

@@ -1,6 +1,7 @@
 //! Subscription-related FFI types
+use std::sync::Arc;
 
-use cdk::pub_sub::SubId;
+use cdk::event::MintEvent;
 use serde::{Deserialize, Serialize};
 
 use super::proof::ProofStateUpdate;
@@ -53,21 +54,17 @@ pub struct SubscribeParams {
     pub id: Option<String>,
 }
 
-impl From<SubscribeParams> for cdk::nuts::nut17::Params<cdk::pub_sub::SubId> {
+impl From<SubscribeParams> for cdk::nuts::nut17::Params<Arc<String>> {
     fn from(params: SubscribeParams) -> Self {
-        let sub_id = params
-            .id
-            .map(|id| SubId::from(id.as_str()))
-            .unwrap_or_else(|| {
-                // Generate a random ID
-                let uuid = uuid::Uuid::new_v4();
-                SubId::from(uuid.to_string().as_str())
-            });
+        let sub_id = params.id.unwrap_or_else(|| {
+            // Generate a random ID
+            uuid::Uuid::new_v4().to_string()
+        });
 
         cdk::nuts::nut17::Params {
             kind: params.kind.into(),
             filters: params.filters,
-            id: sub_id,
+            id: Arc::new(sub_id),
         }
     }
 }
@@ -132,12 +129,7 @@ impl ActiveSubscription {
     /// Try to receive a notification without blocking
     pub async fn try_recv(&self) -> Result<Option<NotificationPayload>, FfiError> {
         let mut guard = self.inner.lock().await;
-        guard
-            .try_recv()
-            .map(|opt| opt.map(Into::into))
-            .map_err(|e| FfiError::Generic {
-                msg: format!("Failed to receive notification: {}", e),
-            })
+        Ok(guard.try_recv().map(Into::into))
     }
 }
 
@@ -156,9 +148,9 @@ pub enum NotificationPayload {
     },
 }
 
-impl From<cdk::nuts::NotificationPayload<String>> for NotificationPayload {
-    fn from(payload: cdk::nuts::NotificationPayload<String>) -> Self {
-        match payload {
+impl From<MintEvent<String>> for NotificationPayload {
+    fn from(payload: MintEvent<String>) -> Self {
+        match payload.into() {
             cdk::nuts::NotificationPayload::ProofState(states) => NotificationPayload::ProofState {
                 proof_states: vec![states.into()],
             },

+ 1 - 1
crates/cdk-ffi/src/wallet.rs

@@ -349,7 +349,7 @@ impl Wallet {
         &self,
         params: SubscribeParams,
     ) -> Result<std::sync::Arc<ActiveSubscription>, FfiError> {
-        let cdk_params: cdk::nuts::nut17::Params<cdk::pub_sub::SubId> = params.clone().into();
+        let cdk_params: cdk::nuts::nut17::Params<Arc<String>> = params.clone().into();
         let sub_id = cdk_params.id.to_string();
         let active_sub = self.inner.subscribe(cdk_params).await;
         Ok(std::sync::Arc::new(ActiveSubscription::new(

+ 10 - 14
crates/cdk-integration-tests/tests/integration_tests_pure.rs

@@ -13,6 +13,7 @@ use std::assert_eq;
 use std::collections::{HashMap, HashSet};
 use std::hash::RandomState;
 use std::str::FromStr;
+use std::sync::Arc;
 use std::time::Duration;
 
 use cashu::amount::SplitTarget;
@@ -24,7 +25,7 @@ use cashu::{
 };
 use cdk::mint::Mint;
 use cdk::nuts::nut00::ProofsMethods;
-use cdk::subscription::{IndexableParams, Params};
+use cdk::subscription::Params;
 use cdk::wallet::types::{TransactionDirection, TransactionId};
 use cdk::wallet::{ReceiveOptions, SendMemo, SendOptions};
 use cdk::Amount;
@@ -485,15 +486,11 @@ pub async fn test_p2pk_swap() {
 
     let mut listener = mint_bob
         .pubsub_manager()
-        .try_subscribe::<IndexableParams>(
-            Params {
-                kind: cdk::nuts::nut17::Kind::ProofState,
-                filters: public_keys_to_listen.clone(),
-                id: "test".into(),
-            }
-            .into(),
-        )
-        .await
+        .subscribe(Params {
+            kind: cdk::nuts::nut17::Kind::ProofState,
+            filters: public_keys_to_listen.clone(),
+            id: Arc::new("test".into()),
+        })
         .expect("valid subscription");
 
     match mint_bob.process_swap_request(swap_request).await {
@@ -520,9 +517,8 @@ pub async fn test_p2pk_swap() {
     sleep(Duration::from_secs(1)).await;
 
     let mut msgs = HashMap::new();
-    while let Ok((sub_id, msg)) = listener.try_recv() {
-        assert_eq!(sub_id, "test".into());
-        match msg {
+    while let Some(msg) = listener.try_recv() {
+        match msg.into_inner() {
             NotificationPayload::ProofState(ProofState { y, state, .. }) => {
                 msgs.entry(y.to_string())
                     .or_insert_with(Vec::new)
@@ -544,7 +540,7 @@ pub async fn test_p2pk_swap() {
         );
     }
 
-    assert!(listener.try_recv().is_err(), "no other event is happening");
+    assert!(listener.try_recv().is_none(), "no other event is happening");
     assert!(msgs.is_empty(), "Only expected key events are received");
 }
 

+ 2 - 2
crates/cdk-integration-tests/tests/regtest.rs

@@ -165,7 +165,7 @@ async fn test_websocket_connection() {
         .expect("timeout waiting for unpaid notification")
         .expect("No paid notification received");
 
-    match msg {
+    match msg.into_inner() {
         NotificationPayload::MintQuoteBolt11Response(response) => {
             assert_eq!(response.quote.to_string(), mint_quote.id);
             assert_eq!(response.state, MintQuoteState::Unpaid);
@@ -185,7 +185,7 @@ async fn test_websocket_connection() {
         .expect("timeout waiting for paid notification")
         .expect("No paid notification received");
 
-    match msg {
+    match msg.into_inner() {
         NotificationPayload::MintQuoteBolt11Response(response) => {
             assert_eq!(response.quote.to_string(), mint_quote.id);
             assert_eq!(response.state, MintQuoteState::Paid);

+ 0 - 2
crates/cdk/Cargo.toml

@@ -100,8 +100,6 @@ ring = { version = "0.17.14", features = ["wasm32_unknown_unknown_js"] }
 rustls = { workspace = true, optional = true }
 
 uuid = { workspace = true, features = ["js"] }
-wasm-bindgen = "0.2"
-wasm-bindgen-futures = "0.4"
 gloo-timers = { version = "0.3", features = ["futures"] }
 
 [[example]]

+ 127 - 0
crates/cdk/src/event.rs

@@ -0,0 +1,127 @@
+//! Mint event types
+use std::fmt::Debug;
+use std::hash::Hash;
+use std::ops::Deref;
+
+use cdk_common::nut17::NotificationId;
+use cdk_common::pub_sub::Event;
+use cdk_common::{
+    MeltQuoteBolt11Response, MintQuoteBolt11Response, MintQuoteBolt12Response, NotificationPayload,
+    ProofState,
+};
+use serde::de::DeserializeOwned;
+use serde::{Deserialize, Serialize};
+
+/// Simple wrapper over NotificationPayload<QuoteId> which is a foreign type
+#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(bound = "T: Serialize + DeserializeOwned")]
+pub struct MintEvent<T>(NotificationPayload<T>)
+where
+    T: Clone + Eq + PartialEq;
+
+impl<T> From<MintEvent<T>> for NotificationPayload<T>
+where
+    T: Clone + Eq + PartialEq,
+{
+    fn from(value: MintEvent<T>) -> Self {
+        value.0
+    }
+}
+
+impl<T> Deref for MintEvent<T>
+where
+    T: Clone + Eq + PartialEq,
+{
+    type Target = NotificationPayload<T>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl<T> From<ProofState> for MintEvent<T>
+where
+    T: Clone + Eq + PartialEq,
+{
+    fn from(value: ProofState) -> Self {
+        Self(NotificationPayload::ProofState(value))
+    }
+}
+
+impl<T> MintEvent<T>
+where
+    T: Clone + Eq + PartialEq,
+{
+    /// New instance
+    pub fn new(t: NotificationPayload<T>) -> Self {
+        Self(t)
+    }
+
+    /// Get inner
+    pub fn inner(&self) -> &NotificationPayload<T> {
+        &self.0
+    }
+
+    /// Into inner
+    pub fn into_inner(self) -> NotificationPayload<T> {
+        self.0
+    }
+}
+
+impl<T> From<NotificationPayload<T>> for MintEvent<T>
+where
+    T: Clone + Eq + PartialEq,
+{
+    fn from(value: NotificationPayload<T>) -> Self {
+        Self(value)
+    }
+}
+
+impl<T> From<MintQuoteBolt11Response<T>> for MintEvent<T>
+where
+    T: Clone + Eq + PartialEq,
+{
+    fn from(value: MintQuoteBolt11Response<T>) -> Self {
+        Self(NotificationPayload::MintQuoteBolt11Response(value))
+    }
+}
+
+impl<T> From<MeltQuoteBolt11Response<T>> for MintEvent<T>
+where
+    T: Clone + Eq + PartialEq,
+{
+    fn from(value: MeltQuoteBolt11Response<T>) -> Self {
+        Self(NotificationPayload::MeltQuoteBolt11Response(value))
+    }
+}
+
+impl<T> From<MintQuoteBolt12Response<T>> for MintEvent<T>
+where
+    T: Clone + Eq + PartialEq,
+{
+    fn from(value: MintQuoteBolt12Response<T>) -> Self {
+        Self(NotificationPayload::MintQuoteBolt12Response(value))
+    }
+}
+
+impl<T> Event for MintEvent<T>
+where
+    T: Clone + Serialize + DeserializeOwned + Debug + Ord + Hash + Send + Sync + Eq + PartialEq,
+{
+    type Topic = NotificationId<T>;
+
+    fn get_topics(&self) -> Vec<Self::Topic> {
+        vec![match &self.0 {
+            NotificationPayload::MeltQuoteBolt11Response(r) => {
+                NotificationId::MeltQuoteBolt11(r.quote.to_owned())
+            }
+            NotificationPayload::MintQuoteBolt11Response(r) => {
+                NotificationId::MintQuoteBolt11(r.quote.to_owned())
+            }
+            NotificationPayload::MintQuoteBolt12Response(r) => {
+                NotificationId::MintQuoteBolt12(r.quote.to_owned())
+            }
+            NotificationPayload::ProofState(p) => NotificationId::ProofState(p.y.to_owned()),
+        }]
+    }
+}

+ 9 - 8
crates/cdk/src/lib.rs

@@ -32,11 +32,9 @@ mod bip353;
 #[cfg(all(any(feature = "wallet", feature = "mint"), feature = "auth"))]
 mod oidc_client;
 
-#[cfg(all(any(feature = "wallet", feature = "mint"), feature = "auth"))]
-pub use oidc_client::OidcClient;
-
-pub mod pub_sub;
-
+#[cfg(feature = "mint")]
+#[doc(hidden)]
+pub use cdk_common::payment as cdk_payment;
 /// Re-export amount type
 #[doc(hidden)]
 pub use cdk_common::{
@@ -44,10 +42,11 @@ pub use cdk_common::{
     error::{self, Error},
     lightning_invoice, mint_url, nuts, secret, util, ws, Amount, Bolt11Invoice,
 };
-#[cfg(feature = "mint")]
-#[doc(hidden)]
-pub use cdk_common::{payment as cdk_payment, subscription};
+#[cfg(all(any(feature = "wallet", feature = "mint"), feature = "auth"))]
+pub use oidc_client::OidcClient;
 
+#[cfg(any(feature = "wallet", feature = "mint"))]
+pub mod event;
 pub mod fees;
 
 #[doc(hidden)]
@@ -69,6 +68,8 @@ pub use self::wallet::HttpClient;
 #[doc(hidden)]
 pub type Result<T, E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
 
+/// Re-export subscription
+pub use cdk_common::subscription;
 /// Re-export futures::Stream
 #[cfg(any(feature = "wallet", feature = "mint"))]
 pub use futures::{Stream, StreamExt};

+ 2 - 2
crates/cdk/src/mint/issue/mod.rs

@@ -322,12 +322,12 @@ impl Mint {
                 PaymentMethod::Bolt11 => {
                     let res: MintQuoteBolt11Response<QuoteId> = quote.clone().into();
                     self.pubsub_manager
-                        .broadcast(NotificationPayload::MintQuoteBolt11Response(res));
+                        .publish(NotificationPayload::MintQuoteBolt11Response(res));
                 }
                 PaymentMethod::Bolt12 => {
                     let res: MintQuoteBolt12Response<QuoteId> = quote.clone().try_into()?;
                     self.pubsub_manager
-                        .broadcast(NotificationPayload::MintQuoteBolt12Response(res));
+                        .publish(NotificationPayload::MintQuoteBolt12Response(res));
                 }
                 PaymentMethod::Custom(_) => {}
             }

+ 2 - 2
crates/cdk/src/mint/mod.rs

@@ -43,7 +43,7 @@ mod ln;
 mod melt;
 mod proof_writer;
 mod start_up_check;
-pub mod subscription;
+mod subscription;
 mod swap;
 mod verification;
 
@@ -206,7 +206,7 @@ impl Mint {
 
         Ok(Self {
             signatory,
-            pubsub_manager: Arc::new(localstore.clone().into()),
+            pubsub_manager: PubSubManager::new(localstore.clone()),
             localstore,
             #[cfg(feature = "auth")]
             oidc_client: computed_info.nuts.nut21.as_ref().map(|nut21| {

+ 244 - 0
crates/cdk/src/mint/subscription.rs

@@ -0,0 +1,244 @@
+//! Specific Subscription for the cdk crate
+
+use std::ops::Deref;
+use std::sync::Arc;
+
+use cdk_common::database::DynMintDatabase;
+use cdk_common::mint::MintQuote;
+use cdk_common::nut17::NotificationId;
+use cdk_common::pub_sub::{Pubsub, Spec, Subscriber};
+use cdk_common::subscription::SubId;
+use cdk_common::{
+    Amount, BlindSignature, MeltQuoteBolt11Response, MeltQuoteState, MintQuoteBolt11Response,
+    MintQuoteBolt12Response, MintQuoteState, PaymentMethod, ProofState, PublicKey, QuoteId,
+};
+
+use crate::event::MintEvent;
+
+/// Mint subtopics
+#[derive(Clone)]
+pub struct MintPubSubSpec {
+    db: DynMintDatabase,
+}
+
+impl MintPubSubSpec {
+    async fn get_events_from_db(
+        &self,
+        request: &[NotificationId<QuoteId>],
+    ) -> Result<Vec<MintEvent<QuoteId>>, String> {
+        let mut to_return = vec![];
+        let mut public_keys: Vec<PublicKey> = Vec::new();
+        let mut melt_queries = Vec::new();
+        let mut mint_queries = Vec::new();
+
+        for idx in request.iter() {
+            match idx {
+                NotificationId::ProofState(pk) => public_keys.push(*pk),
+                NotificationId::MeltQuoteBolt11(uuid) => {
+                    melt_queries.push(self.db.get_melt_quote(uuid))
+                }
+                NotificationId::MintQuoteBolt11(uuid) => {
+                    mint_queries.push(self.db.get_mint_quote(uuid))
+                }
+                NotificationId::MintQuoteBolt12(uuid) => {
+                    mint_queries.push(self.db.get_mint_quote(uuid))
+                }
+                NotificationId::MeltQuoteBolt12(uuid) => {
+                    melt_queries.push(self.db.get_melt_quote(uuid))
+                }
+            }
+        }
+
+        if !melt_queries.is_empty() {
+            to_return.extend(
+                futures::future::try_join_all(melt_queries)
+                    .await
+                    .map(|quotes| {
+                        quotes
+                            .into_iter()
+                            .filter_map(|quote| quote.map(|x| x.into()))
+                            .map(|x: MeltQuoteBolt11Response<QuoteId>| x.into())
+                            .collect::<Vec<_>>()
+                    })
+                    .map_err(|e| e.to_string())?,
+            );
+        }
+
+        if !mint_queries.is_empty() {
+            to_return.extend(
+                futures::future::try_join_all(mint_queries)
+                    .await
+                    .map(|quotes| {
+                        quotes
+                            .into_iter()
+                            .filter_map(|quote| {
+                                quote.and_then(|x| match x.payment_method {
+                                    PaymentMethod::Bolt11 => {
+                                        let response: MintQuoteBolt11Response<QuoteId> = x.into();
+                                        Some(response.into())
+                                    }
+                                    PaymentMethod::Bolt12 => match x.try_into() {
+                                        Ok(response) => {
+                                            let response: MintQuoteBolt12Response<QuoteId> =
+                                                response;
+                                            Some(response.into())
+                                        }
+                                        Err(_) => None,
+                                    },
+                                    PaymentMethod::Custom(_) => None,
+                                })
+                            })
+                            .collect::<Vec<_>>()
+                    })
+                    .map_err(|e| e.to_string())?,
+            );
+        }
+
+        if !public_keys.is_empty() {
+            to_return.extend(
+                self.db
+                    .get_proofs_states(public_keys.as_slice())
+                    .await
+                    .map_err(|e| e.to_string())?
+                    .into_iter()
+                    .enumerate()
+                    .filter_map(|(idx, state)| state.map(|state| (public_keys[idx], state).into()))
+                    .map(|state: ProofState| state.into()),
+            );
+        }
+
+        Ok(to_return)
+    }
+}
+
+#[async_trait::async_trait]
+impl Spec for MintPubSubSpec {
+    type SubscriptionId = SubId;
+
+    type Topic = NotificationId<QuoteId>;
+
+    type Event = MintEvent<QuoteId>;
+
+    type Context = DynMintDatabase;
+
+    fn new_instance(context: Self::Context) -> Arc<Self> {
+        Arc::new(Self { db: context })
+    }
+
+    async fn fetch_events(self: &Arc<Self>, topics: Vec<Self::Topic>, reply_to: Subscriber<Self>) {
+        for event in self
+            .get_events_from_db(&topics)
+            .await
+            .inspect_err(|err| tracing::error!("Error reading events from db {err:?}"))
+            .unwrap_or_default()
+        {
+            let _ = reply_to.send(event);
+        }
+    }
+}
+
+/// PubsubManager
+pub struct PubSubManager(Pubsub<MintPubSubSpec>);
+
+impl PubSubManager {
+    /// Create a new instance
+    pub fn new(db: DynMintDatabase) -> Arc<Self> {
+        Arc::new(Self(Pubsub::new(MintPubSubSpec::new_instance(db))))
+    }
+
+    /// Helper function to emit a ProofState status
+    pub fn proof_state<E: Into<ProofState>>(&self, event: E) {
+        self.publish(event.into());
+    }
+
+    /// Helper function to publish even of a mint quote being paid
+    pub fn mint_quote_issue(&self, mint_quote: &MintQuote, total_issued: Amount) {
+        match mint_quote.payment_method {
+            PaymentMethod::Bolt11 => {
+                self.mint_quote_bolt11_status(mint_quote.clone(), MintQuoteState::Issued);
+            }
+            PaymentMethod::Bolt12 => {
+                self.mint_quote_bolt12_status(
+                    mint_quote.clone(),
+                    mint_quote.amount_paid(),
+                    total_issued,
+                );
+            }
+            _ => {
+                // We don't send ws updates for unknown methods
+            }
+        }
+    }
+
+    /// Helper function to publish even of a mint quote being paid
+    pub fn mint_quote_payment(&self, mint_quote: &MintQuote, total_paid: Amount) {
+        match mint_quote.payment_method {
+            PaymentMethod::Bolt11 => {
+                self.mint_quote_bolt11_status(mint_quote.clone(), MintQuoteState::Paid);
+            }
+            PaymentMethod::Bolt12 => {
+                self.mint_quote_bolt12_status(
+                    mint_quote.clone(),
+                    total_paid,
+                    mint_quote.amount_issued(),
+                );
+            }
+            _ => {
+                // We don't send ws updates for unknown methods
+            }
+        }
+    }
+
+    /// Helper function to emit a MintQuoteBolt11Response status
+    pub fn mint_quote_bolt11_status<E: Into<MintQuoteBolt11Response<QuoteId>>>(
+        &self,
+        quote: E,
+        new_state: MintQuoteState,
+    ) {
+        let mut event = quote.into();
+        event.state = new_state;
+
+        self.publish(event);
+    }
+
+    /// Helper function to emit a MintQuoteBolt11Response status
+    pub fn mint_quote_bolt12_status<E: TryInto<MintQuoteBolt12Response<QuoteId>>>(
+        &self,
+        quote: E,
+        amount_paid: Amount,
+        amount_issued: Amount,
+    ) {
+        if let Ok(mut event) = quote.try_into() {
+            event.amount_paid = amount_paid;
+            event.amount_issued = amount_issued;
+
+            self.publish(event);
+        } else {
+            tracing::warn!("Could not convert quote to MintQuoteResponse");
+        }
+    }
+
+    /// Helper function to emit a MeltQuoteBolt11Response status
+    pub fn melt_quote_status<E: Into<MeltQuoteBolt11Response<QuoteId>>>(
+        &self,
+        quote: E,
+        payment_preimage: Option<String>,
+        change: Option<Vec<BlindSignature>>,
+        new_state: MeltQuoteState,
+    ) {
+        let mut quote = quote.into();
+        quote.state = new_state;
+        quote.paid = Some(new_state == MeltQuoteState::Paid);
+        quote.payment_preimage = payment_preimage;
+        quote.change = change;
+        self.publish(quote);
+    }
+}
+
+impl Deref for PubSubManager {
+    type Target = Pubsub<MintPubSubSpec>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}

+ 0 - 292
crates/cdk/src/mint/subscription/manager.rs

@@ -1,292 +0,0 @@
-//! Specific Subscription for the cdk crate
-use std::ops::Deref;
-
-use cdk_common::database::DynMintDatabase;
-use cdk_common::mint::MintQuote;
-use cdk_common::nut17::Notification;
-use cdk_common::quote_id::QuoteId;
-use cdk_common::{Amount, MintQuoteBolt12Response, NotificationPayload, PaymentMethod};
-
-use super::OnSubscription;
-use crate::nuts::{
-    BlindSignature, MeltQuoteBolt11Response, MeltQuoteState, MintQuoteBolt11Response,
-    MintQuoteState, ProofState,
-};
-use crate::pub_sub;
-
-/// Manager
-/// Publish–subscribe manager
-///
-/// Nut-17 implementation is system-wide and not only through the WebSocket, so
-/// it is possible for another part of the system to subscribe to events.
-pub struct PubSubManager(
-    pub_sub::Manager<NotificationPayload<QuoteId>, Notification, OnSubscription>,
-);
-
-#[allow(clippy::default_constructed_unit_structs)]
-impl Default for PubSubManager {
-    fn default() -> Self {
-        PubSubManager(OnSubscription::default().into())
-    }
-}
-
-impl From<DynMintDatabase> for PubSubManager {
-    fn from(val: DynMintDatabase) -> Self {
-        PubSubManager(OnSubscription(Some(val)).into())
-    }
-}
-
-impl Deref for PubSubManager {
-    type Target = pub_sub::Manager<NotificationPayload<QuoteId>, Notification, OnSubscription>;
-
-    fn deref(&self) -> &Self::Target {
-        &self.0
-    }
-}
-
-impl PubSubManager {
-    /// Helper function to emit a ProofState status
-    pub fn proof_state<E: Into<ProofState>>(&self, event: E) {
-        self.broadcast(event.into().into());
-    }
-
-    /// Helper function to publish even of a mint quote being paid
-    pub fn mint_quote_issue(&self, mint_quote: &MintQuote, total_issued: Amount) {
-        match mint_quote.payment_method {
-            PaymentMethod::Bolt11 => {
-                self.mint_quote_bolt11_status(mint_quote.clone(), MintQuoteState::Issued);
-            }
-            PaymentMethod::Bolt12 => {
-                self.mint_quote_bolt12_status(
-                    mint_quote.clone(),
-                    mint_quote.amount_paid(),
-                    total_issued,
-                );
-            }
-            _ => {
-                // We don't send ws updates for unknown methods
-            }
-        }
-    }
-
-    /// Helper function to publish even of a mint quote being paid
-    pub fn mint_quote_payment(&self, mint_quote: &MintQuote, total_paid: Amount) {
-        match mint_quote.payment_method {
-            PaymentMethod::Bolt11 => {
-                self.mint_quote_bolt11_status(mint_quote.clone(), MintQuoteState::Paid);
-            }
-            PaymentMethod::Bolt12 => {
-                self.mint_quote_bolt12_status(
-                    mint_quote.clone(),
-                    total_paid,
-                    mint_quote.amount_issued(),
-                );
-            }
-            _ => {
-                // We don't send ws updates for unknown methods
-            }
-        }
-    }
-
-    /// Helper function to emit a MintQuoteBolt11Response status
-    pub fn mint_quote_bolt11_status<E: Into<MintQuoteBolt11Response<QuoteId>>>(
-        &self,
-        quote: E,
-        new_state: MintQuoteState,
-    ) {
-        let mut event = quote.into();
-        event.state = new_state;
-
-        self.broadcast(event.into());
-    }
-
-    /// Helper function to emit a MintQuoteBolt11Response status
-    pub fn mint_quote_bolt12_status<E: TryInto<MintQuoteBolt12Response<QuoteId>>>(
-        &self,
-        quote: E,
-        amount_paid: Amount,
-        amount_issued: Amount,
-    ) {
-        if let Ok(mut event) = quote.try_into() {
-            event.amount_paid = amount_paid;
-            event.amount_issued = amount_issued;
-
-            self.broadcast(event.into());
-        } else {
-            tracing::warn!("Could not convert quote to MintQuoteResponse");
-        }
-    }
-
-    /// Helper function to emit a MeltQuoteBolt11Response status
-    pub fn melt_quote_status<E: Into<MeltQuoteBolt11Response<QuoteId>>>(
-        &self,
-        quote: E,
-        payment_preimage: Option<String>,
-        change: Option<Vec<BlindSignature>>,
-        new_state: MeltQuoteState,
-    ) {
-        let mut quote = quote.into();
-        quote.state = new_state;
-        quote.paid = Some(new_state == MeltQuoteState::Paid);
-        quote.payment_preimage = payment_preimage;
-        quote.change = change;
-        self.broadcast(quote.into());
-    }
-}
-
-#[cfg(test)]
-mod test {
-    use std::time::Duration;
-
-    use tokio::time::sleep;
-
-    use super::*;
-    use crate::nuts::nut17::Kind;
-    use crate::nuts::{PublicKey, State};
-    use crate::subscription::{IndexableParams, Params};
-
-    #[tokio::test]
-    async fn active_and_drop() {
-        let manager = PubSubManager::default();
-        let params: IndexableParams = Params {
-            kind: Kind::ProofState,
-            filters: vec![
-                "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2".to_owned(),
-            ],
-            id: "uno".into(),
-        }
-        .into();
-
-        // Although the same param is used, two subscriptions are created, that
-        // is because each index is unique, thanks to `Unique`, it is the
-        // responsibility of the implementor to make sure that SubId are unique
-        // either globally or per client
-        let subscriptions = vec![
-            manager
-                .try_subscribe(params.clone())
-                .await
-                .expect("valid subscription"),
-            manager
-                .try_subscribe(params)
-                .await
-                .expect("valid subscription"),
-        ];
-        assert_eq!(2, manager.active_subscriptions());
-        drop(subscriptions);
-
-        sleep(Duration::from_millis(10)).await;
-
-        assert_eq!(0, manager.active_subscriptions());
-    }
-
-    #[tokio::test]
-    async fn broadcast() {
-        let manager = PubSubManager::default();
-        let mut subscriptions = [
-            manager
-                .try_subscribe::<IndexableParams>(
-                    Params {
-                        kind: Kind::ProofState,
-                        filters: vec![
-                            "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104"
-                                .to_string(),
-                        ],
-                        id: "uno".into(),
-                    }
-                    .into(),
-                )
-                .await
-                .expect("valid subscription"),
-            manager
-                .try_subscribe::<IndexableParams>(
-                    Params {
-                        kind: Kind::ProofState,
-                        filters: vec![
-                            "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104"
-                                .to_string(),
-                        ],
-                        id: "dos".into(),
-                    }
-                    .into(),
-                )
-                .await
-                .expect("valid subscription"),
-        ];
-
-        let event = ProofState {
-            y: PublicKey::from_hex(
-                "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104",
-            )
-            .expect("valid pk"),
-            state: State::Pending,
-            witness: None,
-        };
-
-        manager.broadcast(event.into());
-
-        sleep(Duration::from_millis(10)).await;
-
-        let (sub1, _) = subscriptions[0].try_recv().expect("valid message");
-        assert_eq!("uno", *sub1);
-
-        let (sub1, _) = subscriptions[1].try_recv().expect("valid message");
-        assert_eq!("dos", *sub1);
-
-        assert!(subscriptions[0].try_recv().is_err());
-        assert!(subscriptions[1].try_recv().is_err());
-    }
-
-    #[test]
-    fn parsing_request() {
-        let json = r#"{"kind":"proof_state","filters":["x"],"subId":"uno"}"#;
-        let params: Params = serde_json::from_str(json).expect("valid json");
-        assert_eq!(params.kind, Kind::ProofState);
-        assert_eq!(params.filters, vec!["x"]);
-        assert_eq!(*params.id, "uno");
-    }
-
-    #[tokio::test]
-    async fn json_test() {
-        let manager = PubSubManager::default();
-        let mut subscription = manager
-            .try_subscribe::<IndexableParams>(
-                serde_json::from_str(r#"{"kind":"proof_state","filters":["02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104"],"subId":"uno"}"#)
-                    .expect("valid json"),
-            )
-            .await.expect("valid subscription");
-
-        manager.broadcast(
-            ProofState {
-                y: PublicKey::from_hex(
-                    "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104",
-                )
-                .expect("valid pk"),
-                state: State::Pending,
-                witness: None,
-            }
-            .into(),
-        );
-
-        // no one is listening for this event
-        manager.broadcast(
-            ProofState {
-                y: PublicKey::from_hex(
-                    "020000000000000000000000000000000000000000000000000000000000000001",
-                )
-                .expect("valid pk"),
-                state: State::Pending,
-                witness: None,
-            }
-            .into(),
-        );
-
-        sleep(Duration::from_millis(10)).await;
-        let (sub1, msg) = subscription.try_recv().expect("valid message");
-        assert_eq!("uno", *sub1);
-        assert_eq!(
-            r#"{"Y":"02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104","state":"PENDING","witness":null}"#,
-            serde_json::to_string(&msg).expect("valid json")
-        );
-        assert!(subscription.try_recv().is_err());
-    }
-}

+ 0 - 12
crates/cdk/src/mint/subscription/mod.rs

@@ -1,12 +0,0 @@
-//! Specific Subscription for the cdk crate
-
-#[cfg(feature = "mint")]
-mod manager;
-#[cfg(feature = "mint")]
-mod on_subscription;
-#[cfg(feature = "mint")]
-pub use manager::PubSubManager;
-#[cfg(feature = "mint")]
-pub use on_subscription::OnSubscription;
-
-pub use crate::pub_sub::SubId;

+ 0 - 119
crates/cdk/src/mint/subscription/on_subscription.rs

@@ -1,119 +0,0 @@
-//! On Subscription
-//!
-//! This module contains the code that is triggered when a new subscription is created.
-
-use cdk_common::database::DynMintDatabase;
-use cdk_common::nut17::Notification;
-use cdk_common::pub_sub::OnNewSubscription;
-use cdk_common::quote_id::QuoteId;
-use cdk_common::{MintQuoteBolt12Response, NotificationPayload, PaymentMethod};
-
-use crate::nuts::{MeltQuoteBolt11Response, MintQuoteBolt11Response, ProofState, PublicKey};
-
-#[derive(Default)]
-/// Subscription Init
-///
-/// This struct triggers code when a new subscription is created.
-///
-/// It is used to send the initial state of the subscription to the client.
-pub struct OnSubscription(pub(crate) Option<DynMintDatabase>);
-
-#[async_trait::async_trait]
-impl OnNewSubscription for OnSubscription {
-    type Event = NotificationPayload<QuoteId>;
-    type Index = Notification;
-
-    async fn on_new_subscription(
-        &self,
-        request: &[&Self::Index],
-    ) -> Result<Vec<Self::Event>, String> {
-        let datastore = if let Some(localstore) = self.0.as_ref() {
-            localstore
-        } else {
-            return Ok(vec![]);
-        };
-
-        let mut to_return = vec![];
-        let mut public_keys: Vec<PublicKey> = Vec::new();
-        let mut melt_queries = Vec::new();
-        let mut mint_queries = Vec::new();
-
-        for idx in request.iter() {
-            match idx {
-                Notification::ProofState(pk) => public_keys.push(*pk),
-                Notification::MeltQuoteBolt11(uuid) => {
-                    melt_queries.push(datastore.get_melt_quote(uuid))
-                }
-                Notification::MintQuoteBolt11(uuid) => {
-                    mint_queries.push(datastore.get_mint_quote(uuid))
-                }
-                Notification::MintQuoteBolt12(uuid) => {
-                    mint_queries.push(datastore.get_mint_quote(uuid))
-                }
-                Notification::MeltQuoteBolt12(uuid) => {
-                    melt_queries.push(datastore.get_melt_quote(uuid))
-                }
-            }
-        }
-
-        if !melt_queries.is_empty() {
-            to_return.extend(
-                futures::future::try_join_all(melt_queries)
-                    .await
-                    .map(|quotes| {
-                        quotes
-                            .into_iter()
-                            .filter_map(|quote| quote.map(|x| x.into()))
-                            .map(|x: MeltQuoteBolt11Response<QuoteId>| x.into())
-                            .collect::<Vec<_>>()
-                    })
-                    .map_err(|e| e.to_string())?,
-            );
-        }
-
-        if !mint_queries.is_empty() {
-            to_return.extend(
-                futures::future::try_join_all(mint_queries)
-                    .await
-                    .map(|quotes| {
-                        quotes
-                            .into_iter()
-                            .filter_map(|quote| {
-                                quote.and_then(|x| match x.payment_method {
-                                    PaymentMethod::Bolt11 => {
-                                        let response: MintQuoteBolt11Response<QuoteId> = x.into();
-                                        Some(response.into())
-                                    }
-                                    PaymentMethod::Bolt12 => match x.try_into() {
-                                        Ok(response) => {
-                                            let response: MintQuoteBolt12Response<QuoteId> =
-                                                response;
-                                            Some(response.into())
-                                        }
-                                        Err(_) => None,
-                                    },
-                                    PaymentMethod::Custom(_) => None,
-                                })
-                            })
-                            .collect::<Vec<_>>()
-                    })
-                    .map_err(|e| e.to_string())?,
-            );
-        }
-
-        if !public_keys.is_empty() {
-            to_return.extend(
-                datastore
-                    .get_proofs_states(public_keys.as_slice())
-                    .await
-                    .map_err(|e| e.to_string())?
-                    .into_iter()
-                    .enumerate()
-                    .filter_map(|(idx, state)| state.map(|state| (public_keys[idx], state).into()))
-                    .map(|state: ProofState| state.into()),
-            );
-        }
-
-        Ok(to_return)
-    }
-}

+ 0 - 339
crates/cdk/src/pub_sub.rs

@@ -1,339 +0,0 @@
-//! Publish–subscribe pattern.
-//!
-//! This is a generic implementation for
-//! [NUT-17](<https://github.com/cashubtc/nuts/blob/main/17.md>) with a type
-//! agnostic Publish-subscribe manager.
-//!
-//! The manager has a method for subscribers to subscribe to events with a
-//! generic type that must be converted to a vector of indexes.
-//!
-//! Events are also generic that should implement the `Indexable` trait.
-use std::cmp::Ordering;
-use std::collections::{BTreeMap, HashSet};
-use std::fmt::Debug;
-use std::ops::{Deref, DerefMut};
-use std::sync::atomic::{self, AtomicUsize};
-use std::sync::Arc;
-
-pub use cdk_common::pub_sub::index::{Index, Indexable, SubscriptionGlobalId};
-use cdk_common::pub_sub::OnNewSubscription;
-pub use cdk_common::pub_sub::SubId;
-use tokio::sync::{mpsc, RwLock};
-use tokio::task::JoinHandle;
-
-type IndexTree<T, I> = Arc<RwLock<BTreeMap<Index<I>, mpsc::Sender<(SubId, T)>>>>;
-
-/// Default size of the remove channel
-pub const DEFAULT_REMOVE_SIZE: usize = 10_000;
-
-/// Default channel size for subscription buffering
-pub const DEFAULT_CHANNEL_SIZE: usize = 10;
-
-/// Subscription manager
-///
-/// This object keep track of all subscription listener and it is also
-/// responsible for broadcasting events to all listeners
-///
-/// The content of the notification is not relevant to this scope and it is up
-/// to the application, therefore the generic T is used instead of a specific
-/// type
-pub struct Manager<T, I, F>
-where
-    T: Indexable<Type = I> + Clone + Send + Sync + 'static,
-    I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static,
-    F: OnNewSubscription<Index = I, Event = T> + Send + Sync + 'static,
-{
-    indexes: IndexTree<T, I>,
-    on_new_subscription: Option<Arc<F>>,
-    unsubscription_sender: mpsc::Sender<(SubId, Vec<Index<I>>)>,
-    active_subscriptions: Arc<AtomicUsize>,
-    background_subscription_remover: Option<JoinHandle<()>>,
-}
-
-impl<T, I, F> Default for Manager<T, I, F>
-where
-    T: Indexable<Type = I> + Clone + Send + Sync + 'static,
-    I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static,
-    F: OnNewSubscription<Index = I, Event = T> + Send + Sync + 'static,
-{
-    fn default() -> Self {
-        let (sender, receiver) = mpsc::channel(DEFAULT_REMOVE_SIZE);
-        let active_subscriptions: Arc<AtomicUsize> = Default::default();
-        let storage: IndexTree<T, I> = Arc::new(Default::default());
-
-        Self {
-            background_subscription_remover: Some(tokio::spawn(Self::remove_subscription(
-                receiver,
-                storage.clone(),
-                active_subscriptions.clone(),
-            ))),
-            on_new_subscription: None,
-            unsubscription_sender: sender,
-            active_subscriptions,
-            indexes: storage,
-        }
-    }
-}
-
-impl<T, I, F> From<F> for Manager<T, I, F>
-where
-    T: Indexable<Type = I> + Clone + Send + Sync + 'static,
-    I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static,
-    F: OnNewSubscription<Index = I, Event = T> + Send + Sync + 'static,
-{
-    fn from(value: F) -> Self {
-        let mut manager: Self = Default::default();
-        manager.on_new_subscription = Some(Arc::new(value));
-        manager
-    }
-}
-
-impl<T, I, F> Manager<T, I, F>
-where
-    T: Indexable<Type = I> + Clone + Send + Sync + 'static,
-    I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static,
-    F: OnNewSubscription<Index = I, Event = T> + Send + Sync + 'static,
-{
-    #[inline]
-    /// Broadcast an event to all listeners
-    ///
-    /// This function takes an Arc to the storage struct, the event_id, the kind
-    /// and the vent to broadcast
-    async fn broadcast_impl(storage: &IndexTree<T, I>, event: T) {
-        let index_storage = storage.read().await;
-        let mut sent = HashSet::new();
-        for index in event.to_indexes() {
-            for (key, sender) in index_storage.range(index.clone()..) {
-                if index.cmp_prefix(key) != Ordering::Equal {
-                    break;
-                }
-                let sub_id = key.unique_id();
-                if sent.contains(&sub_id) {
-                    continue;
-                }
-                sent.insert(sub_id);
-                let _ = sender.try_send((key.into(), event.clone()));
-            }
-        }
-    }
-
-    /// Broadcasts an event to all listeners
-    ///
-    /// This public method will not block the caller, it will spawn a new task
-    /// instead
-    pub fn broadcast(&self, event: T) {
-        let storage = self.indexes.clone();
-        tokio::spawn(async move {
-            Self::broadcast_impl(&storage, event).await;
-        });
-    }
-
-    /// Broadcasts an event to all listeners
-    ///
-    /// This method is async and will await for the broadcast to be completed
-    pub async fn broadcast_async(&self, event: T) {
-        Self::broadcast_impl(&self.indexes, event).await;
-    }
-
-    /// Specific of the subscription, this is the abstraction between `subscribe` and `try_subscribe`
-    #[inline(always)]
-    async fn subscribe_inner(
-        &self,
-        sub_id: SubId,
-        indexes: Vec<Index<I>>,
-    ) -> ActiveSubscription<T, I> {
-        let (sender, receiver) = mpsc::channel(10);
-
-        let mut index_storage = self.indexes.write().await;
-        // Subscribe to events as soon as possible
-        for index in indexes.clone() {
-            index_storage.insert(index, sender.clone());
-        }
-        drop(index_storage);
-
-        if let Some(on_new_subscription) = self.on_new_subscription.clone() {
-            // After we're subscribed already, fetch the current status of matching events. It is
-            // down in another thread to return right away
-            let indexes_for_worker = indexes.clone();
-            let sub_id_for_worker = sub_id.clone();
-            tokio::spawn(async move {
-                match on_new_subscription
-                    .on_new_subscription(
-                        &indexes_for_worker
-                            .iter()
-                            .map(|x| x.deref())
-                            .collect::<Vec<_>>(),
-                    )
-                    .await
-                {
-                    Ok(events) => {
-                        for event in events {
-                            let _ = sender.try_send((sub_id_for_worker.clone(), event));
-                        }
-                    }
-                    Err(err) => {
-                        tracing::info!(
-                            "Failed to get initial state for subscription: {:?}, {}",
-                            sub_id_for_worker,
-                            err
-                        );
-                    }
-                }
-            });
-        }
-
-        self.active_subscriptions
-            .fetch_add(1, atomic::Ordering::Relaxed);
-
-        ActiveSubscription {
-            sub_id,
-            receiver,
-            indexes,
-            drop: self.unsubscription_sender.clone(),
-        }
-    }
-
-    /// Try to subscribe to a specific event
-    pub async fn try_subscribe<P>(&self, params: P) -> Result<ActiveSubscription<T, I>, P::Error>
-    where
-        P: AsRef<SubId> + TryInto<Vec<Index<I>>>,
-    {
-        Ok(self
-            .subscribe_inner(params.as_ref().clone(), params.try_into()?)
-            .await)
-    }
-
-    /// Subscribe to a specific event
-    pub async fn subscribe<P>(&self, params: P) -> ActiveSubscription<T, I>
-    where
-        P: AsRef<SubId> + Into<Vec<Index<I>>>,
-    {
-        self.subscribe_inner(params.as_ref().clone(), params.into())
-            .await
-    }
-
-    /// Return number of active subscriptions
-    pub fn active_subscriptions(&self) -> usize {
-        self.active_subscriptions.load(atomic::Ordering::SeqCst)
-    }
-
-    /// Task to remove dropped subscriptions from the storage struct
-    ///
-    /// This task will run in the background (and will be dropped when the [`Manager`]
-    /// is) and will remove subscriptions from the storage struct it is dropped.
-    async fn remove_subscription(
-        mut receiver: mpsc::Receiver<(SubId, Vec<Index<I>>)>,
-        storage: IndexTree<T, I>,
-        active_subscriptions: Arc<AtomicUsize>,
-    ) {
-        while let Some((sub_id, indexes)) = receiver.recv().await {
-            tracing::info!("Removing subscription: {}", *sub_id);
-
-            active_subscriptions.fetch_sub(1, atomic::Ordering::AcqRel);
-
-            let mut index_storage = storage.write().await;
-            for key in indexes {
-                index_storage.remove(&key);
-            }
-            drop(index_storage);
-        }
-    }
-}
-
-/// Manager goes out of scope, stop all background tasks
-impl<T, I, F> Drop for Manager<T, I, F>
-where
-    T: Indexable<Type = I> + Clone + Send + Sync + 'static,
-    I: Clone + Debug + PartialOrd + Ord + Send + Sync + 'static,
-    F: OnNewSubscription<Index = I, Event = T> + Send + Sync + 'static,
-{
-    fn drop(&mut self) {
-        if let Some(handler) = self.background_subscription_remover.take() {
-            handler.abort();
-        }
-    }
-}
-
-/// Active Subscription
-///
-/// This struct is a wrapper around the `mpsc::Receiver<Event>` and it also used
-/// to keep track of the subscription itself. When this struct goes out of
-/// scope, it will notify the Manager about it, so it can be removed from the
-/// list of active listeners
-pub struct ActiveSubscription<T, I>
-where
-    T: Send + Sync,
-    I: Clone + Debug + PartialOrd + Ord + Send + Sync + 'static,
-{
-    /// The subscription ID
-    pub sub_id: SubId,
-    indexes: Vec<Index<I>>,
-    receiver: mpsc::Receiver<(SubId, T)>,
-    drop: mpsc::Sender<(SubId, Vec<Index<I>>)>,
-}
-
-impl<T, I> Deref for ActiveSubscription<T, I>
-where
-    T: Send + Sync,
-    I: Clone + Debug + PartialOrd + Ord + Send + Sync + 'static,
-{
-    type Target = mpsc::Receiver<(SubId, T)>;
-
-    fn deref(&self) -> &Self::Target {
-        &self.receiver
-    }
-}
-
-impl<T, I> DerefMut for ActiveSubscription<T, I>
-where
-    T: Indexable + Clone + Send + Sync + 'static,
-    I: Clone + Debug + PartialOrd + Ord + Send + Sync + 'static,
-{
-    fn deref_mut(&mut self) -> &mut Self::Target {
-        &mut self.receiver
-    }
-}
-
-/// The ActiveSubscription is Drop out of scope, notify the Manager about it, so
-/// it can be removed from the list of active listeners
-///
-/// Having this in place, we can avoid memory leaks and also makes it super
-/// simple to implement the Unsubscribe method
-impl<T, I> Drop for ActiveSubscription<T, I>
-where
-    T: Send + Sync,
-    I: Clone + Debug + PartialOrd + Ord + Send + Sync + 'static,
-{
-    fn drop(&mut self) {
-        let _ = self
-            .drop
-            .try_send((self.sub_id.clone(), self.indexes.drain(..).collect()));
-    }
-}
-
-#[cfg(test)]
-mod test {
-    use tokio::sync::mpsc;
-
-    use super::*;
-
-    #[test]
-    fn test_active_subscription_drop() {
-        let (tx, rx) = mpsc::channel::<(SubId, ())>(10);
-        let sub_id = SubId::from("test_sub_id");
-        let indexes: Vec<Index<String>> = vec![Index::from(("test".to_string(), sub_id.clone()))];
-        let (drop_tx, mut drop_rx) = mpsc::channel(10);
-
-        {
-            let _active_subscription = ActiveSubscription {
-                sub_id: sub_id.clone(),
-                indexes,
-                receiver: rx,
-                drop: drop_tx,
-            };
-            // When it goes out of scope, it should notify
-        }
-        assert_eq!(drop_rx.try_recv().unwrap().0, sub_id); // it should have notified
-        assert!(tx.try_send(("foo".into(), ())).is_err()); // subscriber is dropped
-    }
-}

+ 22 - 20
crates/cdk/src/wallet/mod.rs

@@ -6,7 +6,7 @@ use std::sync::Arc;
 
 use cdk_common::amount::FeeAndAmounts;
 use cdk_common::database::{self, WalletDatabase};
-use cdk_common::subscription::Params;
+use cdk_common::subscription::WalletParams;
 use getrandom::getrandom;
 use subscription::{ActiveSubscription, SubscriptionManager};
 #[cfg(feature = "auth")]
@@ -108,40 +108,42 @@ pub enum WalletSubscription {
     Bolt12MintQuoteState(Vec<String>),
 }
 
-impl From<WalletSubscription> for Params {
+impl From<WalletSubscription> for WalletParams {
     fn from(val: WalletSubscription) -> Self {
         let mut buffer = vec![0u8; 10];
 
         getrandom(&mut buffer).expect("Failed to generate random bytes");
 
-        let id = buffer
-            .iter()
-            .map(|&byte| {
-                let index = byte as usize % ALPHANUMERIC.len(); // 62 alphanumeric characters (A-Z, a-z, 0-9)
-                ALPHANUMERIC[index] as char
-            })
-            .collect::<String>();
+        let id = Arc::new(
+            buffer
+                .iter()
+                .map(|&byte| {
+                    let index = byte as usize % ALPHANUMERIC.len(); // 62 alphanumeric characters (A-Z, a-z, 0-9)
+                    ALPHANUMERIC[index] as char
+                })
+                .collect::<String>(),
+        );
 
         match val {
-            WalletSubscription::ProofState(filters) => Params {
+            WalletSubscription::ProofState(filters) => WalletParams {
                 filters,
                 kind: Kind::ProofState,
-                id: id.into(),
+                id,
             },
-            WalletSubscription::Bolt11MintQuoteState(filters) => Params {
+            WalletSubscription::Bolt11MintQuoteState(filters) => WalletParams {
                 filters,
                 kind: Kind::Bolt11MintQuote,
-                id: id.into(),
+                id,
             },
-            WalletSubscription::Bolt11MeltQuoteState(filters) => Params {
+            WalletSubscription::Bolt11MeltQuoteState(filters) => WalletParams {
                 filters,
                 kind: Kind::Bolt11MeltQuote,
-                id: id.into(),
+                id,
             },
-            WalletSubscription::Bolt12MintQuoteState(filters) => Params {
+            WalletSubscription::Bolt12MintQuoteState(filters) => WalletParams {
                 filters,
                 kind: Kind::Bolt12MintQuote,
-                id: id.into(),
+                id,
             },
         }
     }
@@ -193,10 +195,10 @@ impl Wallet {
     }
 
     /// Subscribe to events
-    pub async fn subscribe<T: Into<Params>>(&self, query: T) -> ActiveSubscription {
+    pub async fn subscribe<T: Into<WalletParams>>(&self, query: T) -> ActiveSubscription {
         self.subscription
-            .subscribe(self.mint_url.clone(), query.into(), Arc::new(self.clone()))
-            .await
+            .subscribe(self.mint_url.clone(), query.into())
+            .expect("FIXME")
     }
 
     /// Fee required for proof set

+ 3 - 2
crates/cdk/src/wallet/multi_mint_wallet.rs

@@ -4,6 +4,7 @@
 //! pairs
 
 use std::collections::BTreeMap;
+use std::ops::Deref;
 use std::str::FromStr;
 use std::sync::Arc;
 
@@ -675,7 +676,7 @@ impl MultiMintWallet {
                     // Check if this is a mint quote response with paid state
                     if let crate::nuts::nut17::NotificationPayload::MintQuoteBolt11Response(
                         quote_response,
-                    ) = notification
+                    ) = notification.deref()
                     {
                         if quote_response.state == QuoteState::Paid {
                             // Quote is paid, now mint the tokens
@@ -1264,7 +1265,7 @@ impl MultiMintWallet {
     /// Melt (pay invoice) with automatic wallet selection (deprecated, use specific mint functions for better control)
     ///
     /// Automatically selects the best wallet to pay from based on:
-    /// - Available balance  
+    /// - Available balance
     /// - Fees
     ///
     /// # Examples

+ 3 - 2
crates/cdk/src/wallet/streams/payment.rs

@@ -13,10 +13,11 @@ use futures::{FutureExt, Stream, StreamExt};
 use tokio_util::sync::CancellationToken;
 
 use super::RecvFuture;
+use crate::event::MintEvent;
 use crate::wallet::subscription::ActiveSubscription;
 use crate::{Wallet, WalletSubscription};
 
-type SubscribeReceived = (Option<NotificationPayload<String>>, Vec<ActiveSubscription>);
+type SubscribeReceived = (Option<MintEvent<String>>, Vec<ActiveSubscription>);
 type PaymentValue = (String, Option<Amount>);
 
 /// PaymentWaiter
@@ -145,7 +146,7 @@ impl<'a> PaymentStream<'a> {
                         Poll::Ready(None)
                     }
                     Some(info) => {
-                        match info {
+                        match info.into_inner() {
                             NotificationPayload::MintQuoteBolt11Response(info) => {
                                 if info.state == MintQuoteState::Paid {
                                     self.is_finalized = true;

+ 0 - 238
crates/cdk/src/wallet/subscription/http.rs

@@ -1,238 +0,0 @@
-use std::collections::HashMap;
-use std::sync::Arc;
-
-use cdk_common::MintQuoteBolt12Response;
-use tokio::sync::{mpsc, RwLock};
-#[cfg(not(target_arch = "wasm32"))]
-use tokio::time;
-use web_time::Duration;
-
-use super::WsSubscriptionBody;
-use crate::nuts::nut17::Kind;
-use crate::nuts::{nut01, nut05, nut07, nut23, CheckStateRequest, NotificationPayload};
-use crate::pub_sub::SubId;
-use crate::wallet::MintConnector;
-use crate::Wallet;
-
-#[derive(Debug, Hash, PartialEq, Eq)]
-enum UrlType {
-    Mint(String),
-    MintBolt12(String),
-    Melt(String),
-    PublicKey(nut01::PublicKey),
-}
-
-#[derive(Debug, Eq, PartialEq)]
-enum AnyState {
-    MintQuoteState(nut23::QuoteState),
-    MintBolt12QuoteState(MintQuoteBolt12Response<String>),
-    MeltQuoteState(nut05::QuoteState),
-    PublicKey(nut07::State),
-    Empty,
-}
-
-type SubscribedTo = HashMap<UrlType, (mpsc::Sender<NotificationPayload<String>>, SubId, AnyState)>;
-
-async fn convert_subscription(
-    sub_id: SubId,
-    subscriptions: &Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
-    subscribed_to: &mut SubscribedTo,
-) -> Option<()> {
-    let subscription = subscriptions.read().await;
-    let sub = subscription.get(&sub_id)?;
-    tracing::debug!("New subscription: {:?}", sub);
-    match sub.1.kind {
-        Kind::Bolt11MintQuote => {
-            for id in sub.1.filters.iter().map(|id| UrlType::Mint(id.clone())) {
-                subscribed_to.insert(id, (sub.0.clone(), sub.1.id.clone(), AnyState::Empty));
-            }
-        }
-        Kind::Bolt11MeltQuote => {
-            for id in sub.1.filters.iter().map(|id| UrlType::Melt(id.clone())) {
-                subscribed_to.insert(id, (sub.0.clone(), sub.1.id.clone(), AnyState::Empty));
-            }
-        }
-        Kind::ProofState => {
-            for id in sub
-                .1
-                .filters
-                .iter()
-                .map(|id| nut01::PublicKey::from_hex(id).map(UrlType::PublicKey))
-            {
-                match id {
-                    Ok(id) => {
-                        subscribed_to
-                            .insert(id, (sub.0.clone(), sub.1.id.clone(), AnyState::Empty));
-                    }
-                    Err(err) => {
-                        tracing::error!("Error parsing public key: {:?}. Subscription ignored, will never yield any result", err);
-                    }
-                }
-            }
-        }
-        Kind::Bolt12MintQuote => {
-            for id in sub
-                .1
-                .filters
-                .iter()
-                .map(|id| UrlType::MintBolt12(id.clone()))
-            {
-                subscribed_to.insert(id, (sub.0.clone(), sub.1.id.clone(), AnyState::Empty));
-            }
-        }
-    }
-
-    Some(())
-}
-
-#[cfg(not(target_arch = "wasm32"))]
-#[inline]
-pub async fn http_main<S: IntoIterator<Item = SubId>>(
-    initial_state: S,
-    http_client: Arc<dyn MintConnector + Send + Sync>,
-    subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
-    mut new_subscription_recv: mpsc::Receiver<SubId>,
-    mut on_drop: mpsc::Receiver<SubId>,
-    _wallet: Arc<Wallet>,
-) {
-    let mut interval = time::interval(Duration::from_secs(2));
-    let mut subscribed_to = SubscribedTo::new();
-
-    for sub_id in initial_state {
-        convert_subscription(sub_id, &subscriptions, &mut subscribed_to).await;
-    }
-
-    loop {
-        tokio::select! {
-            _ = interval.tick() => {
-                poll_subscriptions(&http_client, &mut subscribed_to).await;
-            }
-            Some(subid) = new_subscription_recv.recv() => {
-                convert_subscription(subid, &subscriptions, &mut subscribed_to).await;
-            }
-            Some(id) = on_drop.recv() => {
-                subscribed_to.retain(|_, (_, sub_id, _)| *sub_id != id);
-            }
-        }
-    }
-}
-
-#[cfg(target_arch = "wasm32")]
-#[inline]
-pub async fn http_main<S: IntoIterator<Item = SubId>>(
-    initial_state: S,
-    http_client: Arc<dyn MintConnector + Send + Sync>,
-    subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
-    mut new_subscription_recv: mpsc::Receiver<SubId>,
-    mut on_drop: mpsc::Receiver<SubId>,
-    _wallet: Arc<Wallet>,
-) {
-    let mut subscribed_to = SubscribedTo::new();
-
-    for sub_id in initial_state {
-        convert_subscription(sub_id, &subscriptions, &mut subscribed_to).await;
-    }
-
-    loop {
-        tokio::select! {
-            _ = gloo_timers::future::sleep(Duration::from_secs(2)) => {
-                poll_subscriptions(&http_client, &mut subscribed_to).await;
-            }
-            subid = new_subscription_recv.recv() => {
-                match subid {
-                    Some(subid) => {
-                        convert_subscription(subid, &subscriptions, &mut subscribed_to).await;
-                    }
-                    None => {
-                        // New subscription channel closed - SubscriptionClient was dropped, terminate worker
-                        break;
-                    }
-                }
-            }
-            id = on_drop.recv() => {
-                match id {
-                    Some(id) => {
-                        subscribed_to.retain(|_, (_, sub_id, _)| *sub_id != id);
-                    }
-                    None => {
-                        // Drop notification channel closed - SubscriptionClient was dropped, terminate worker
-                        break;
-                    }
-                }
-            }
-        }
-    }
-}
-
-async fn poll_subscriptions(
-    http_client: &Arc<dyn MintConnector + Send + Sync>,
-    subscribed_to: &mut SubscribedTo,
-) {
-    for (url, (sender, _, last_state)) in subscribed_to.iter_mut() {
-        tracing::debug!("Polling: {:?}", url);
-        match url {
-            UrlType::MintBolt12(id) => {
-                let response = http_client.get_mint_quote_bolt12_status(id).await;
-                if let Ok(response) = response {
-                    if *last_state == AnyState::MintBolt12QuoteState(response.clone()) {
-                        continue;
-                    }
-                    *last_state = AnyState::MintBolt12QuoteState(response.clone());
-                    if let Err(err) =
-                        sender.try_send(NotificationPayload::MintQuoteBolt12Response(response))
-                    {
-                        tracing::error!("Error sending mint quote response: {:?}", err);
-                    }
-                }
-            }
-            UrlType::Mint(id) => {
-                let response = http_client.get_mint_quote_status(id).await;
-                if let Ok(response) = response {
-                    if *last_state == AnyState::MintQuoteState(response.state) {
-                        continue;
-                    }
-                    *last_state = AnyState::MintQuoteState(response.state);
-                    if let Err(err) =
-                        sender.try_send(NotificationPayload::MintQuoteBolt11Response(response))
-                    {
-                        tracing::error!("Error sending mint quote response: {:?}", err);
-                    }
-                }
-            }
-            UrlType::Melt(id) => {
-                let response = http_client.get_melt_quote_status(id).await;
-                if let Ok(response) = response {
-                    if *last_state == AnyState::MeltQuoteState(response.state) {
-                        continue;
-                    }
-                    *last_state = AnyState::MeltQuoteState(response.state);
-                    if let Err(err) =
-                        sender.try_send(NotificationPayload::MeltQuoteBolt11Response(response))
-                    {
-                        tracing::error!("Error sending melt quote response: {:?}", err);
-                    }
-                }
-            }
-            UrlType::PublicKey(id) => {
-                let responses = http_client
-                    .post_check_state(CheckStateRequest { ys: vec![*id] })
-                    .await;
-                if let Ok(mut responses) = responses {
-                    let response = if let Some(state) = responses.states.pop() {
-                        state
-                    } else {
-                        continue;
-                    };
-
-                    if *last_state == AnyState::PublicKey(response.state) {
-                        continue;
-                    }
-                    *last_state = AnyState::PublicKey(response.state);
-                    if let Err(err) = sender.try_send(NotificationPayload::ProofState(response)) {
-                        tracing::error!("Error sending proof state response: {:?}", err);
-                    }
-                }
-            }
-        }
-    }
-}

+ 233 - 271
crates/cdk/src/wallet/subscription/mod.rs

@@ -7,28 +7,32 @@
 //! the HTTP client.
 use std::collections::HashMap;
 use std::fmt::Debug;
-use std::sync::Arc;
-
-use cdk_common::subscription::Params;
-use tokio::sync::{mpsc, RwLock};
-use tokio::task::JoinHandle;
-#[cfg(target_arch = "wasm32")]
-use wasm_bindgen_futures;
-
-use super::Wallet;
+use std::sync::atomic::AtomicUsize;
+use std::sync::{Arc, RwLock};
+
+use cdk_common::nut17::ws::{WsMethodRequest, WsRequest, WsUnsubscribeRequest};
+use cdk_common::nut17::{Kind, NotificationId};
+use cdk_common::pub_sub::remote_consumer::{
+    Consumer, InternalRelay, RemoteActiveConsumer, StreamCtrl, SubscribeMessage, Transport,
+};
+use cdk_common::pub_sub::{Error as PubsubError, Spec, Subscriber};
+use cdk_common::subscription::WalletParams;
+use cdk_common::CheckStateRequest;
+use tokio::sync::mpsc;
+use uuid::Uuid;
+
+use crate::event::MintEvent;
 use crate::mint_url::MintUrl;
-use crate::pub_sub::SubId;
 use crate::wallet::MintConnector;
 
-mod http;
-#[cfg(all(
-    not(feature = "http_subscription"),
-    feature = "mint",
-    not(target_arch = "wasm32")
-))]
+#[cfg(not(target_arch = "wasm32"))]
 mod ws;
 
-type WsSubscriptionBody = (mpsc::Sender<NotificationPayload>, Params);
+/// Notification Payload
+pub type NotificationPayload = crate::nuts::NotificationPayload<String>;
+
+/// Type alias
+pub type ActiveSubscription = RemoteActiveConsumer<SubscriptionClient>;
 
 /// Subscription manager
 ///
@@ -45,13 +49,26 @@ type WsSubscriptionBody = (mpsc::Sender<NotificationPayload>, Params);
 /// The subscribers have a simple-to-use interface, receiving an
 /// ActiveSubscription struct, which can be used to receive updates and to
 /// unsubscribe from updates automatically on the drop.
-#[derive(Debug, Clone)]
+#[derive(Clone)]
 pub struct SubscriptionManager {
-    all_connections: Arc<RwLock<HashMap<MintUrl, SubscriptionClient>>>,
+    all_connections: Arc<RwLock<HashMap<MintUrl, Arc<Consumer<SubscriptionClient>>>>>,
     http_client: Arc<dyn MintConnector + Send + Sync>,
     prefer_http: bool,
 }
 
+impl Debug for SubscriptionManager {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "Subscription Manager connected to {:?}",
+            self.all_connections
+                .write()
+                .map(|connections| connections.keys().cloned().collect::<Vec<_>>())
+                .unwrap_or_default()
+        )
+    }
+}
+
 impl SubscriptionManager {
     /// Create a new subscription manager
     pub fn new(http_client: Arc<dyn MintConnector + Send + Sync>, prefer_http: bool) -> Self {
@@ -63,291 +80,236 @@ impl SubscriptionManager {
     }
 
     /// Subscribe to updates from a mint server with a given filter
-    pub async fn subscribe(
+    pub fn subscribe(
         &self,
         mint_url: MintUrl,
-        filter: Params,
-        wallet: Arc<Wallet>,
-    ) -> ActiveSubscription {
-        let subscription_clients = self.all_connections.read().await;
-        let id = filter.id.clone();
-        if let Some(subscription_client) = subscription_clients.get(&mint_url) {
-            let (on_drop_notif, receiver) = subscription_client.subscribe(filter).await;
-            ActiveSubscription::new(receiver, id, on_drop_notif)
-        } else {
-            drop(subscription_clients);
-
-            #[cfg(all(
-                not(feature = "http_subscription"),
-                feature = "mint",
-                not(target_arch = "wasm32")
-            ))]
-            let is_ws_support = self
-                .http_client
-                .get_mint_info()
-                .await
-                .map(|info| !info.nuts.nut17.supported.is_empty())
-                .unwrap_or_default();
-
-            #[cfg(any(
-                feature = "http_subscription",
-                not(feature = "mint"),
-                target_arch = "wasm32"
-            ))]
-            let is_ws_support = false;
-
-            let is_ws_support = if self.prefer_http {
-                false
-            } else {
-                is_ws_support
-            };
-
-            tracing::debug!(
-                "Connect to {:?} to subscribe. WebSocket is supported ({})",
-                mint_url,
-                is_ws_support
-            );
-
-            let mut subscription_clients = self.all_connections.write().await;
-            let subscription_client = SubscriptionClient::new(
-                mint_url.clone(),
-                self.http_client.clone(),
-                is_ws_support,
-                wallet,
-            );
-            let (on_drop_notif, receiver) = subscription_client.subscribe(filter).await;
-            subscription_clients.insert(mint_url, subscription_client);
-
-            ActiveSubscription::new(receiver, id, on_drop_notif)
-        }
+        filter: WalletParams,
+    ) -> Result<RemoteActiveConsumer<SubscriptionClient>, PubsubError> {
+        self.all_connections
+            .write()
+            .map_err(|_| PubsubError::Poison)?
+            .entry(mint_url.clone())
+            .or_insert_with(|| {
+                Consumer::new(
+                    SubscriptionClient {
+                        mint_url,
+                        http_client: self.http_client.clone(),
+                        req_id: 0.into(),
+                    },
+                    self.prefer_http,
+                    (),
+                )
+            })
+            .subscribe(filter)
     }
 }
 
-/// Subscription client
-///
-/// If the server supports WebSocket subscriptions, this client will be used,
-/// otherwise the HTTP pool and pause will be used (which is the less efficient
-/// method).
-#[derive(Debug)]
-pub struct SubscriptionClient {
-    new_subscription_notif: mpsc::Sender<SubId>,
-    on_drop_notif: mpsc::Sender<SubId>,
-    subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
-    worker: Option<JoinHandle<()>>,
-}
+/// MintSubTopics
+#[derive(Clone, Default)]
+pub struct MintSubTopics {}
 
-type NotificationPayload = crate::nuts::NotificationPayload<String>;
+#[async_trait::async_trait]
+impl Spec for MintSubTopics {
+    type SubscriptionId = String;
 
-/// Active Subscription
-pub struct ActiveSubscription {
-    sub_id: Option<SubId>,
-    on_drop_notif: mpsc::Sender<SubId>,
-    receiver: mpsc::Receiver<NotificationPayload>,
-}
+    type Event = MintEvent<String>;
 
-impl ActiveSubscription {
-    fn new(
-        receiver: mpsc::Receiver<NotificationPayload>,
-        sub_id: SubId,
-        on_drop_notif: mpsc::Sender<SubId>,
-    ) -> Self {
-        Self {
-            sub_id: Some(sub_id),
-            on_drop_notif,
-            receiver,
-        }
-    }
+    type Topic = NotificationId<String>;
 
-    /// Try to receive a notification
-    pub fn try_recv(&mut self) -> Result<Option<NotificationPayload>, Error> {
-        match self.receiver.try_recv() {
-            Ok(payload) => Ok(Some(payload)),
-            Err(mpsc::error::TryRecvError::Empty) => Ok(None),
-            Err(mpsc::error::TryRecvError::Disconnected) => Err(Error::Disconnected),
-        }
-    }
+    type Context = ();
 
-    /// Receive a notification asynchronously
-    pub async fn recv(&mut self) -> Option<NotificationPayload> {
-        self.receiver.recv().await
+    fn new_instance(_context: Self::Context) -> Arc<Self>
+    where
+        Self: Sized,
+    {
+        Arc::new(Self {})
     }
-}
 
-impl Drop for ActiveSubscription {
-    fn drop(&mut self) {
-        if let Some(sub_id) = self.sub_id.take() {
-            let _ = self.on_drop_notif.try_send(sub_id);
-        }
+    async fn fetch_events(self: &Arc<Self>, _topics: Vec<Self::Topic>, _reply_to: Subscriber<Self>)
+    where
+        Self: Sized,
+    {
     }
 }
 
-/// Subscription client error
-#[derive(thiserror::Error, Debug)]
-pub enum Error {
-    /// Url error
-    #[error("Could not join paths: {0}")]
-    Url(#[from] crate::mint_url::Error),
-    /// Disconnected from the notification channel
-    #[error("Disconnected from the notification channel")]
-    Disconnected,
+/// Subscription client
+///
+/// If the server supports WebSocket subscriptions, this client will be used,
+/// otherwise the HTTP pool and pause will be used (which is the less efficient
+/// method).
+#[derive(Debug)]
+#[allow(dead_code)]
+pub struct SubscriptionClient {
+    http_client: Arc<dyn MintConnector + Send + Sync>,
+    mint_url: MintUrl,
+    req_id: AtomicUsize,
 }
 
+#[allow(dead_code)]
 impl SubscriptionClient {
-    /// Create new [`SubscriptionClient`]
-    pub fn new(
-        url: MintUrl,
-        http_client: Arc<dyn MintConnector + Send + Sync>,
-        prefer_ws_method: bool,
-        wallet: Arc<Wallet>,
-    ) -> Self {
-        let subscriptions = Arc::new(RwLock::new(HashMap::new()));
-        let (new_subscription_notif, new_subscription_recv) = mpsc::channel(100);
-        let (on_drop_notif, on_drop_recv) = mpsc::channel(1000);
+    fn get_sub_request(
+        &self,
+        id: String,
+        params: NotificationId<String>,
+    ) -> Option<(usize, String)> {
+        let (kind, filter) = match params {
+            NotificationId::ProofState(x) => (Kind::ProofState, x.to_string()),
+            NotificationId::MeltQuoteBolt11(q) | NotificationId::MeltQuoteBolt12(q) => {
+                (Kind::Bolt11MeltQuote, q)
+            }
+            NotificationId::MintQuoteBolt11(q) => (Kind::Bolt11MintQuote, q),
+            NotificationId::MintQuoteBolt12(q) => (Kind::Bolt12MintQuote, q),
+        };
+
+        let request: WsRequest<_> = (
+            WsMethodRequest::Subscribe(WalletParams {
+                kind,
+                filters: vec![filter],
+                id: id.into(),
+            }),
+            self.req_id
+                .fetch_add(1, std::sync::atomic::Ordering::Relaxed),
+        )
+            .into();
+
+        serde_json::to_string(&request)
+            .inspect_err(|err| {
+                tracing::error!("Could not serialize subscribe message: {:?}", err);
+            })
+            .map(|json| (request.id, json))
+            .ok()
+    }
 
-        Self {
-            new_subscription_notif,
-            on_drop_notif,
-            subscriptions: subscriptions.clone(),
-            worker: Self::start_worker(
-                prefer_ws_method,
-                http_client,
-                url,
-                subscriptions,
-                new_subscription_recv,
-                on_drop_recv,
-                wallet,
-            ),
+    fn get_unsub_request(&self, sub_id: String) -> Option<String> {
+        let request: WsRequest<_> = (
+            WsMethodRequest::Unsubscribe(WsUnsubscribeRequest { sub_id }),
+            self.req_id
+                .fetch_add(1, std::sync::atomic::Ordering::Relaxed),
+        )
+            .into();
+
+        match serde_json::to_string(&request) {
+            Ok(json) => Some(json),
+            Err(err) => {
+                tracing::error!("Could not serialize unsubscribe message: {:?}", err);
+                None
+            }
         }
     }
+}
 
-    #[allow(unused_variables)]
-    fn start_worker(
-        prefer_ws_method: bool,
-        http_client: Arc<dyn MintConnector + Send + Sync>,
-        url: MintUrl,
-        subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
-        new_subscription_recv: mpsc::Receiver<SubId>,
-        on_drop_recv: mpsc::Receiver<SubId>,
-        wallet: Arc<Wallet>,
-    ) -> Option<JoinHandle<()>> {
-        #[cfg(any(
-            feature = "http_subscription",
-            not(feature = "mint"),
-            target_arch = "wasm32"
-        ))]
-        return Self::http_worker(
-            http_client,
-            subscriptions,
-            new_subscription_recv,
-            on_drop_recv,
-            wallet,
-        );
-
-        #[cfg(all(
-            not(feature = "http_subscription"),
-            feature = "mint",
-            not(target_arch = "wasm32")
-        ))]
-        if prefer_ws_method {
-            Self::ws_worker(
-                http_client,
-                url,
-                subscriptions,
-                new_subscription_recv,
-                on_drop_recv,
-                wallet,
-            )
-        } else {
-            Self::http_worker(
-                http_client,
-                subscriptions,
-                new_subscription_recv,
-                on_drop_recv,
-                wallet,
-            )
-        }
+#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
+#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
+impl Transport for SubscriptionClient {
+    type Spec = MintSubTopics;
+
+    fn new_name(&self) -> <Self::Spec as Spec>::SubscriptionId {
+        Uuid::new_v4().to_string()
     }
 
-    /// Subscribe to a WebSocket channel
-    pub async fn subscribe(
+    async fn stream(
         &self,
-        filter: Params,
-    ) -> (mpsc::Sender<SubId>, mpsc::Receiver<NotificationPayload>) {
-        let mut subscriptions = self.subscriptions.write().await;
-        let id = filter.id.clone();
+        _ctrls: mpsc::Receiver<StreamCtrl<Self::Spec>>,
+        _topics: Vec<SubscribeMessage<Self::Spec>>,
+        _reply_to: InternalRelay<Self::Spec>,
+    ) -> Result<(), PubsubError> {
+        #[cfg(not(target_arch = "wasm32"))]
+        let r = ws::stream_client(self, _ctrls, _topics, _reply_to).await;
 
-        let (sender, receiver) = mpsc::channel(10_000);
-        subscriptions.insert(id.clone(), (sender, filter));
-        drop(subscriptions);
+        #[cfg(target_arch = "wasm32")]
+        let r = Err(PubsubError::NotSupported);
 
-        let _ = self.new_subscription_notif.send(id).await;
-        (self.on_drop_notif.clone(), receiver)
+        r
     }
 
-    /// HTTP subscription client
-    ///
-    /// This is a poll based subscription, where the client will poll the server
-    /// from time to time to get updates, notifying the subscribers on changes
-    fn http_worker(
-        http_client: Arc<dyn MintConnector + Send + Sync>,
-        subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
-        new_subscription_recv: mpsc::Receiver<SubId>,
-        on_drop: mpsc::Receiver<SubId>,
-        wallet: Arc<Wallet>,
-    ) -> Option<JoinHandle<()>> {
-        let http_worker = http::http_main(
-            vec![],
-            http_client,
-            subscriptions,
-            new_subscription_recv,
-            on_drop,
-            wallet,
-        );
-
-        #[cfg(target_arch = "wasm32")]
-        {
-            wasm_bindgen_futures::spawn_local(http_worker);
-            None
+    /// Poll on demand
+    async fn poll(
+        &self,
+        topics: Vec<SubscribeMessage<Self::Spec>>,
+        reply_to: InternalRelay<Self::Spec>,
+    ) -> Result<(), PubsubError> {
+        let proofs = topics
+            .iter()
+            .filter_map(|(_, x)| match &x {
+                NotificationId::ProofState(p) => Some(*p),
+                _ => None,
+            })
+            .collect::<Vec<_>>();
+
+        if !proofs.is_empty() {
+            for state in self
+                .http_client
+                .post_check_state(CheckStateRequest { ys: proofs })
+                .await
+                .map_err(|e| PubsubError::Internal(Box::new(e)))?
+                .states
+            {
+                reply_to.send(MintEvent::new(NotificationPayload::ProofState(state)));
+            }
         }
 
-        #[cfg(not(target_arch = "wasm32"))]
+        for topic in topics
+            .into_iter()
+            .map(|(_, x)| x)
+            .filter(|x| !matches!(x, NotificationId::ProofState(_)))
         {
-            Some(tokio::spawn(http_worker))
+            match topic {
+                NotificationId::MintQuoteBolt11(id) => {
+                    let response = match self.http_client.get_mint_quote_status(&id).await {
+                        Ok(success) => success,
+                        Err(err) => {
+                            tracing::error!("Error with MintBolt11 {} with {:?}", id, err);
+                            continue;
+                        }
+                    };
+
+                    reply_to.send(MintEvent::new(
+                        NotificationPayload::MintQuoteBolt11Response(response.clone()),
+                    ));
+                }
+                NotificationId::MeltQuoteBolt11(id) => {
+                    let response = match self.http_client.get_melt_quote_status(&id).await {
+                        Ok(success) => success,
+                        Err(err) => {
+                            tracing::error!("Error with MeltBolt11 {} with {:?}", id, err);
+                            continue;
+                        }
+                    };
+
+                    reply_to.send(MintEvent::new(
+                        NotificationPayload::MeltQuoteBolt11Response(response),
+                    ));
+                }
+                NotificationId::MintQuoteBolt12(id) => {
+                    let response = match self.http_client.get_mint_quote_bolt12_status(&id).await {
+                        Ok(success) => success,
+                        Err(err) => {
+                            tracing::error!("Error with MintBolt12 {} with {:?}", id, err);
+                            continue;
+                        }
+                    };
+
+                    reply_to.send(MintEvent::new(
+                        NotificationPayload::MintQuoteBolt12Response(response),
+                    ));
+                }
+                NotificationId::MeltQuoteBolt12(id) => {
+                    let response = match self.http_client.get_melt_bolt12_quote_status(&id).await {
+                        Ok(success) => success,
+                        Err(err) => {
+                            tracing::error!("Error with MeltBolt12 {} with {:?}", id, err);
+                            continue;
+                        }
+                    };
+
+                    reply_to.send(MintEvent::new(
+                        NotificationPayload::MeltQuoteBolt11Response(response),
+                    ));
+                }
+                _ => {}
+            }
         }
-    }
 
-    /// WebSocket subscription client
-    ///
-    /// This is a WebSocket based subscription, where the client will connect to
-    /// the server and stay there idle waiting for server-side notifications
-    #[cfg(all(
-        not(feature = "http_subscription"),
-        feature = "mint",
-        not(target_arch = "wasm32")
-    ))]
-    fn ws_worker(
-        http_client: Arc<dyn MintConnector + Send + Sync>,
-        url: MintUrl,
-        subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
-        new_subscription_recv: mpsc::Receiver<SubId>,
-        on_drop: mpsc::Receiver<SubId>,
-        wallet: Arc<Wallet>,
-    ) -> Option<JoinHandle<()>> {
-        Some(tokio::spawn(ws::ws_main(
-            http_client,
-            url,
-            subscriptions,
-            new_subscription_recv,
-            on_drop,
-            wallet,
-        )))
-    }
-}
-
-impl Drop for SubscriptionClient {
-    fn drop(&mut self) {
-        if let Some(handle) = self.worker.take() {
-            handle.abort();
-        }
+        Ok(())
     }
 }

+ 121 - 235
crates/cdk/src/wallet/subscription/ws.rs

@@ -1,38 +1,25 @@
-use std::collections::{HashMap, HashSet};
-use std::sync::atomic::AtomicUsize;
-use std::sync::Arc;
-use std::time::Duration;
-
-use cdk_common::subscription::Params;
-use cdk_common::ws::{WsMessageOrResponse, WsMethodRequest, WsRequest, WsUnsubscribeRequest};
+use cdk_common::nut17::ws::WsMessageOrResponse;
+use cdk_common::pub_sub::remote_consumer::{InternalRelay, StreamCtrl, SubscribeMessage};
+use cdk_common::pub_sub::Error as PubsubError;
 #[cfg(feature = "auth")]
 use cdk_common::{Method, RoutePath};
 use futures::{SinkExt, StreamExt};
-use tokio::sync::{mpsc, RwLock};
-use tokio::time::sleep;
+use tokio::sync::mpsc;
 use tokio_tungstenite::connect_async;
 use tokio_tungstenite::tungstenite::client::IntoClientRequest;
 use tokio_tungstenite::tungstenite::Message;
 
-use super::http::http_main;
-use super::WsSubscriptionBody;
-use crate::mint_url::MintUrl;
-use crate::pub_sub::SubId;
-use crate::wallet::MintConnector;
-use crate::Wallet;
-
-const MAX_ATTEMPT_FALLBACK_HTTP: usize = 10;
-
-#[inline]
-pub async fn ws_main(
-    http_client: Arc<dyn MintConnector + Send + Sync>,
-    mint_url: MintUrl,
-    subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
-    mut new_subscription_recv: mpsc::Receiver<SubId>,
-    mut on_drop: mpsc::Receiver<SubId>,
-    wallet: Arc<Wallet>,
-) {
-    let mut url = mint_url
+use super::{MintSubTopics, SubscriptionClient};
+
+#[inline(always)]
+pub(crate) async fn stream_client(
+    client: &SubscriptionClient,
+    mut ctrl: mpsc::Receiver<StreamCtrl<MintSubTopics>>,
+    topics: Vec<SubscribeMessage<MintSubTopics>>,
+    reply_to: InternalRelay<MintSubTopics>,
+) -> Result<(), PubsubError> {
+    let mut url = client
+        .mint_url
         .join_paths(&["v1", "ws"])
         .expect("Could not join paths");
 
@@ -42,241 +29,140 @@ pub async fn ws_main(
         url.set_scheme("ws").expect("Could not set scheme");
     }
 
-    let request = match url.to_string().into_client_request() {
-        Ok(req) => req,
-        Err(err) => {
-            tracing::error!("Failed to create client request: {:?}", err);
-            // Fallback to HTTP client if we can't create the WebSocket request
-            return http_main(
-                std::iter::empty(),
-                http_client,
-                subscriptions,
-                new_subscription_recv,
-                on_drop,
-                wallet,
-            )
-            .await;
-        }
-    };
-
-    let mut active_subscriptions = HashMap::<SubId, mpsc::Sender<_>>::new();
-    let mut failure_count = 0;
-
-    loop {
-        if subscriptions.read().await.is_empty() {
-            // No active subscription
-            sleep(Duration::from_millis(100)).await;
-            continue;
-        }
-
-        let mut request_clone = request.clone();
-        #[cfg(feature = "auth")]
-        {
-            let auth_wallet = http_client.get_auth_wallet().await;
-            let token = match auth_wallet.as_ref() {
-                Some(auth_wallet) => {
-                    let endpoint = cdk_common::ProtectedEndpoint::new(Method::Get, RoutePath::Ws);
-                    match auth_wallet.get_auth_for_request(&endpoint).await {
-                        Ok(token) => token,
-                        Err(err) => {
-                            tracing::warn!("Failed to get auth token: {:?}", err);
-                            None
-                        }
-                    }
-                }
-                None => None,
-            };
-
-            if let Some(auth_token) = token {
-                let header_key = match &auth_token {
-                    cdk_common::AuthToken::ClearAuth(_) => "Clear-auth",
-                    cdk_common::AuthToken::BlindAuth(_) => "Blind-auth",
-                };
-
-                match auth_token.to_string().parse() {
-                    Ok(header_value) => {
-                        request_clone.headers_mut().insert(header_key, header_value);
-                    }
+    #[cfg(not(feature = "auth"))]
+    let request = url.to_string().into_client_request().map_err(|err| {
+        tracing::error!("Failed to create client request: {:?}", err);
+        // Fallback to HTTP client if we can't create the WebSocket request
+        cdk_common::pub_sub::Error::NotSupported
+    })?;
+
+    #[cfg(feature = "auth")]
+    let mut request = url.to_string().into_client_request().map_err(|err| {
+        tracing::error!("Failed to create client request: {:?}", err);
+        // Fallback to HTTP client if we can't create the WebSocket request
+        cdk_common::pub_sub::Error::NotSupported
+    })?;
+
+    #[cfg(feature = "auth")]
+    {
+        let auth_wallet = client.http_client.get_auth_wallet().await;
+        let token = match auth_wallet.as_ref() {
+            Some(auth_wallet) => {
+                let endpoint = cdk_common::ProtectedEndpoint::new(Method::Get, RoutePath::Ws);
+                match auth_wallet.get_auth_for_request(&endpoint).await {
+                    Ok(token) => token,
                     Err(err) => {
-                        tracing::warn!("Failed to parse auth token as header value: {:?}", err);
+                        tracing::warn!("Failed to get auth token: {:?}", err);
+                        None
                     }
                 }
             }
-        }
-
-        tracing::debug!("Connecting to {}", url);
-        let ws_stream = match connect_async(request_clone.clone()).await {
-            Ok((ws_stream, _)) => ws_stream,
-            Err(err) => {
-                failure_count += 1;
-                tracing::error!("Could not connect to server: {:?}", err);
-                if failure_count > MAX_ATTEMPT_FALLBACK_HTTP {
-                    tracing::error!(
-                        "Could not connect to server after {MAX_ATTEMPT_FALLBACK_HTTP} attempts, falling back to HTTP-subscription client"
-                    );
-
-                    return http_main(
-                        active_subscriptions.into_keys(),
-                        http_client,
-                        subscriptions,
-                        new_subscription_recv,
-                        on_drop,
-                        wallet,
-                    )
-                    .await;
-                }
-                continue;
-            }
+            None => None,
         };
-        tracing::debug!("Connected to {}", url);
-
-        let (mut write, mut read) = ws_stream.split();
-        let req_id = AtomicUsize::new(0);
 
-        let get_sub_request = |params: Params| -> Option<(usize, String)> {
-            let request: WsRequest = (
-                WsMethodRequest::Subscribe(params),
-                req_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
-            )
-                .into();
+        if let Some(auth_token) = token {
+            let header_key = match &auth_token {
+                cdk_common::AuthToken::ClearAuth(_) => "Clear-auth",
+                cdk_common::AuthToken::BlindAuth(_) => "Blind-auth",
+            };
 
-            match serde_json::to_string(&request) {
-                Ok(json) => Some((request.id, json)),
-                Err(err) => {
-                    tracing::error!("Could not serialize subscribe message: {:?}", err);
-                    None
+            match auth_token.to_string().parse() {
+                Ok(header_value) => {
+                    request.headers_mut().insert(header_key, header_value);
                 }
-            }
-        };
-
-        let get_unsub_request = |sub_id: SubId| -> Option<String> {
-            let request: WsRequest = (
-                WsMethodRequest::Unsubscribe(WsUnsubscribeRequest { sub_id }),
-                req_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
-            )
-                .into();
-
-            match serde_json::to_string(&request) {
-                Ok(json) => Some(json),
                 Err(err) => {
-                    tracing::error!("Could not serialize unsubscribe message: {:?}", err);
-                    None
+                    tracing::warn!("Failed to parse auth token as header value: {:?}", err);
                 }
             }
-        };
-
-        // Websocket reconnected, restore all subscriptions
-        let mut subscription_requests = HashSet::new();
-
-        let read_subscriptions = subscriptions.read().await;
-        for (sub_id, _) in active_subscriptions.iter() {
-            if let Some(Some((req_id, req))) = read_subscriptions
-                .get(sub_id)
-                .map(|(_, params)| get_sub_request(params.clone()))
-            {
-                let _ = write.send(Message::Text(req.into())).await;
-                subscription_requests.insert(req_id);
-            }
         }
-        drop(read_subscriptions);
-
-        loop {
-            tokio::select! {
-                Some(msg) = read.next() => {
-                    let msg = match msg {
-                        Ok(msg) => msg,
-                        Err(_) => {
-                            if let Err(err) = write.send(Message::Close(None)).await {
-                                tracing::error!("Closing error {err:?}");
-                            }
-                            break
-                        },
-                    };
-                    let msg = match msg {
-                        Message::Text(msg) => msg,
-                        _ => continue,
-                    };
-                    let msg = match serde_json::from_str::<WsMessageOrResponse>(&msg) {
-                        Ok(msg) => msg,
-                        Err(_) => continue,
-                    };
+    }
 
-                    match msg {
-                        WsMessageOrResponse::Notification(payload) => {
-                            tracing::debug!("Received notification from server: {:?}", payload);
-                            let _ = active_subscriptions.get(&payload.params.sub_id).map(|sender| {
-                                let _ = sender.try_send(payload.params.payload);
-                            });
-                        }
-                        WsMessageOrResponse::Response(response) => {
-                            tracing::debug!("Received response from server: {:?}", response);
-                            subscription_requests.remove(&response.id);
-                            // reset connection failure after a successful response from the serer
-                            failure_count = 0;
-                        }
-                        WsMessageOrResponse::ErrorResponse(error) => {
-                            tracing::error!("Received error from server: {:?}", error);
+    tracing::debug!("Connecting to {}", url);
+    let ws_stream = connect_async(request)
+        .await
+        .map(|(ws_stream, _)| ws_stream)
+        .map_err(|err| {
+            tracing::error!("Error connecting: {err:?}");
 
-                            if subscription_requests.contains(&error.id) {
-                                failure_count += 1;
-                                if failure_count > MAX_ATTEMPT_FALLBACK_HTTP {
-                                    tracing::error!(
-                                        "Falling back to HTTP client"
-                                    );
+            cdk_common::pub_sub::Error::Internal(Box::new(err))
+        })?;
 
-                                    return http_main(
-                                        active_subscriptions.into_keys(),
-                                        http_client,
-                                        subscriptions,
-                                        new_subscription_recv,
-                                        on_drop,
-                                        wallet,
-                                    )
-                                    .await;
-                                }
+    tracing::debug!("Connected to {}", url);
+    let (mut write, mut read) = ws_stream.split();
 
-                                if let Err(err) = write.send(Message::Close(None)).await {
-                                    tracing::error!("Closing error {err:?}");
-                                }
+    for (name, index) in topics {
+        let (_, req) = if let Some(req) = client.get_sub_request(name, index) {
+            req
+        } else {
+            continue;
+        };
 
-                                break; // break connection to force a reconnection, to attempt to recover form this error
-                            }
-                        }
-                    }
+        let _ = write.send(Message::Text(req.into())).await;
+    }
 
-                }
-                Some(subid) = new_subscription_recv.recv() => {
-                    let subscription = subscriptions.read().await;
-                    let sub = if let Some(subscription) = subscription.get(&subid) {
-                        subscription
-                    } else {
-                        continue
-                    };
-                    tracing::debug!("Subscribing to {:?}", sub.1);
-                    active_subscriptions.insert(subid, sub.0.clone());
-                    if let Some((req_id, json)) = get_sub_request(sub.1.clone()) {
-                        let _ = write.send(Message::Text(json.into())).await;
-                        subscription_requests.insert(req_id);
+    loop {
+        tokio::select! {
+            Some(msg) = ctrl.recv() => {
+                match msg {
+                    StreamCtrl::Subscribe(msg) => {
+                        let (_, req) = if let Some(req) = client.get_sub_request(msg.0, msg.1) {
+                            req
+                        } else {
+                            continue;
+                        };
+                        let _ = write.send(Message::Text(req.into())).await;
                     }
-                },
-                Some(subid) = on_drop.recv() => {
-                    let mut subscription = subscriptions.write().await;
-                    if let Some(sub) = subscription.remove(&subid) {
-                        drop(sub);
+                    StreamCtrl::Unsubscribe(msg) => {
+                        let req = if let Some(req) = client.get_unsub_request(msg) {
+                            req
+                        } else {
+                            continue;
+                        };
+                        let _ = write.send(Message::Text(req.into())).await;
                     }
-                    tracing::debug!("Unsubscribing from {:?}", subid);
-                    if let Some(json) = get_unsub_request(subid) {
-                        let _ = write.send(Message::Text(json.into())).await;
+                    StreamCtrl::Stop => {
+                        if let Err(err) = write.send(Message::Close(None)).await {
+                            tracing::error!("Closing error {err:?}");
+                        }
+                        break;
                     }
-
-                    if subscription.is_empty() {
+                };
+            }
+            Some(msg) = read.next() => {
+                let msg = match msg {
+                    Ok(msg) => msg,
+                    Err(_) => {
                         if let Err(err) = write.send(Message::Close(None)).await {
                             tracing::error!("Closing error {err:?}");
                         }
                         break;
                     }
+                };
+                let msg = match msg {
+                    Message::Text(msg) => msg,
+                    _ => continue,
+                };
+                let msg = match serde_json::from_str::<WsMessageOrResponse<String>>(&msg) {
+                    Ok(msg) => msg,
+                    Err(_) => continue,
+                };
+
+                match msg {
+                    WsMessageOrResponse::Notification(payload) => {
+                        reply_to.send(payload.params.payload);
+                    }
+                    WsMessageOrResponse::Response(response) => {
+                        tracing::debug!("Received response from server: {:?}", response);
+                    }
+                    WsMessageOrResponse::ErrorResponse(error) => {
+                        tracing::debug!("Received an error from server: {:?}", error);
+                        return Err(PubsubError::InternalStr(error.error.message));
+                    }
                 }
+
             }
         }
     }
+
+    Ok(())
 }