Initial commit

This commit is contained in:
Jazz Turner-Baggs 2026-03-06 10:05:49 +00:00
commit fc23911bd3
No known key found for this signature in database
14 changed files with 2761 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/target
/.DS_Store
.DS_Store
/libs

1821
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

5
Cargo.toml Normal file
View File

@ -0,0 +1,5 @@
[workspace]
members = ["chat"]
# optional but nice
resolver = "2"

61
Makefile Normal file
View File

@ -0,0 +1,61 @@
# Config
REPO_URL = https://github.com/logos-messaging/logos-messaging-nim
REPO_DIR = logos-messaging-nim
OUTPUT_DIR = libs
LIBWAKU_NIM_PARAMS ?= --undef:metrics
# Platform-specific library name
ifeq ($(shell uname),Darwin)
LIB_NAME = libwaku.dylib
# macOS needs SDKROOT for Clang to find <string.h> and friends.
export SDKROOT ?= $(shell xcrun --show-sdk-path)
else
LIB_NAME = libwaku.so
endif
.PHONY: all clean setup build copy
all: setup build copy
# 1. Setup: Clone and initialize submodules
setup:
@echo "--- [1/3] Checking dependencies ---"
@if ! command -v nim > /dev/null; then \
echo "Error: Nim is not installed. Please run: brew install nim"; \
exit 1; \
fi
@echo "--- Checking repository ---"
@if [ ! -d "$(REPO_DIR)" ]; then \
echo "Cloning logos-messaging-nim..."; \
git clone $(REPO_URL) $(REPO_DIR); \
else \
echo "Repository exists. Updating..."; \
cd $(REPO_DIR) && git pull; \
fi
@echo "--- Initializing Submodules ---"
cd $(REPO_DIR) && git submodule update --init --recursive
# 2. Build: Use the repo's internal 'make'
build:
@echo "--- [2/3] Building libwaku ---"
@echo "Using SDKROOT: $(SDKROOT)"
@# Update vendored deps
cd $(REPO_DIR) && $(MAKE) update
@# Compile
cd $(REPO_DIR) && $(MAKE) libwaku BUILD_COMMAND="libwakuDynamic $(LIBWAKU_NIM_PARAMS)"
# 3. Retrieve: Copy the result
copy:
@echo "--- [3/3] Retrieving library ---"
@mkdir -p $(OUTPUT_DIR)
@if [ -f "$(REPO_DIR)/build/$(LIB_NAME)" ]; then \
cp "$(REPO_DIR)/build/$(LIB_NAME)" "$(OUTPUT_DIR)/$(LIB_NAME)"; \
else \
echo "Error: Could not find $(LIB_NAME) in $(REPO_DIR)/build/"; \
exit 1; \
fi
@echo "Success! Library located at: ./$(OUTPUT_DIR)/$(LIB_NAME)"
clean:
rm -rf $(OUTPUT_DIR)
rm -rf $(REPO_DIR)

18
build.rs Normal file
View File

@ -0,0 +1,18 @@
fn main() -> Result<(), std::io::Error> {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let libs_dir = std::path::Path::new(&manifest_dir).join("libs");
println!("cargo:rustc-link-search=native={}", libs_dir.display());
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
match target_os.as_str() {
"macos" | "linux" => {
println!("cargo:rustc-link-lib=dylib=waku");
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", libs_dir.display());
}
other => {
panic!("Unsupported target OS: {other}. Only macOS and Linux are supported.");
}
}
Ok(())
}

23
chat/Cargo.toml Normal file
View File

@ -0,0 +1,23 @@
[package]
name = "chat-rs"
version = "0.1.0"
edition = "2024"
[lib]
name = "chat_rs"
path = "src/lib.rs"
[[bin]]
name = "dev"
path = "src/main.rs"
[dependencies]
anyhow = "1"
async-trait = "0.1.89"
base64 = "0.22"
chrono = "0.4.42"
mpsc = "0.2.6"
serde_json = "1"
tokio = { version = "1.48.0", features = ["full", "tokio-macros"] }
tracing = "0.1"
libchat = {git = "https://github.com/logos-messaging/libchat.git"}

12
chat/build.rs Normal file
View File

