use std::time::Duration;
use satd_events_proto::v1 as pb;
use satd_events_proto::v1::node_event_stream_client::NodeEventStreamClient;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use tonic::metadata::{Ascii, MetadataValue};
use tonic::transport::{Channel, Endpoint};
use crate::error::StreamError;
use crate::event::{Cursor, Event};
const CONTROL_BUFFER: usize = 16;
pub struct Categories;
impl Categories {
pub const ALL: u32 = 0;
pub const MEMPOOL: u32 = 1;
pub const CHAIN: u32 = 2;
pub const HEARTBEAT: u32 = 4;
}
#[derive(Debug, Clone, Default)]
pub struct SubscribeOptions {
pub categories: u32,
pub from_cursor: Option<Cursor>,
pub since_seq: Option<u64>,
}
impl SubscribeOptions {
fn into_request(self) -> pb::SubscribeRequest {
pb::SubscribeRequest {
categories: self.categories,
since_seq: self.since_seq,
from_cursor: self.from_cursor,
}
}
}
pub struct EventStream {
inner: tonic::Streaming<pb::NodeEvent>,
last_cursor: Option<Cursor>,
}
impl EventStream {
pub async fn message(&mut self) -> Result<Option<Event>, StreamError> {
match self.inner.message().await {
Ok(Some(ev)) => {
if let Some(c) = ev.cursor {
self.last_cursor = Some(c);
}
Ok(Some(Event::from(ev)))
}
Ok(None) => Ok(None),
Err(status) => Err(StreamError::from_status(status)),
}
}
pub fn cursor(&self) -> Option<&Cursor> {
self.last_cursor.as_ref()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum AutoClose {
#[default]
Never,
AtDepth(u32),
}
impl AutoClose {
fn depth(self) -> u32 {
match self {
AutoClose::Never => 0,
AutoClose::AtDepth(d) => d,
}
}
}
pub struct WatchHandle {
tx: mpsc::Sender<pb::SubscribeControl>,
}
impl WatchHandle {
pub async fn send_control(&self, ctrl: pb::SubscribeControl) -> Result<(), StreamError> {
self.tx.send(ctrl).await.map_err(|_| StreamError::ControlClosed)
}
async fn send_msg(&self, msg: pb::subscribe_control::Msg) -> Result<(), StreamError> {
self.send_control(pb::SubscribeControl { msg: Some(msg) }).await
}
pub async fn add_scripts(
&self,
items: impl IntoIterator<Item = ([u8; 32], Option<u64>)>,
) -> Result<(), StreamError> {
let items: Vec<_> = items.into_iter().collect();
if items.is_empty() {
return Ok(());
}
let scripthashes = items.iter().map(|(h, _)| h.to_vec()).collect();
let min_values = if items.iter().any(|(_, f)| f.is_some()) {
items.iter().map(|(_, f)| f.unwrap_or(0)).collect()
} else {
Vec::new()
};
self.send_msg(pb::subscribe_control::Msg::AddScripts(pb::AddScripts {
scripthashes,
min_values,
}))
.await
}
pub async fn remove_scripts(
&self,
scripthashes: impl IntoIterator<Item = [u8; 32]>,
) -> Result<(), StreamError> {
let scripthashes: Vec<Vec<u8>> = scripthashes.into_iter().map(|h| h.to_vec()).collect();
if scripthashes.is_empty() {
return Ok(());
}
self.send_msg(pb::subscribe_control::Msg::RemoveScripts(pb::RemoveScripts {
scripthashes,
}))
.await
}
pub async fn add_outpoints(
&self,
outpoints: impl IntoIterator<Item = ([u8; 32], u32)>,
) -> Result<(), StreamError> {
let outpoints: Vec<pb::Outpoint> = outpoints
.into_iter()
.map(|(txid, vout)| pb::Outpoint { txid: txid.to_vec(), vout })
.collect();
if outpoints.is_empty() {
return Ok(());
}
self.send_msg(pb::subscribe_control::Msg::AddOutpoints(pb::AddOutpoints { outpoints }))
.await
}
pub async fn remove_outpoints(
&self,
outpoints: impl IntoIterator<Item = ([u8; 32], u32)>,
) -> Result<(), StreamError> {
let outpoints: Vec<pb::Outpoint> = outpoints
.into_iter()
.map(|(txid, vout)| pb::Outpoint { txid: txid.to_vec(), vout })
.collect();
if outpoints.is_empty() {
return Ok(());
}
self.send_msg(pb::subscribe_control::Msg::RemoveOutpoints(pb::RemoveOutpoints {
outpoints,
}))
.await
}
pub async fn add_tx_lifecycle(
&self,
txids: impl IntoIterator<Item = [u8; 32]>,
auto_close: AutoClose,
) -> Result<(), StreamError> {
let txids: Vec<Vec<u8>> = txids.into_iter().map(|t| t.to_vec()).collect();
if txids.is_empty() {
return Ok(());
}
self.send_msg(pb::subscribe_control::Msg::AddTransactions(pb::AddTransactions {
txids,
min_depths: Vec::new(),
auto_close_depth: auto_close.depth(),
}))
.await
}
pub async fn remove_tx_lifecycle(
&self,
txids: impl IntoIterator<Item = [u8; 32]>,
) -> Result<(), StreamError> {
let txids: Vec<Vec<u8>> = txids.into_iter().map(|t| t.to_vec()).collect();
if txids.is_empty() {
return Ok(());
}
self.send_msg(pb::subscribe_control::Msg::RemoveTransactions(pb::RemoveTransactions {
txids,
min_depths: Vec::new(),
}))
.await
}
pub async fn add_depth_alarms(
&self,
txids: impl IntoIterator<Item = [u8; 32]>,
depths: impl IntoIterator<Item = u32>,
) -> Result<(), StreamError> {
let txids: Vec<Vec<u8>> = txids.into_iter().map(|t| t.to_vec()).collect();
let min_depths: Vec<u32> = depths.into_iter().filter(|d| *d >= 1).collect();
if txids.is_empty() || min_depths.is_empty() {
return Ok(());
}
self.send_msg(pb::subscribe_control::Msg::AddTransactions(pb::AddTransactions {
txids,
min_depths,
auto_close_depth: 0,
}))
.await
}
pub async fn remove_depth_alarms(
&self,
txids: impl IntoIterator<Item = [u8; 32]>,
depths: impl IntoIterator<Item = u32>,
) -> Result<(), StreamError> {
let txids: Vec<Vec<u8>> = txids.into_iter().map(|t| t.to_vec()).collect();
let min_depths: Vec<u32> = depths.into_iter().filter(|d| *d >= 1).collect();
if txids.is_empty() || min_depths.is_empty() {
return Ok(());
}
self.send_msg(pb::subscribe_control::Msg::RemoveTransactions(pb::RemoveTransactions {
txids,
min_depths,
}))
.await
}
pub async fn add_descriptor(
&self,
descriptor: impl Into<String>,
gap_limit: u32,
start: u32,
) -> Result<(), StreamError> {
self.send_msg(pb::subscribe_control::Msg::AddDescriptor(pb::AddDescriptor {
descriptor: descriptor.into(),
gap_limit,
start,
}))
.await
}
pub async fn remove_descriptor(
&self,
descriptor: impl Into<String>,
) -> Result<(), StreamError> {
self.send_msg(pb::subscribe_control::Msg::RemoveDescriptor(pb::RemoveDescriptor {
descriptor: descriptor.into(),
}))
.await
}
pub async fn add_script_prefixes(
&self,
prefixes: impl IntoIterator<Item = (Vec<u8>, u32)>,
) -> Result<(), StreamError> {
let prefixes: Vec<pb::ScriptPrefix> = prefixes
.into_iter()
.map(|(prefix, bits)| validate_prefix(prefix, bits))
.collect::<Result<_, _>>()?;
if prefixes.is_empty() {
return Ok(());
}
self.send_msg(pb::subscribe_control::Msg::AddScriptPrefixes(pb::AddScriptPrefixes {
prefixes,
}))
.await
}
pub async fn remove_script_prefixes(
&self,
prefixes: impl IntoIterator<Item = (Vec<u8>, u32)>,
) -> Result<(), StreamError> {
let prefixes: Vec<pb::ScriptPrefix> = prefixes
.into_iter()
.map(|(prefix, bits)| validate_prefix(prefix, bits))
.collect::<Result<_, _>>()?;
if prefixes.is_empty() {
return Ok(());
}
self.send_msg(pb::subscribe_control::Msg::RemoveScriptPrefixes(
pb::RemoveScriptPrefixes { prefixes },
))
.await
}
pub async fn set_categories(&self, categories: u32) -> Result<(), StreamError> {
self.send_msg(pb::subscribe_control::Msg::SetCategories(pb::SetCategories {
categories,
}))
.await
}
pub async fn set_watch_options(&self, include_raw_tx: bool) -> Result<(), StreamError> {
self.send_msg(pb::subscribe_control::Msg::SetWatchOptions(
pb::SetWatchOptions { include_raw_tx },
))
.await
}
pub async fn set_cursor(&self, cursor: Cursor) -> Result<(), StreamError> {
self.send_msg(pb::subscribe_control::Msg::SetCursor(pb::SetCursor {
cursor: Some(cursor),
}))
.await
}
pub async fn rescan(&self, from_height: u32, to_height: u32) -> Result<(), StreamError> {
self.send_msg(pb::subscribe_control::Msg::RescanBlocks(pb::RescanBlocks {
from_height,
to_height,
}))
.await
}
}
pub(crate) const MAX_PREFIX_BITS: u32 = 32;
pub(crate) fn validate_prefix(
prefix: Vec<u8>,
bits: u32,
) -> Result<pb::ScriptPrefix, StreamError> {
if !(1..=MAX_PREFIX_BITS).contains(&bits) {
return Err(StreamError::InvalidArgument(format!(
"prefix bits {bits} out of range 1..={MAX_PREFIX_BITS}"
)));
}
let want = bits.div_ceil(8) as usize;
if prefix.len() != want {
return Err(StreamError::InvalidArgument(format!(
"prefix for {bits} bits must be {want} bytes, got {}",
prefix.len()
)));
}
Ok(pb::ScriptPrefix { prefix, bits })
}
#[cfg(feature = "tls")]
#[derive(Clone, Default)]
struct TlsSettings {
ca_pem: Option<Vec<u8>>,
identity: Option<(Vec<u8>, Vec<u8>)>,
domain: Option<String>,
}
#[derive(Clone)]
pub struct StreamClientBuilder {
endpoint: String,
token: Option<String>,
keepalive: Option<(Duration, Duration)>,
#[cfg(feature = "tls")]
tls: Option<TlsSettings>,
}
impl std::fmt::Debug for StreamClientBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut d = f.debug_struct("StreamClientBuilder");
d.field("endpoint", &self.endpoint)
.field("token", &self.token.as_ref().map(|_| "<redacted>"))
.field("keepalive", &self.keepalive);
#[cfg(feature = "tls")]
d.field("tls", &self.tls.is_some());
d.finish()
}
}
impl StreamClientBuilder {
pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
self.token = Some(token.into());
self
}
pub fn keepalive(mut self, interval_secs: u64, timeout_secs: u64) -> Self {
self.keepalive =
Some((Duration::from_secs(interval_secs), Duration::from_secs(timeout_secs)));
self
}
pub fn keepalive_default(self) -> Self {
self.keepalive(30, 20)
}
#[cfg(feature = "tls")]
#[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
pub fn tls(mut self) -> Self {
self.tls.get_or_insert_with(TlsSettings::default);
self
}
#[cfg(feature = "tls")]
#[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
pub fn tls_ca_pem(mut self, pem: impl Into<Vec<u8>>) -> Self {
self.tls.get_or_insert_with(TlsSettings::default).ca_pem = Some(pem.into());
self
}
#[cfg(feature = "tls")]
#[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
pub fn tls_client_identity(
mut self,
cert_pem: impl Into<Vec<u8>>,
key_pem: impl Into<Vec<u8>>,
) -> Self {
self.tls.get_or_insert_with(TlsSettings::default).identity =
Some((cert_pem.into(), key_pem.into()));
self
}
#[cfg(feature = "tls")]
#[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
pub fn tls_domain(mut self, domain: impl Into<String>) -> Self {
self.tls.get_or_insert_with(TlsSettings::default).domain = Some(domain.into());
self
}
pub async fn connect(self) -> Result<StreamClient, StreamError> {
let auth = match self.token {
Some(t) => Some(
format!("Bearer {t}")
.parse::<MetadataValue<Ascii>>()
.map_err(|_| StreamError::InvalidToken)?,
),
None => None,
};
#[cfg(feature = "tls")]
if self.tls.is_some() {
let scheme_is_https = self
.endpoint
.split_once("://")
.map(|(scheme, _)| scheme.eq_ignore_ascii_case("https"))
.unwrap_or(false);
if !scheme_is_https {
return Err(StreamError::InvalidEndpoint(
"TLS was requested (tls / tls_ca_pem / tls_client_identity / tls_domain) but \
the endpoint scheme is not https:// — refusing to connect in cleartext; use \
an https:// endpoint"
.to_string(),
));
}
}
let mut endpoint = Endpoint::from_shared(self.endpoint)
.map_err(|e| StreamError::InvalidEndpoint(e.to_string()))?;
if let Some((interval, timeout)) = self.keepalive {
endpoint = endpoint
.http2_keep_alive_interval(interval)
.keep_alive_timeout(timeout)
.keep_alive_while_idle(true);
}
#[cfg(feature = "tls")]
if let Some(tls) = self.tls {
ensure_crypto_provider();
let mut cfg = tonic::transport::ClientTlsConfig::new();
cfg = match tls.ca_pem {
Some(ca) => cfg.ca_certificate(tonic::transport::Certificate::from_pem(ca)),
None => cfg.with_webpki_roots(),
};
if let Some((cert, key)) = tls.identity {
cfg = cfg.identity(tonic::transport::Identity::from_pem(cert, key));
}
if let Some(domain) = tls.domain {
cfg = cfg.domain_name(domain);
}
endpoint = endpoint.tls_config(cfg)?;
}
let channel = endpoint.connect().await?;
Ok(StreamClient { inner: NodeEventStreamClient::new(channel), auth })
}
}
#[cfg(feature = "tls")]
fn ensure_crypto_provider() {
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let _ = tokio_rustls::rustls::crypto::ring::default_provider().install_default();
});
}
#[derive(Clone)]
pub struct StreamClient {
inner: NodeEventStreamClient<Channel>,
auth: Option<MetadataValue<Ascii>>,
}
impl std::fmt::Debug for StreamClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StreamClient")
.field("auth", &self.auth.as_ref().map(|_| "<redacted>"))
.finish_non_exhaustive()
}
}
impl StreamClient {
pub fn builder(endpoint: impl Into<String>) -> StreamClientBuilder {
StreamClientBuilder {
endpoint: endpoint.into(),
token: None,
keepalive: None,
#[cfg(feature = "tls")]
tls: None,
}
}
fn authed<T>(&self, msg: T) -> tonic::Request<T> {
let mut req = tonic::Request::new(msg);
if let Some(value) = &self.auth {
req.metadata_mut().insert("authorization", value.clone());
}
req
}
pub async fn subscribe(
&mut self,
opts: SubscribeOptions,
) -> Result<EventStream, StreamError> {
let req = self.authed(opts.into_request());
let inner = self.inner.subscribe(req).await?.into_inner();
Ok(EventStream { inner, last_cursor: None })
}
pub fn resilient_subscribe(
&self,
opts: SubscribeOptions,
config: crate::ResilientConfig,
) -> crate::ResilientSubscription {
crate::ResilientSubscription::new(self.clone(), opts, config)
}
pub fn resilient_watch(&self, config: crate::ResilientWatchConfig) -> crate::ResilientWatch {
crate::ResilientWatch::new(self.clone(), config)
}
pub async fn watch(&mut self) -> Result<(WatchHandle, EventStream), StreamError> {
let (tx, rx) = mpsc::channel::<pb::SubscribeControl>(CONTROL_BUFFER);
let req = self.authed(ReceiverStream::new(rx));
let inner = self.inner.watch(req).await?.into_inner();
Ok((WatchHandle { tx }, EventStream { inner, last_cursor: None }))
}
}
#[cfg(test)]
impl WatchHandle {
pub(crate) fn for_test() -> (Self, mpsc::Receiver<pb::SubscribeControl>) {
let (tx, rx) = mpsc::channel(64);
(WatchHandle { tx }, rx)
}
}
#[cfg(test)]
impl StreamClient {
pub(crate) fn for_test() -> Self {
let channel = Endpoint::from_static("http://127.0.0.1:1").connect_lazy();
StreamClient { inner: NodeEventStreamClient::new(channel), auth: None }
}
}
#[cfg(test)]
mod tests {
use super::*;
use pb::subscribe_control::Msg;
fn handle() -> (WatchHandle, mpsc::Receiver<pb::SubscribeControl>) {
let (tx, rx) = mpsc::channel(16);
(WatchHandle { tx }, rx)
}
fn next(rx: &mut mpsc::Receiver<pb::SubscribeControl>) -> Msg {
rx.try_recv().expect("a control message was sent").msg.expect("msg set")
}
#[tokio::test]
async fn add_scripts_mixed_floors_emits_parallel_min_values() {
let (h, mut rx) = handle();
h.add_scripts([([1u8; 32], Some(5_000)), ([2u8; 32], None)]).await.unwrap();
match next(&mut rx) {
Msg::AddScripts(a) => {
assert_eq!(a.scripthashes.len(), 2);
assert_eq!(a.min_values, vec![5_000, 0]);
}
other => panic!("expected AddScripts, got {other:?}"),
}
}
#[tokio::test]
async fn add_scripts_no_floors_emits_empty_min_values() {
let (h, mut rx) = handle();
h.add_scripts([([1u8; 32], None), ([2u8; 32], None)]).await.unwrap();
match next(&mut rx) {
Msg::AddScripts(a) => assert!(a.min_values.is_empty()),
other => panic!("expected AddScripts, got {other:?}"),
}
}
#[tokio::test]
async fn empty_inputs_send_nothing() {
let (h, mut rx) = handle();
h.add_scripts([]).await.unwrap();
h.add_outpoints([]).await.unwrap();
h.add_depth_alarms([[1u8; 32]], []).await.unwrap(); h.add_depth_alarms([], [3]).await.unwrap(); h.add_depth_alarms([[1u8; 32]], [0]).await.unwrap();
assert!(rx.try_recv().is_err(), "no message should have been sent");
}
#[tokio::test]
async fn lifecycle_vs_depth_alarms_dispatch_on_min_depths() {
let (h, mut rx) = handle();
h.add_tx_lifecycle([[7u8; 32]], AutoClose::AtDepth(6)).await.unwrap();
match next(&mut rx) {
Msg::AddTransactions(a) => {
assert!(a.min_depths.is_empty(), "lifecycle => empty min_depths");
assert_eq!(a.auto_close_depth, 6);
}
other => panic!("expected AddTransactions, got {other:?}"),
}
h.add_depth_alarms([[7u8; 32], [8u8; 32]], [3, 6]).await.unwrap();
match next(&mut rx) {
Msg::AddTransactions(a) => {
assert_eq!(a.txids.len(), 2);
assert_eq!(a.min_depths, vec![3, 6]); assert_eq!(a.auto_close_depth, 0);
}
other => panic!("expected AddTransactions, got {other:?}"),
}
}
#[tokio::test]
async fn prefix_validation_rejects_bad_length_and_bits() {
let (h, mut rx) = handle();
let err = h.add_script_prefixes([(vec![0xab], 16)]).await.unwrap_err();
assert!(matches!(err, StreamError::InvalidArgument(_)));
let err = h.add_script_prefixes([(vec![], 0)]).await.unwrap_err();
assert!(matches!(err, StreamError::InvalidArgument(_)));
let err = h.add_script_prefixes([(vec![0u8; 5], 33)]).await.unwrap_err();
assert!(matches!(err, StreamError::InvalidArgument(_)));
assert!(rx.try_recv().is_err());
h.add_script_prefixes([(vec![0xde, 0xad, 0xbe, 0xef], 32)]).await.unwrap();
match next(&mut rx) {
Msg::AddScriptPrefixes(a) => assert_eq!(a.prefixes[0].bits, 32),
other => panic!("expected AddScriptPrefixes, got {other:?}"),
}
h.add_script_prefixes([(vec![0xab, 0xcd], 16)]).await.unwrap();
match next(&mut rx) {
Msg::AddScriptPrefixes(a) => {
assert_eq!(a.prefixes.len(), 1);
assert_eq!(a.prefixes[0].bits, 16);
assert_eq!(a.prefixes[0].prefix, vec![0xab, 0xcd]);
}
other => panic!("expected AddScriptPrefixes, got {other:?}"),
}
}
#[tokio::test]
async fn set_cursor_wraps_cursor() {
let (h, mut rx) = handle();
let cursor = Cursor { height: 9, tx_index: 0, mempool_seq: 1, instance_id: 2 };
h.set_cursor(cursor).await.unwrap();
match next(&mut rx) {
Msg::SetCursor(s) => assert_eq!(s.cursor, Some(cursor)),
other => panic!("expected SetCursor, got {other:?}"),
}
}
#[cfg(feature = "tls")]
#[test]
fn tls_builder_debug_redacts_secrets() {
let b = StreamClient::builder("https://node:50051")
.bearer_token("SUPER_SECRET_TOKEN")
.tls_ca_pem(b"-----BEGIN CERTIFICATE-----SECRET_CA_BYTES".to_vec())
.tls_client_identity(b"CLIENT_CERT".to_vec(), b"CLIENT_PRIVATE_KEY".to_vec());
let dbg = format!("{b:?}");
assert!(dbg.contains("tls: true"), "expected tls: true, got {dbg}");
assert!(dbg.contains("<redacted>"), "token should be redacted: {dbg}");
assert!(!dbg.contains("SUPER_SECRET_TOKEN"));
assert!(!dbg.contains("SECRET_CA_BYTES"));
assert!(!dbg.contains("CLIENT_PRIVATE_KEY"));
}
#[cfg(feature = "tls")]
#[tokio::test]
async fn tls_connect_assembles_and_fails_cleanly() {
let err = StreamClient::builder("https://127.0.0.1:1")
.tls()
.tls_domain("node.example")
.connect()
.await
.expect_err("connect to a dead port must fail");
assert!(
!matches!(err, StreamError::InvalidEndpoint(_) | StreamError::InvalidToken),
"expected a transport/connect error, got {err:?}",
);
}
#[cfg(feature = "tls")]
#[tokio::test]
async fn tls_over_non_https_endpoint_is_refused() {
for ep in ["http://127.0.0.1:1", "127.0.0.1:1"] {
let err = StreamClient::builder(ep)
.tls_ca_pem(b"-----BEGIN CERTIFICATE-----".to_vec())
.bearer_token("SECRET")
.connect()
.await
.expect_err("TLS over non-https must be refused");
match err {
StreamError::InvalidEndpoint(msg) => assert!(
msg.contains("https"),
"expected an https-scheme error for {ep}, got {msg}"
),
other => panic!("expected InvalidEndpoint for {ep}, got {other:?}"),
}
}
}
}