use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
time::Duration,
};
use anyhow::{ensure, Result};
use hashtree_cli::Config;
use hashtree_fips_transport::{
bind_fips_endpoint, bind_fips_endpoint_at_local_rendezvous, set_fips_peer_configs,
BoundFipsEndpoint, FipsEndpointOptions,
};
use hashtree_nostr::{catchup::CatchupPolicy, stored_event_from_nostr_sdk_event, StoredNostrEvent};
use nostr::{nips::nip19::ToBech32, Filter, Keys, Kind, PublicKey, Timestamp};
use nostr_pubsub::{EventBus, QueryOptions, QueryReport};
use nostr_pubsub_fips::{FipsPubsubClient, FipsPubsubClientOptions};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(super) enum Status {
Observed,
Unavailable,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Receipt {
pub status: Status,
pub events: usize,
pub added_events: usize,
pub sources: BTreeMap<String, usize>,
pub event_ids_sha256: String,
}
pub(super) const MAX_PEERS: usize = 16;
pub(super) const MAX_REPLAY_EVENTS: usize = 128;
pub(super) struct Runtime {
endpoint: BoundFipsEndpoint,
pub client: Arc<FipsPubsubClient>,
}
impl Runtime {
pub async fn start(config: &Config, relays: &[String], timeout: Duration) -> Result<Self> {
let mut options = FipsEndpointOptions::new(Keys::generate().secret_key().to_bech32()?);
options.enable_udp = config.server.enable_fips_udp;
options.enable_webrtc = config.server.enable_fips_webrtc;
options.enable_lan_discovery = config.server.enable_fips_lan_discovery;
options.share_local_candidates = config.server.enable_fips_lan_discovery;
options.enable_local_rendezvous = true;
options.ethernet_interfaces = config.server.fips_ethernet_interfaces.clone();
options.discovery_scope = config.server.fips_discovery_scope.clone();
options.relays = config
.server
.fips_relays
.clone()
.unwrap_or_else(|| relays.to_vec());
options.open_discovery_max_pending = MAX_PEERS;
options.webrtc_max_connections = MAX_PEERS;
options.webrtc_auto_connect = options.enable_webrtc;
let seed_urls = config.server.resolved_fips_websocket_seed_urls();
if !seed_urls.is_empty() {
options.websocket = Some(hashtree_fips_transport::WebSocketConfig {
seed_urls,
..Default::default()
});
}
let endpoint = if let Some(addr) = config.server.fips_local_rendezvous_addr.as_ref() {
bind_fips_endpoint_at_local_rendezvous(options, addr.parse()?).await?
} else {
bind_fips_endpoint(options).await?
};
let peers = hashtree_cli::fips_transport::daemon_fips_peer_configs(config, Vec::new());
if !peers.is_empty() {
if let Err(error) = set_fips_peer_configs(&endpoint.native_endpoint, peers).await {
let _ = endpoint.native_endpoint.shutdown().await;
return Err(error.into());
}
}
let client = FipsPubsubClient::start(
endpoint.native_endpoint.clone(),
FipsPubsubClientOptions {
max_connected_peers: MAX_PEERS,
max_inbound_routed_peers: MAX_PEERS / 4,
query_timeout: timeout,
max_active_subscriptions: 4,
max_replay_events: MAX_REPLAY_EVENTS,
..Default::default()
},
)
.await;
match client {
Ok(client) => Ok(Self {
endpoint,
client: Arc::new(client),
}),
Err(error) => {
let _ = endpoint.native_endpoint.shutdown().await;
Err(error.into())
}
}
}
pub async fn shutdown(&self) {
self.client.shutdown_shared().await;
let _ = self.endpoint.native_endpoint.shutdown().await;
}
}
pub(super) fn filter(
policy: &CatchupPolicy,
author: &str,
since: u64,
until: u64,
) -> Result<Filter> {
Ok(Filter::new()
.author(PublicKey::from_hex(author)?)
.kinds(policy.kinds.iter().copied().map(Kind::from))
.since(Timestamp::from_secs(since))
.until(Timestamp::from_secs(until))
.limit(policy.max_events_per_author.saturating_add(1)))
}
pub(super) async fn query(
bus: &dyn EventBus,
filter: Filter,
timeout: Duration,
) -> Option<QueryReport> {
tokio::time::timeout(
timeout,
bus.query(
vec![filter.clone()],
QueryOptions {
limit: filter.limit,
},
),
)
.await
.ok()
.and_then(Result::ok)
}
pub(super) fn merge(
relay_events: &mut Vec<StoredNostrEvent>,
report: Option<QueryReport>,
filter: &Filter,
policy: &CatchupPolicy,
) -> Result<Receipt> {
let Some(report) = report else {
return Ok(Receipt {
status: Status::Unavailable,
events: 0,
added_events: 0,
sources: BTreeMap::new(),
event_ids_sha256: hex::encode(Sha256::digest([])),
});
};
ensure!(
report.events.len() <= MAX_REPLAY_EVENTS
&& report.events.len() <= policy.max_events_per_author.saturating_add(1),
"pubsub event budget exhausted"
);
let mut peer_events = BTreeMap::new();
let mut peer_bytes = 0usize;
let mut sources = BTreeMap::new();
for entry in report.events {
if entry.source.kind != nostr_pubsub::EventSourceKind::FipsEndpoint {
continue;
}
let event = entry.event.as_event();
ensure!(
filter.match_event(event, nostr_pubsub::MatchEventOptions::default()),
"pubsub event outside requested author/kind/time filter"
);
let event = stored_event_from_nostr_sdk_event(event);
if !peer_events.contains_key(&event.id) {
peer_bytes = peer_bytes.saturating_add(serde_json::to_vec(&event)?.len());
ensure!(
peer_events.len() < policy.max_events_per_author
&& peer_bytes <= policy.max_bytes_per_author,
"pubsub event/byte budget exhausted"
);
*sources
.entry(entry.source.id.as_str().to_owned())
.or_insert(0usize) += 1;
peer_events.insert(event.id.clone(), event);
}
}
let mut digest = Sha256::new();
for id in peer_events.keys() {
digest.update(id.as_bytes());
}
let mut receipt = Receipt {
status: Status::Observed,
events: peer_events.len(),
added_events: 0,
sources,
event_ids_sha256: hex::encode(digest.finalize()),
};
let existing: BTreeSet<_> = relay_events.iter().map(|event| event.id.as_str()).collect();
peer_events.retain(|id, _| !existing.contains(id.as_str()));
let mut bytes = 0usize;
for event in relay_events.iter().chain(peer_events.values()) {
bytes = bytes.saturating_add(serde_json::to_vec(event)?.len());
}
ensure!(
relay_events.len().saturating_add(peer_events.len()) <= policy.max_events_per_author
&& bytes <= policy.max_bytes_per_author,
"combined relay/pubsub event/byte budget exhausted"
);
receipt.added_events = peer_events.len();
relay_events.extend(peer_events.into_values());
relay_events.sort_by(|left, right| left.id.cmp(&right.id));
Ok(receipt)
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod discovery_tests;
#[cfg(test)]
mod relay_discovery_tests;