@ -0,0 +1,12 @@
fn main() {
// libwaku.dylib lives in <workspace_root>/libs/
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let libs_dir = std::path::Path::new(&manifest_dir)
.parent()
.expect("chat package should have a parent directory")
.join("libs");
println!("cargo:rustc-link-search=native={}", libs_dir.display());
println!("cargo:rustc-link-lib=dylib=waku");
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", libs_dir.display());
}

0
chat/src/client.rs Normal file
View File

47
chat/src/ds.rs Normal file
View File

@ -0,0 +1,47 @@
use std::sync::mpsc;
#[derive(Debug)]
pub enum DeliveryServiceError {
WakuNodeAlreadyInitialized(String),
WakuPublishMessageError(String),
Other(anyhow::Error),
}
impl std::fmt::Display for DeliveryServiceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::WakuNodeAlreadyInitialized(s) => write!(f, "waku node already initialized: {s}"),
Self::WakuPublishMessageError(s) => write!(f, "waku publish error: {s}"),
Self::Other(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for DeliveryServiceError {}
pub mod transport {
use std::sync::mpsc;
use super::DeliveryServiceError;
#[derive(Clone)]
pub struct InboundPacket {
pub payload: Vec<u8>,
pub subtopic: String,
pub group_id: String,
pub app_id: Vec<u8>,
pub timestamp: i64,
}
pub struct OutboundPacket {
pub group_id: String,
pub subtopic: String,
pub payload: Vec<u8>,
pub app_id: Vec<u8>,
}
pub trait DeliveryService {
fn send(&self, pkt: OutboundPacket) -> Result<String, DeliveryServiceError>;
fn subscribe(&self) -> mpsc::Receiver<InboundPacket>;
}
}

6
chat/src/lib.rs Normal file
View File

@ -0,0 +1,6 @@
pub mod client;
pub mod ds;
pub mod waku;
pub const GROUP_VERSION: &str = "1";
pub const SUBTOPICS: &[&str] = &["chat"];

26
chat/src/main.rs Normal file
View File

@ -0,0 +1,26 @@
mod ds;
mod waku;
use ds::transport::DeliveryService;
use waku::{WakuConfig, WakuDeliveryService};
const GROUP_VERSION: &str = "1";
const SUBTOPICS: &[&str] = &["chat"];
fn main() {
let result = WakuDeliveryService::start(WakuConfig::default())
.expect("failed to start waku node");
println!("Waku node started. Listening for messages...");
let rx = result.service.subscribe();
for pkt in rx {
println!(
"--- received {} bytes (group={}, subtopic={}) ---",
pkt.payload.len(),
pkt.group_id,
pkt.subtopic
);
println!("raw bytes: {:?}", pkt.payload);
}
}

360
chat/src/waku/mod.rs Normal file
View File

@ -0,0 +1,360 @@
//! Waku transport implementation and Waku-backed `DeliveryService`.
pub(crate) mod sys;
pub(crate) mod wrapper;
use std::sync::{Arc, Mutex, mpsc};
use std::thread;
use std::time::Duration;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use tracing::{debug, error, info};
use crate::ds::{
DeliveryServiceError,
transport::{DeliveryService, InboundPacket, OutboundPacket},
};
use wrapper::WakuNodeCtx;
use super::{GROUP_VERSION, SUBTOPICS};
/// The pubsub topic for the Waku Node.
pub fn pubsub_topic() -> String {
"/waku/2/rs/15/1".to_string()
}
/// Build the content topics for a group.
pub fn build_content_topics(group_name: &str) -> Vec<String> {
SUBTOPICS
.iter()
.map(|subtopic| build_content_topic(group_name, GROUP_VERSION, subtopic))
.collect()
}
/// Build the content topic string: `/{group_name}/{version}/{subtopic}/proto`
pub fn build_content_topic(group_name: &str, group_version: &str, subtopic: &str) -> String {
format!("/{group_name}/{group_version}/{subtopic}/proto")
}
// ── Outbound command ────────────────────────────────────────────────────────
struct OutboundCommand {
pkt: OutboundPacket,
reply: mpsc::SyncSender<Result<String, DeliveryServiceError>>,
}
// ── Subscriber registry ─────────────────────────────────────────────────────
type SubscriberList = Arc<Mutex<Vec<mpsc::SyncSender<InboundPacket>>>>;
/// Result returned by [`WakuDeliveryService::start`].
pub struct WakuStartResult {
pub service: WakuDeliveryService,
/// The local ENR (Ethereum Node Record) if discv5 is enabled.
/// Pass this to other nodes via `WakuConfig::discv5_bootstrap_enrs`.
pub enr: Option<String>,
}
/// Waku-backed delivery service.
///
/// The service starts an embedded Waku node on a dedicated `std::thread`.
/// All interaction is via synchronous `std::sync::mpsc` channels.
///
/// Use [`WakuDeliveryService::start`] to create an instance. Call
/// [`shutdown`](WakuDeliveryService::shutdown) for explicit cleanup, or
/// simply drop all clones to stop the background thread.
#[derive(Clone)]
pub struct WakuDeliveryService {
outbound: mpsc::SyncSender<OutboundCommand>,
subscribers: SubscriberList,
enr: Option<String>,
}
#[derive(Debug, Clone)]
pub struct WakuConfig {
pub node_port: u16,
/// Enable discv5 peer discovery. Nodes on the same clusterId/shard
/// will find each other automatically.
pub discv5: bool,
/// UDP port for discv5 discovery (default: 9000).
pub discv5_udp_port: u16,
/// Bootstrap ENR strings for discv5. If empty and discv5 is enabled,
/// this node acts as a bootstrap node (others must know its ENR).
pub discv5_bootstrap_enrs: Vec<String>,
}
impl Default for WakuConfig {
fn default() -> Self {
Self {
node_port: 60000,
discv5: false,
discv5_udp_port: 9000,
discv5_bootstrap_enrs: Vec::new(),
}
}
}
impl WakuDeliveryService {
/// Start a Waku node and return both the delivery service and the local ENR
/// (if discv5 is enabled).
pub fn start(cfg: WakuConfig) -> Result<WakuStartResult, DeliveryServiceError> {
let (out_tx, out_rx) = mpsc::sync_channel::<OutboundCommand>(256);
let subscribers: SubscriberList = Arc::new(Mutex::new(Vec::new()));
let (ready_tx, ready_rx) = mpsc::channel::<Result<Option<String>, DeliveryServiceError>>();
let subs_for_thread = subscribers.clone();
thread::Builder::new()
.name("waku-node".into())
.spawn(move || {
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Self::node_thread(cfg, out_rx, subs_for_thread, ready_tx);
})) {
let msg = panic
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".to_string());
error!("waku-node thread panicked: {msg}");
}
})
.map_err(|e| DeliveryServiceError::Other(anyhow::anyhow!(e)))?;
// Wait for the node to either start or fail.
let enr = ready_rx
.recv()
.map_err(|e| DeliveryServiceError::Other(anyhow::anyhow!(e)))??;
let service = Self {
outbound: out_tx,
subscribers,
enr: enr.clone(),
};
Ok(WakuStartResult { service, enr })
}
/// The local ENR (Ethereum Node Record), available when discv5 is enabled.
pub fn enr(&self) -> Option<&str> {
self.enr.as_deref()
}
/// Explicitly shut down the background Waku node thread.
///
/// After calling this, all subsequent [`send`](DeliveryService::send) calls
/// will return an error. Alternatively, dropping all clones of this service
/// achieves the same effect.
pub fn shutdown(self) {
drop(self.outbound);
}
fn node_thread(
cfg: WakuConfig,
out_rx: mpsc::Receiver<OutboundCommand>,
subscribers: SubscriberList,
ready_tx: mpsc::Sender<Result<Option<String>, DeliveryServiceError>>,
) {
let mut config = serde_json::json!({
"host": "0.0.0.0",
"port": cfg.node_port,
"relay": true,
"clusterId": 15,
"shards": [1],
"numShardsInNetwork": 8,
"logLevel": "ERROR",
// Keep metrics disabled for libwaku runtime to avoid exposing
// Prometheus/logging endpoints from embedded nodes.
"metricsServer": false,
"metricsLogging": false,
});
if cfg.discv5 {
config["discv5Discovery"] = serde_json::json!(true);
config["discv5UdpPort"] = serde_json::json!(cfg.discv5_udp_port);
if !cfg.discv5_bootstrap_enrs.is_empty() {
config["discv5BootstrapNodes"] = serde_json::json!(cfg.discv5_bootstrap_enrs);
}
}
let config_json = config.to_string();
// Create node
let waku = match WakuNodeCtx::new(&config_json) {
Ok(w) => w,
Err(e) => {
let _ = ready_tx.send(Err(DeliveryServiceError::WakuNodeAlreadyInitialized(e)));
return;
}
};
// Start node
if let Err(e) = waku.start() {
let _ = ready_tx.send(Err(DeliveryServiceError::WakuNodeAlreadyInitialized(e)));
return;
}
info!("Waku node started");
thread::sleep(Duration::from_secs(2));
// Retrieve ENR for discv5 bootstrapping (discv5 is started automatically
// by waku_start when discv5Discovery=true is in the config JSON).
let local_enr = if cfg.discv5 {
match waku.get_enr() {
Ok(enr) => {
info!("Local ENR: {enr}");
Some(enr)
}
Err(e) => {
info!("Could not retrieve ENR: {e}");
None
}
}
} else {
None
};
// Explicit relay subscribe as safety net (config shards may auto-subscribe,
// but this ensures we're subscribed regardless of libwaku version behavior).
let topic = pubsub_topic();
if let Err(e) = waku.relay_subscribe(&topic) {
// Non-fatal: some libwaku versions reject duplicate subscribe
info!("relay_subscribe returned (may already be subscribed): {e}");
}
// Set event callback — this closure must live for the node lifetime.
let subs_for_cb = subscribers.clone();
let event_closure = move |_ret: i32, data: &str| {
if let Some(pkt) = Self::parse_event(data) {
let guard = subs_for_cb.lock();
// If the mutex is poisoned, subscribers are lost — log and skip.
let mut guard = match guard {
Ok(g) => g,
Err(e) => {
error!("Subscriber mutex poisoned: {e}");
return;
}
};
guard.retain(|tx| match tx.try_send(pkt.clone()) {
Ok(()) => true,
Err(mpsc::TrySendError::Full(_)) => true, // keep — just slow
Err(mpsc::TrySendError::Disconnected(_)) => false, // drop — dead
});
}
};
// Heap-allocated closure — must stay alive until node stops.
let _event_cb_guard = waku.set_event_callback(event_closure);
// Signal ready (with ENR)
let _ = ready_tx.send(Ok(local_enr));
// Outbound loop — blocks until all senders drop
let topic = pubsub_topic();
while let Ok(cmd) = out_rx.recv() {
let res = Self::do_publish(&waku, &topic, cmd.pkt);
let _ = cmd.reply.try_send(res);
}
// _event_cb_guard dropped here, then waku dropped (calls waku_stop via Drop)
info!("Waku outbound loop finished");
}
fn do_publish(
waku: &WakuNodeCtx,
pubsub_topic: &str,
pkt: OutboundPacket,
) -> Result<String, DeliveryServiceError> {
let content_topic = build_content_topic(&pkt.group_id, GROUP_VERSION, &pkt.subtopic);
let payload_b64 = BASE64.encode(&pkt.payload);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let meta_b64 = BASE64.encode(&pkt.app_id);
let msg_json = serde_json::json!({
"payload": payload_b64,
"contentTopic": content_topic,
"timestamp": timestamp as u64,
"version": 2,
"meta": meta_b64,
})
.to_string();
waku.relay_publish(pubsub_topic, &msg_json, 10_000)
.map_err(|e| {
error!("Failed to relay publish: {e}");
DeliveryServiceError::WakuPublishMessageError(e)
})
}
/// Parse a waku event JSON into an InboundPacket (if it's a message event).
fn parse_event(data: &str) -> Option<InboundPacket> {
let v: serde_json::Value = serde_json::from_str(data).ok()?;
let waku_msg = v.get("wakuMessage")?;
let payload_b64 = waku_msg.get("payload")?.as_str()?;
let payload = BASE64.decode(payload_b64).ok()?;
let content_topic = waku_msg.get("contentTopic")?.as_str()?;
let timestamp = waku_msg
.get("timestamp")
.and_then(|t| t.as_i64())
.unwrap_or(0);
let meta = waku_msg
.get("meta")
.and_then(|m| m.as_str())
.and_then(|m| BASE64.decode(m).ok())
.unwrap_or_default();
// Parse content topic: /{group_name}/{version}/{subtopic}/proto
let (group_id, subtopic) = Self::parse_content_topic(content_topic)?;
debug!("Inbound message: group={group_id} subtopic={subtopic}");
Some(InboundPacket {
payload,
subtopic,
group_id,
app_id: meta,
timestamp,
})
}
/// Parse `/{group_name}/{version}/{subtopic}/proto` into (group_id, subtopic).
fn parse_content_topic(ct: &str) -> Option<(String, String)> {
// Expected: "/{group_name}/{version}/{subtopic}/proto"
let mut parts = ct.split('/');
let _empty = parts.next()?; // leading ""
let group_id = parts.next()?;
let _version = parts.next()?;
let subtopic = parts.next()?;
if group_id.is_empty() || subtopic.is_empty() {
return None;
}
Some((group_id.to_owned(), subtopic.to_owned()))
}
}
impl DeliveryService for WakuDeliveryService {
fn send(&self, pkt: OutboundPacket) -> Result<String, DeliveryServiceError> {
let (reply_tx, reply_rx) = mpsc::sync_channel(1);
self.outbound
.send(OutboundCommand {
pkt,
reply: reply_tx,
})
.map_err(|e| DeliveryServiceError::Other(anyhow::anyhow!(e)))?;
reply_rx
.recv()
.map_err(|e| DeliveryServiceError::Other(anyhow::anyhow!(e)))?
}
fn subscribe(&self) -> mpsc::Receiver<InboundPacket> {
let (tx, rx) = mpsc::sync_channel(256);
match self.subscribers.lock() {
Ok(mut g) => g.push(tx),
Err(e) => {
error!("Subscriber mutex poisoned, subscription lost: {e}");
}
}
rx
}
}

