use canton_core::{Auth, Error, Result, TlsConfig};
use futures_core::Stream;
use futures_util::{SinkExt as _, StreamExt as _};
use serde_json::Value;
use tokio_tungstenite::Connector;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::ClientRequestBuilder;
use tokio_tungstenite::tungstenite::http::Uri;
const WS_SUBPROTOCOL: &str = "daml.ws.auth";
pub(crate) fn build_connector(tls: Option<&TlsConfig>) -> Result<Option<Connector>> {
use rustls_pki_types::pem::PemObject as _;
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
let Some(tls) = tls else { return Ok(None) };
let mut roots = rustls::RootCertStore::empty();
match &tls.ca_certificate_pem {
Some(ca) => {
for cert in CertificateDer::pem_slice_iter(ca) {
let cert = cert
.map_err(|e| Error::InvalidRequest(format!("invalid CA certificate: {e}")))?;
roots
.add(cert)
.map_err(|e| Error::InvalidRequest(format!("rejected CA certificate: {e}")))?;
}
if roots.is_empty() {
return Err(Error::InvalidRequest(
"the CA PEM contained no certificates".to_string(),
));
}
}
None => {
for cert in rustls_native_certs::load_native_certs().certs {
let _ = roots.add(cert);
}
}
}
let builder = rustls::ClientConfig::builder_with_provider(
rustls::crypto::ring::default_provider().into(),
)
.with_safe_default_protocol_versions()
.map_err(|e| Error::InvalidRequest(format!("tls protocol setup failed: {e}")))?
.with_root_certificates(roots);
let config = match &tls.client_identity_pem {
Some((cert_pem, key_pem)) => {
let certs = CertificateDer::pem_slice_iter(cert_pem)
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|e| Error::InvalidRequest(format!("invalid client certificate: {e}")))?;
let key = PrivateKeyDer::from_pem_slice(key_pem)
.map_err(|e| Error::InvalidRequest(format!("invalid client key: {e}")))?;
builder
.with_client_auth_cert(certs, key)
.map_err(|e| Error::InvalidRequest(format!("invalid client identity: {e}")))?
}
None => builder.with_no_client_auth(),
};
Ok(Some(Connector::Rustls(std::sync::Arc::new(config))))
}
fn ws_url(base_url: &str, path: &str) -> String {
let base = base_url.trim_end_matches('/');
let base = if let Some(rest) = base.strip_prefix("https://") {
format!("wss://{rest}")
} else if let Some(rest) = base.strip_prefix("http://") {
format!("ws://{rest}")
} else {
base.to_string()
};
format!("{base}{path}")
}
fn is_error_frame(value: &Value) -> bool {
value.get("errorCategory").is_some() && value.get("code").is_some()
}
fn error_frame(value: &Value) -> Error {
let status = value
.get("grpcCodeValue")
.and_then(Value::as_i64)
.map_or(500, grpc_code_to_http_status);
Error::Http {
status,
body: value.to_string(),
}
}
fn grpc_code_to_http_status(code: i64) -> u16 {
match code {
0 => 200, 1 => 499, 3 | 9 | 11 => 400, 4 => 504, 5 => 404, 6 | 10 => 409, 7 => 403, 8 => 429, 12 => 501, 14 => 503, 16 => 401, _ => 500, }
}
pub(crate) fn update_offset(value: &Value) -> Option<i64> {
value
.get("update")?
.as_object()?
.values()
.next()?
.get("value")?
.get("offset")?
.as_i64()
}
pub(crate) fn is_offset_checkpoint(value: &Value) -> bool {
["update", "completionResponse"].iter().any(|key| {
value
.get(key)
.and_then(Value::as_object)
.is_some_and(|obj| obj.contains_key("OffsetCheckpoint"))
})
}
pub(crate) fn filter_checkpoints(
inner: impl Stream<Item = Result<Value>> + Send,
) -> impl Stream<Item = Result<Value>> + Send {
async_stream::try_stream! {
tokio::pin!(inner);
while let Some(item) = inner.next().await {
let frame = item?;
if !is_offset_checkpoint(&frame) {
yield frame;
}
}
}
}
pub(crate) async fn subscribe(
base_url: &str,
auth: &Auth,
tls: Option<&TlsConfig>,
path: &str,
request: Value,
) -> Result<impl Stream<Item = Result<Value>> + Send + use<>> {
let url = ws_url(base_url, path);
let uri: Uri = url
.parse()
.map_err(|e| Error::InvalidRequest(format!("invalid ws url {url}: {e}")))?;
let mut builder = ClientRequestBuilder::new(uri).with_sub_protocol(WS_SUBPROTOCOL);
if let Some(token) = auth.bearer().await? {
builder = builder.with_header("Authorization", format!("Bearer {token}"));
}
let connector = build_connector(tls)?;
let (mut socket, _response) =
tokio_tungstenite::connect_async_tls_with_config(builder, None, false, connector)
.await
.map_err(|e| Error::Connection(format!("ws connect to {url} failed: {e}")))?;
socket
.send(Message::text(request.to_string()))
.await
.map_err(|e| Error::Connection(format!("ws send to {url} failed: {e}")))?;
Ok(async_stream::try_stream! {
while let Some(message) = socket.next().await {
let message = message.map_err(|e| Error::Connection(format!("ws recv failed: {e}")))?;
match message {
Message::Text(text) => {
let value: Value = serde_json::from_str(text.as_str()).map_err(Error::from)?;
if is_error_frame(&value) {
Err(error_frame(&value))?;
}
yield value;
}
Message::Close(_) => break,
_ => {} }
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ws_url_rewrites_the_scheme() {
assert_eq!(
ws_url("http://localhost:3975", "/v2/updates"),
"ws://localhost:3975/v2/updates"
);
assert_eq!(
ws_url("https://host/", "/v2/state/active-contracts"),
"wss://host/v2/state/active-contracts"
);
assert_eq!(ws_url("ws://host:9", "/p"), "ws://host:9/p");
}
#[test]
fn error_frames_are_distinguished_from_success() {
let error = serde_json::json!({
"code": "JSON_API_X", "cause": "boom", "errorCategory": 7, "grpcCodeValue": 7
});
assert!(is_error_frame(&error));
match error_frame(&error) {
Error::Http { status, body } => {
assert_eq!(status, 403, "PERMISSION_DENIED maps to 403");
assert!(body.contains("JSON_API_X"));
}
other => panic!("expected Http, got {other:?}"),
}
let success = serde_json::json!({ "update": { "Transaction": {} } });
assert!(!is_error_frame(&success));
}
#[test]
fn error_frames_classify_by_canton_category() {
let transient = serde_json::json!({
"code": "SEQUENCER_OVERLOADED", "cause": "backpressure",
"errorCategory": 1, "grpcCodeValue": 10, "retryInfo": "1 second"
});
let err = error_frame(&transient);
assert!(err.is_retriable());
assert_eq!(err.retry_delay(), Some(std::time::Duration::from_secs(1)));
let terminal = serde_json::json!({
"code": "BAD_FORMAT", "cause": "malformed",
"errorCategory": 8, "grpcCodeValue": 13
});
assert!(!error_frame(&terminal).is_retriable());
}
#[test]
#[allow(clippy::unwrap_used)]
fn build_connector_honours_the_tls_config() {
assert!(build_connector(None).unwrap().is_none());
let ck = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
let cert_pem = ck.cert.pem().into_bytes();
let key_pem = ck.key_pair.serialize_pem().into_bytes();
let tls = TlsConfig::new()
.with_ca_certificate(cert_pem.clone())
.with_client_identity(cert_pem, key_pem);
assert!(matches!(
build_connector(Some(&tls)).unwrap(),
Some(Connector::Rustls(_))
));
let empty_ca = TlsConfig::new().with_ca_certificate(b"not a pem".to_vec());
assert!(matches!(
build_connector(Some(&empty_ca)),
Err(Error::InvalidRequest(_))
));
let ck2 = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
let bad_identity = TlsConfig::new()
.with_client_identity(ck2.cert.pem().into_bytes(), b"not a key".to_vec());
assert!(matches!(
build_connector(Some(&bad_identity)),
Err(Error::InvalidRequest(_))
));
}
#[test]
fn update_offset_reads_any_tag() {
let tx = serde_json::json!({ "update": { "Transaction": { "value": { "offset": 42 } } } });
assert_eq!(update_offset(&tx), Some(42));
let cp =
serde_json::json!({ "update": { "OffsetCheckpoint": { "value": { "offset": 7 } } } });
assert_eq!(update_offset(&cp), Some(7));
let acs = serde_json::json!({ "contractEntry": {} });
assert_eq!(update_offset(&acs), None);
}
#[test]
fn offset_checkpoints_are_recognized_in_both_envelopes() {
let update_cp = serde_json::json!({ "update": { "OffsetCheckpoint": { "value": {} } } });
let completion_cp =
serde_json::json!({ "completionResponse": { "OffsetCheckpoint": { "value": {} } } });
let real = serde_json::json!({ "update": { "Transaction": { "value": {} } } });
assert!(is_offset_checkpoint(&update_cp));
assert!(is_offset_checkpoint(&completion_cp));
assert!(!is_offset_checkpoint(&real));
}
}