96
chat/src/waku/sys.rs Normal file
View File

@ -0,0 +1,96 @@
//! Raw FFI declarations matching libwaku.h (trampoline pattern).
#![allow(unused)]
use std::os::raw::{c_char, c_int, c_uint, c_void};
use std::slice;
pub type FFICallBack = unsafe extern "C" fn(c_int, *const c_char, usize, *const c_void);
#[link(name = "waku")]
unsafe extern "C" {
pub fn waku_new(
config_json: *const c_char,
cb: FFICallBack,
user_data: *const c_void,
) -> *mut c_void;
pub fn waku_start(ctx: *mut c_void, cb: FFICallBack, user_data: *const c_void) -> c_int;
pub fn waku_stop(ctx: *mut c_void, cb: FFICallBack, user_data: *const c_void) -> c_int;
pub fn waku_version(ctx: *mut c_void, cb: FFICallBack, user_data: *const c_void) -> c_int;
pub fn set_event_callback(ctx: *mut c_void, cb: FFICallBack, user_data: *const c_void);
pub fn waku_relay_publish(
ctx: *mut c_void,
cb: FFICallBack,
user_data: *const c_void,
pubsub_topic: *const c_char,
json_message: *const c_char,
timeout_ms: c_uint,
) -> c_int;
pub fn waku_relay_subscribe(
ctx: *mut c_void,
cb: FFICallBack,
user_data: *const c_void,
pubsub_topic: *const c_char,
) -> c_int;
pub fn waku_connect(
ctx: *mut c_void,
cb: FFICallBack,
user_data: *const c_void,
peer_multi_addr: *const c_char,
timeout_ms: c_uint,
) -> c_int;
pub fn waku_get_my_peerid(ctx: *mut c_void, cb: FFICallBack, user_data: *const c_void)
-> c_int;
pub fn waku_start_discv5(ctx: *mut c_void, cb: FFICallBack, user_data: *const c_void) -> c_int;
pub fn waku_stop_discv5(ctx: *mut c_void, cb: FFICallBack, user_data: *const c_void) -> c_int;
pub fn waku_get_my_enr(ctx: *mut c_void, cb: FFICallBack, user_data: *const c_void) -> c_int;
pub fn waku_discv5_update_bootnodes(
ctx: *mut c_void,
cb: FFICallBack,
user_data: *const c_void,
bootnodes: *const c_char,
) -> c_int;
}
// ── Trampoline pattern ──────────────────────────────────────────────────────
pub unsafe extern "C" fn trampoline<C>(
return_val: c_int,
buffer: *const c_char,
buffer_len: usize,
data: *const c_void,
) where
C: FnMut(i32, &str),
{
if data.is_null() {
return;
}
let closure = unsafe { &mut *(data as *mut C) };
if buffer.is_null() || buffer_len == 0 {
closure(return_val, "");
return;
}
let bytes = unsafe { slice::from_raw_parts(buffer as *const u8, buffer_len) };
let buffer_str = String::from_utf8_lossy(bytes);
closure(return_val, &buffer_str);
}
pub fn get_trampoline<C>(_closure: &C) -> FFICallBack
where
C: FnMut(i32, &str),
{
trampoline::<C>
}
pub const RET_OK: i32 = 0;

282
chat/src/waku/wrapper.rs Normal file
View File

@ -0,0 +1,282 @@
//! Safe synchronous wrapper around the raw libwaku FFI.
#![allow(unused)]
use std::cell::OnceCell;
use std::ffi::CString;
use std::os::raw::c_void;
use super::sys::{self as waku_sys, RET_OK, get_trampoline};
/// Opaque handle to a libwaku node context.
pub struct WakuNodeCtx {
ctx: *mut c_void,
}
// The libwaku ctx pointer is thread-safe (single node, serialized calls inside C).
unsafe impl Send for WakuNodeCtx {}
unsafe impl Sync for WakuNodeCtx {}
impl WakuNodeCtx {
/// Create a new waku node. `config_json` is the JSON string for node configuration.
pub fn new(config_json: &str) -> Result<Self, String> {
let config_cstr = CString::new(config_json).map_err(|e| e.to_string())?;
let mut err: Option<String> = None;
let mut closure = |ret: i32, data: &str| {
if ret != RET_OK {
err = Some(data.to_string());
}
};
let cb = get_trampoline(&closure);
let ctx = unsafe {
waku_sys::waku_new(
config_cstr.as_ptr(),
cb,
&mut closure as *mut _ as *const c_void,
)
};
if ctx.is_null() || err.is_some() {
return Err(err.unwrap_or_else(|| "waku_new returned null".into()));
}
Ok(Self { ctx })
}
/// Start the node.
pub fn start(&self) -> Result<(), String> {
let mut err: Option<String> = None;
let mut closure = |ret: i32, data: &str| {
if ret != RET_OK {
err = Some(data.to_string());
}
};
let cb = get_trampoline(&closure);
let ret =
unsafe { waku_sys::waku_start(self.ctx, cb, &mut closure as *mut _ as *const c_void) };
if ret != RET_OK || err.is_some() {
return Err(err.unwrap_or_else(|| format!("waku_start returned {ret}")));
}
Ok(())
}
/// Get the node version string.
pub fn version(&self) -> Result<String, String> {
let version: OnceCell<String> = OnceCell::new();
let mut closure = |ret: i32, data: &str| {
if ret == RET_OK {
let _ = version.set(data.to_string());
}
};
let cb = get_trampoline(&closure);
let ret = unsafe {
waku_sys::waku_version(self.ctx, cb, &mut closure as *mut _ as *const c_void)
};
if ret != RET_OK {
return Err(format!("waku_version returned {ret}"));
}
version
.into_inner()
.ok_or_else(|| "no version returned".into())
}
/// Get the local peer ID.
pub fn get_peer_id(&self) -> Result<String, String> {
let peer_id: OnceCell<String> = OnceCell::new();
let mut closure = |ret: i32, data: &str| {
if ret == RET_OK {
let _ = peer_id.set(data.to_string());
}
};
let cb = get_trampoline(&closure);
let ret = unsafe {
waku_sys::waku_get_my_peerid(self.ctx, cb, &mut closure as *mut _ as *const c_void)
};
if ret != RET_OK {
return Err(format!("waku_get_my_peerid returned {ret}"));
}
peer_id
.into_inner()
.ok_or_else(|| "no peer id returned".into())
}
/// Connect to a peer by multiaddr.
pub fn connect(&self, peer_multi_addr: &str, timeout_ms: u32) -> Result<(), String> {
let addr_cstr = CString::new(peer_multi_addr).map_err(|e| e.to_string())?;
let mut err: Option<String> = None;
let mut closure = |ret: i32, data: &str| {
if ret != RET_OK {
err = Some(data.to_string());
}
};
let cb = get_trampoline(&closure);
let ret = unsafe {
waku_sys::waku_connect(
self.ctx,
cb,
&mut closure as *mut _ as *const c_void,
addr_cstr.as_ptr(),
timeout_ms,
)
};
if ret != RET_OK || err.is_some() {
return Err(err.unwrap_or_else(|| format!("waku_connect returned {ret}")));
}
Ok(())
}
/// Publish a message via relay. `json_message` is the waku message JSON.
pub fn relay_publish(
&self,
pubsub_topic: &str,
json_message: &str,
timeout_ms: u32,
) -> Result<String, String> {
let topic_cstr = CString::new(pubsub_topic).map_err(|e| e.to_string())?;
let msg_cstr = CString::new(json_message).map_err(|e| e.to_string())?;
let result: OnceCell<String> = OnceCell::new();
let mut err: Option<String> = None;
let mut closure = |ret: i32, data: &str| {
if ret == RET_OK {
let _ = result.set(data.to_string());
} else {
err = Some(data.to_string());
}
};
let cb = get_trampoline(&closure);
let ret = unsafe {
waku_sys::waku_relay_publish(
self.ctx,
cb,
&mut closure as *mut _ as *const c_void,
topic_cstr.as_ptr(),
msg_cstr.as_ptr(),
timeout_ms,
)
};
if ret != RET_OK || err.is_some() {
return Err(err.unwrap_or_else(|| format!("waku_relay_publish returned {ret}")));
}
Ok(result.into_inner().unwrap_or_default())
}
/// Subscribe to a relay pubsub topic.
pub fn relay_subscribe(&self, pubsub_topic: &str) -> Result<(), String> {
let topic_cstr = CString::new(pubsub_topic).map_err(|e| e.to_string())?;
let mut err: Option<String> = None;
let mut closure = |ret: i32, data: &str| {
if ret != RET_OK {
err = Some(data.to_string());
}
};
let cb = get_trampoline(&closure);
let ret = unsafe {
waku_sys::waku_relay_subscribe(
self.ctx,
cb,
&mut closure as *mut _ as *const c_void,
topic_cstr.as_ptr(),
)
};
if ret != RET_OK || err.is_some() {
return Err(err.unwrap_or_else(|| format!("waku_relay_subscribe returned {ret}")));
}
Ok(())
}
/// Register the event callback. Returns the boxed closure — caller must keep
/// it alive for the lifetime of the node (dropping it invalidates the FFI pointer).
pub fn set_event_callback<C>(&self, closure: C) -> Box<C>
where
C: FnMut(i32, &str),
{
let mut boxed = Box::new(closure);
let cb = get_trampoline(&*boxed);
unsafe {
waku_sys::set_event_callback(self.ctx, cb, &mut *boxed as *mut C as *const c_void);
}
boxed
}
/// Stop the node. Should be called before dropping to cleanly release resources.
pub fn stop(&self) -> Result<(), String> {
let mut err: Option<String> = None;
let mut closure = |ret: i32, data: &str| {
if ret != RET_OK {
err = Some(data.to_string());
}
};
let cb = get_trampoline(&closure);
let ret =
unsafe { waku_sys::waku_stop(self.ctx, cb, &mut closure as *mut _ as *const c_void) };
if ret != RET_OK || err.is_some() {
return Err(err.unwrap_or_else(|| format!("waku_stop returned {ret}")));
}
Ok(())
}
/// Start the discv5 discovery service.
pub fn start_discv5(&self) -> Result<(), String> {
let mut err: Option<String> = None;
let mut closure = |ret: i32, data: &str| {
if ret != RET_OK {
err = Some(data.to_string());
}
};
let cb = get_trampoline(&closure);
let ret = unsafe {
waku_sys::waku_start_discv5(self.ctx, cb, &mut closure as *mut _ as *const c_void)
};
if ret != RET_OK || err.is_some() {
return Err(err.unwrap_or_else(|| format!("waku_start_discv5 returned {ret}")));
}
Ok(())
}
/// Get the local ENR (Ethereum Node Record) string.
pub fn get_enr(&self) -> Result<String, String> {
let enr: OnceCell<String> = OnceCell::new();
let mut closure = |ret: i32, data: &str| {
if ret == RET_OK {
let _ = enr.set(data.to_string());
}
};
let cb = get_trampoline(&closure);
let ret = unsafe {
waku_sys::waku_get_my_enr(self.ctx, cb, &mut closure as *mut _ as *const c_void)
};
if ret != RET_OK {
return Err(format!("waku_get_my_enr returned {ret}"));
}
enr.into_inner().ok_or_else(|| "no ENR returned".into())
}
}
impl Drop for WakuNodeCtx {
fn drop(&mut self) {
if let Err(e) = self.stop() {
tracing::warn!("waku_stop failed during drop: {e}");
}
}
}