logo
vision scalability social networking revelation source
consensus immutability Bitcoin mixer snarks

Messaging

abstract: We have key managment and secrets management functionality in src, which is useless by itself.

We want Bitmessage functionality, which means different instances of the program on different machines have to exchange messages.

This document describes the first step towards that, and only the first step: pre-alpha communication. All interaction with the Rust side happens on secondary wxWidgets threads, never on the main UI thread.

The program in src is called wallet, because it is single mindedly focused on key management and secrets management.

We want to add messaging capability. For this, we need libp2p and Gossip sub. We want to implement the simplest possible chat app to start with. We can add the capabilities to make it useful later, but we want to get chat working, and then we develop from there.

What is described here is pre-alpha communication, and it is important not to mistake it for Bitmessage. Gossipsub gives far less than Bitmessage gives: no proof of work against spam, no per recipient encryption, no deniability, no privacy against a peer observing who talks to whom, no global name space of addresses. It also gives far more than a pair of wallets on a trusted local network needs, where an ordinary connected socket and an agreed encoding would do. Both facts are fine, because the point of this first step is not a product. The point is to get communication happening at all, and to have a test environment that verifies that communication. The capabilities Bitmessage actually requires are developed on top of that, later.

The existing program uses a variety of C and C++ libraries, sqlite to store data, wxWidgets for UI.

Unfortunately, the C and C++ implementations of libp2p are lagging. Need to rust libp2p, and a rust application using libp2p must use Asynchronous concurrency, therefore must be written in rust.

Asynchronous concurrency does not, in theory, force Tokio on us: rust-libp2p’s swarm takes any spawner, and tokio is an optional feature of the libp2p crate. In practice it does, because the alternative to using Tokio is to re-invent Tokio — a work stealing scheduler, an io driver, timers, cancellation, and a channel library — which nobody wants to do. Tokio is therefore the de-facto standard for rust-libp2p applications, and this design uses it as such.

The application is written in C++. It is going to need libp2p and gossipsub, but an application to use libp2p and gossip sub has to be written in rust, using asynchronous concurrency and Tokio.

We are going to need to add Rust, Tokio, and Libp2p to the project.

The appropriate toolchain for combining C, Rust, and C++ through the C ABI is CMake plus Corrosion, the standard CMake/Rust integration project. Corrosion is vendored into the source tree like our other dependencies (wxWidgets, libsodium, mpir, utf8proc).

Plan for implementing chat with this program:

1 The overall shape

The natural direction is: the C++ wallet executable stays the process owner; Rust compiles to a static library (staticlib) and is linked into it across a C ABI. Reasons:

Design rules that keep the boundary safe:

2 The C ABI, specified

The surface is tiny and boring. Hand-written net/wallet_net.h (~60 lines; cbindgen is optional later if it ever drifts):

#ifndef WALLET_NET_H
#define WALLET_NET_H
#include <stdint.h>
#include <stddef.h>

typedef struct WalletNet WalletNet;         /* opaque handle */

/* One inbound chat message. All strings are UTF-8, NUL-terminated,
   Rust-allocated. Owner must call wallet_net_free_message(). */
typedef struct WalletNetMessage {
    char    *from;        /* peer id as a multibase string */
    uint64_t ts_millis;   /* sender-claimed unix timestamp, ms */
    char    *text;        /* message body, UTF-8 validated by Rust */
} WalletNetMessage;

/* Error codes */
enum {
    WALLET_NET_OK          =  0,
    WALLET_NET_ERR         = -1,   /* see wallet_net_last_error() */
    WALLET_NET_ERR_BUSY    = -2,   /* outbound queue full; retry later */
    WALLET_NET_ERR_ARG     = -3,   /* null/invalid argument */
    WALLET_NET_ERR_TOO_BIG = -4    /* input exceeds size limit */
};

uint32_t     wallet_net_abi_version(void);            /* returns 1 */
WalletNet   *wallet_net_start(const char *config_json);
int          wallet_net_stop(WalletNet *h);
int          wallet_net_send(WalletNet *h, const char *topic, const char *text);
int          wallet_net_poll(WalletNet *h, WalletNetMessage *out, size_t max, size_t *n);
void         wallet_net_free_message(WalletNetMessage *msg);
const char  *wallet_net_last_error(void);
#endif

Semantics, precisely:

3 Building with Corrosion

CMake has no built-in find_package(Rust), no add_rust_library(), and its native enable_language(Rust) remains experimental. The standard solution is Corrosion, which wraps cargo from CMake and creates proper imported targets. We vendor it: corrosion/ alongside wxWidgets/, libsodium/, etc.

  add_subdirectory(corrosion)
  corrosion_import_crate(MANIFEST_PATH net/Cargo.toml PROFILE release)
  target_link_libraries(wallet PRIVATE walletnet)

Notes on the ecosystem pieces:

4 How the Tokio runtime is launched and shut down

The net thread calls wallet_net_start() once, when it starts — that is, at app start, or when the user opens the chat panel and the main thread asks for a net thread to be created. The runtime is a multi-thread Tokio runtime; it spawns its own worker threads. The long-lived libp2p task is spawned onto it with rt.spawn() — there is no separate block_on thread, and no thread joining at shutdown. Sketch:

static INSTANCE: Mutex<Option<Arc<NetHandle>>> = Mutex::new(None);   // idempotent start

pub extern "C" fn wallet_net_start(cfg_json: *const c_char) -> *mut NetHandle {
    ffi_catch(|| {
        let mut inst = INSTANCE.lock().unwrap_or_recover();
        if let Some(h) = inst.as_ref() { return Arc::as_ptr(h) as *mut _; }  // same handle

        let cfg = parse_config(cvt(cfg_json)?)?;
        let rt = tokio::runtime::Builder::new_multi_thread()
            .enable_all()
            .build()?;                                   // error -> last_error, NULL out
        let (tx, rx) = tokio::sync::mpsc::channel::<Command>(64);
        let task = rt.spawn(net_main(rx, cfg));           // the heart of the async world
        let h = Arc::new(NetHandle { rt, task, tx, inbox: Mutex::new(VecDeque::new()) });
        *inst = Some(h.clone());
        Ok(Arc::into_raw(h) as *mut NetHandle)
    })
}

async fn net_main(mut rx: mpsc::Receiver<Command>, cfg: Config) -> anyhow::Result<()> {
    let mut swarm = /* tcp + noise + yamux + gossipsub Swarm from libp2p */;
    swarm.behaviour_mut().subscribe(&cfg.topic)?;
    loop {
        tokio::select! {
            ev = swarm.select_next_some() => {
                if let SwarmEvent::Behaviour(Gossipsub::Message { propagation_source, message }) = ev {
                    // validate UTF-8 + size limit, then push {from, ts, text}
                    // into the handle's inbox for wallet_net_poll to drain
                }
            }
            cmd = rx.recv() => match cmd {
                Some(Command::Send { topic, text }) => { let _ = publish(&mut swarm, topic, text); }
                Some(Command::Stop) | None => break,
            }
        }
    }
    Ok(())
}

The call chain, from top to bottom:

  1. The main UI thread creates the net thread (a wxThread) and, from then on, communicates with it only by posting wxThreadEvents with wxQueueEvent. The net thread calls wallet_net_start(), which returns almost instantly: it built a runtime and spawned one task.
  2. net_main is the thing that drives everything: it owns the libp2p Swarm, and its select! loop runs as a task on the runtime’s worker threads — Noise handshakes, connection manager, gossipsub receive/publish. libp2p-rs is an actor you poll; this task is the heart.
  3. Commands (send, stop) travel from the UI thread to the net thread as wx events, and from the net thread down a bounded channel via try_send — never blocking_send, so a full channel returns BUSY and the net thread retries, instead of anything blocking.
  4. Inbound chat messages travel up into a mutex-protected inbox. The net thread’s own loop (say every 100 ms) calls wallet_net_poll(), and each drained message becomes a wxThreadEvent posted to the UI thread with wxQueueEvent. (The push alternative — Rust calling a C-registered function pointer — also works and is lower-latency, but polling from the net thread keeps reentrancy away from the FFI boundary entirely, and the UI thread sees nothing but an ordinary wx event arriving in its own queue. For v1 chat, 100 ms latency is invisible.)

So the two event mechanisms meet only at the net thread: wx events on one side of it, the C ABI on the other, and no thread that has to satisfy both. The UI thread never holds a Rust handle and never waits on Rust; Rust never sees a wx object and never calls into C++.

Shutdown runs the same path in reverse: the net thread calls wallet_net_stop(), which sends Command::Stop down the channel (so net_main can exit cleanly and let in-flight work unwind), then calls rt.shutdown_timeout(Duration::from_secs(2)). This waits up to two seconds for all tasks — including the runtime’s own worker threads — to finish, then drops the runtime. No join, no detach, no unbounded wait. The handle is freed, the singleton slot is cleared, and the net thread then exits; the main thread joins it at app exit.

Three rules that keep this sane:

5 The chat itself, deliberately minimal

6 The version object, exchanged when each peer joins

A connection report says only that a peer is there. Onaya’s answer to “what is that peer” is its version message, sent on every connection before anything else; the wallet speaks the same message — same field layout, little-endian, onaya’s compact-size prefixes for the variable fields — over its own libp2p substream protocol, /rho/version/1.0.0 (net/src/version.rs): the dialer opens one substream per connection, writes its object and reads the reply; the listener answers and never initiates, so every connection carries exactly one exchange in each direction. It is a dedicated protocol rather than a gossipsub message because the greeting belongs to the connection: on the chat topic a join greeting would reach every peer instead of the one that joined, and the duplicate cache would eat the repeats.

The message as rho sends it:

field width what rho puts in it
version 4 rho’s own counter, currently 1 — not onaya’s 3, which numbers a different protocol’s history
services 8 bit 0 normal node; bit 1 set when this node runs as a circuit relay (from config)
timestamp 8 sender’s unix seconds; the receiver reports the difference (skew) alongside it, and a skew of an hour or more rejects the connection
addr_recv 26 the receiving peer’s address, as the sender sees it — zero-filled when the connection is not an IP pair (a relay circuit has no address to report about the peer behind it)
addr_from 26 the sender’s own address, as it best knows it: a dialer names its best bound address (the contact rule, address_rank), not the ephemeral source port of the socket it dialed from — but a NATted sender can only name the private address on its own interface, which is exactly not what its peers see (see observed below)
user_agent var software identification; default /rho-wallet:pre-alpha/, config-overridable ("user_agent")
streams var the topic names the sender subscribes to — strings, not onaya’s 4-byte numbers, because rho interests are strings and a hash of a name is a value neither peer can explain

The wire format carries the peer’s own account and cannot carry the two things it cannot know about itself: the public address its traffic arrives at once a NAT has rewritten it, and the wire its packets actually rode. So the report to the UI (wallet_net_peer_versions) adds fields the receiver fills from its own connection — observed, the ip:port the packets really came from, and transport, the stack that socket rides: the wire named by the connection’s own multiaddr (tcp, circuit, quic…) with this build’s crypto beside it (noise over TCP and circuits; TLS is QUIC’s own by specification; this build listens on QUIC v1 at the TCP listen port’s number over UDP). The Peers page shows the observation and the stack, not the claim; both are kept and traced, and the claim sitting next to the observation is more honest than either alone — the difference between them is the NAT.

Two decisions differ from onaya’s spec on purpose. No nonce: the field exists to catch “you have connected to yourself”, and in this stack that case cannot arrive — the identity is one deterministic keypair per wallet, libp2p authenticates the noise handshake against the peer id, and the swarm refuses a connection to one’s own id; the 8 bytes would carry no actionable statement. Nothing is enforced except the clock: version and services are recorded, traced (-T prints one line per exchange, including “no version object” from a peer that does not speak the protocol) and reported to the UI through wallet_net_peer_versions; a peer running an unknown version is still a peer, and chat must keep working between any two builds until some later rule is written on purpose. The timestamp, however, is a promise about the sender’s clock that both ends can check, and a peer off by an hour or more (MAX_CLOCK_SKEW_SECS in net.rs) is disconnected and left undialed for a minute (CLOCK_REJECT_HOLD_OFF) — a five-second re-dial would only re-run the same handshake against the same broken clock, and sixty seconds is short enough that a conversation recovers on its own once the clock does.

The mechanism is a hand-written ConnectionHandler (dcutr’s shape, ReadyUpgrade + OutboundSubstreamRequest), not libp2p-request-response and not libp2p-ping: neither crate is in net/vendor, and the offline-by-configuration build cannot go and get them. Hand-writing the compact-size codec is the same argument in miniature — the vendored unsigned-varint is LEB128, which is not onaya’s encoding, and a two-byte disagreement of that kind is invisible on the wire.

7 Message inventory: the two-day mempool

Gossipsub is a flood, not a store. Its history spans five heartbeats — seconds — its duplicate cache 60 seconds, and both live in private fields with no application API (libp2p-gossipsub-0.50.0/src/config.rs, mcache.rs: checked, not assumed). A peer that joins late therefore hears nothing that was said before it arrived. The inventory is the missing layer: every message this node has seen — received, pulled, or sent — is kept for 48 hours under its content hash, and each new connection compares inventories and fills the gaps (net/src/inventory.rs).

Why not inside gossipsub. The protocol does carry IHAVE/IWANT, but only between its own handlers, only for ids inside the ~5-heartbeat window, and only as an internal optimisation — no public call lets an application ask what a peer holds or fetch by id. The alternative of publishing inventory lists as chat messages would flood every peer with every join and still deliver old messages wearing new propagation sources. So the sync is its own substream protocol, /rho/inventory/1.0.0, in the dialer-initiated style of the version exchange: offer (IHAVE), ask the difference (IWANT), hand over the bytes (MSG), each round closed by an END frame — four rounds on one full-duplex stream, both directions filled by one connection.

Ids are sha256(wire bytes) — the digest gossipsub’s own message_id_fn already uses, so a message arriving live and the same message arriving by sync are one key, and delivery has exactly one rule everywhere: a message reaches the app when storing it was news to the inventory. That gate also outlives gossipsub’s 60-second duplicate cache: a message that arrives again later is not said twice.

Attribution travels beside the bytes, never inside them. Each stored message carries an origin — the peer id this node believes wrote it: its own id for a message it published, the propagation source for one received live, and the server’s stored origin for one pulled by sync (the MSG frame carries the sidecar). Inside the wire is forbidden: the id is a function of the wire alone, and an embedded author field would give one message two identities — the live copy and the synced copy hashing apart — which is exactly the double-delivery the one rule exists to prevent. The sidecar is honest best-effort, like every label in an anonymous chat: a direct neighbour of the author stores the author, a node further out stores the first hop it saw, and a peer could lie about an origin exactly as easily as about the text — so it is trusted exactly as far as the text is, which is to say: for display. Without it, every message pulled from one peer wore that peer’s name, which read as “everything came from the courier”.

Pull only, no re-flood. A synced message is delivered to the local app and never republished into the mesh: every peer missing it will ask for it over its own connection, so a flood would only re-echo to holders and dress the message in the republisher’s source. A peer running an older build never speaks the protocol, syncs with nobody, and gains nothing from a republish — the documented boundary, not a case to relay for.

Retention runs from the earlier of two clocks. A message lives 48 hours from the earlier of when this node first stored it and the ts its author wrote into it, and one already past that is refused as old news. The local clock alone let a message one node had purged come back from a peer that pulled it later, re-shown and re-dated, so staggered nodes could pass it around forever. The author’s ts alone would let a timestamp from the future make a message immortal. The minimum can only shorten a life: ts is inside the hashed bytes, so only the author chooses it. The runtime store and the database purge on the same rule.

The database is its own file. inventory.sqlite sits beside the wallet files, never inside one (the user’s rule: chat plaintext earns no place in the file holding the wallet’s public identity). The runtime is the live authority — a sync must answer IHAVE in microseconds — and the C++ side mirrors it: wallet_net_inventory_new drains newly-stored lines once a second for INSERT OR IGNORE, and at startup wallet_net_inventory_seed hands every surviving row back, each re-hashed before it is trusted (a file on disk is data, not authority).

Startup replays the carried-over conversation, in the order it was said. Seeded rows and sync-pulled batches are stored undelivered; wallet_net_inventory_flush (called once after seeding, and automatically after every sync) hands the whole undelivered set to the inbox sorted by wire timestamp — storage order is database order, and a log reads in the order things were written. A live message is never part of a backlog: it is delivered the moment it is stored. On the window side the log keeps its lines in a timestamp-ordered memory: an arrival older than the last line drawn — a weekend pulled back by a sync, a startup replay landing after the “(topic …)” notes — is inserted at its timestamp and the log replayed once per burst, so history lands between the lines it belongs between instead of after everything. A message whose origin is this node’s own id draws in the own-message style: after a restart, what he wrote reads as what he wrote.

Caps: 2048 ids per announcement (newest kept; the rest wait for the next connection), 8 MiB per sync, messages bounded by the chat limit, a 30 s idle timeout per frame within a ten-minute bound per sync, and a 10 000-line persist queue whose overflow is a trace-visible loss of durability, not of service.

The Peers page shows the protocol’s own accounting (wallet_net_inventory_stats): cumulative ids announced and asked, messages pulled and served, and a gauge over asked-versus-arrived that is full when idle and advances during a pull.

8 Development phases

We want the absolute minimal basic system working first. The first step is not the minimal viable product — it is the minimal system that compiles all the libraries and gets them to perform.

  1. Build integration. Vendor Corrosion; create net/ with a trivial Rust staticlib exposing wallet_net_abi_version(); wire it into CMake; wallet calls it at startup and prints/asserts the version. Success = wallet builds and links with the Rust lib on Linux, and the ABI call returns 1. Windows/MSVC is deliberately not part of this criterion; see the next section. Also settle here: that a vendored Corrosion configures offline, that crate-type = ["staticlib", "lib"] really is what cargo test needs, and what target name Corrosion actually creates for the crate.
  2. Runtime lifecycle. Implement wallet_net_start/wallet_net_stop with the Tokio runtime but no libp2p yet (net_main just waits for Stop), plus the net thread itself and the wx inter-thread events on both sides of it. Success = start/stop repeatedly, driven from the UI thread by posted events and executed on the net thread, without hangs, leaks, or crashes; second start returns the same handle; and no C-ABI call is ever observed on the main thread.
  3. libp2p on the local net. Add tcp + noise + yamux + gossipsub; two wallet instances on the LAN connect by pasted multiaddr and exchange messages through one topic, verified first via a cargo test two-node in-process test, then via two real wallet processes. Send/poll/stop wired through the C ABI with a minimal chat panel (text box; the net thread polls and posts events). This is the pre-alpha milestone: it proves that communication happens and gives us the test environment that verifies it. It is not yet a messaging product.
  4. Only then: the product-level work — contacts, per-chat topics, message history, and the real-internet issues (NAT, hole punching, discovery; see nat.md) when the test environment moves off the local net. On the local net all ports are open and multicast works, so none of that machinery is needed yet; we cross those bridges when we come to them. Everything that Bitmessage requires and gossipsub does not provide — proof of work, per recipient encryption, deniability — belongs here and beyond, not in phases 1 to 3.

9 Windows and MSVC: known problems, deliberately deferred

The plan is to get all of this building, linking, and running on Linux first. Windows/MSVC integration is a substantial piece of work in its own right, and the following known problems are recorded here so that they are not rediscovered later, and then deliberately not solved until Linux works:

10 Pitfalls to keep on the radar

11 Deliberately not decided yet

The following are known open questions that this document does not answer, on purpose. Answering them before anything builds is overplanning: each gets settled when we have something building, or when we are trying to get something to build.

The summary sentence for the design doc: wallet (C++) owns the process and the UI; it links a Rust staticlib built by vendored Corrosion under CMake ≥ 3.22, and talks to it through ~7 non-blocking extern “C” functions with a specified message struct and ownership rules — always from a secondary wxWidgets net thread, never from the main UI thread, which exchanges wx inter-thread events with that thread and nothing else. The Rust library hosts a single Tokio runtime whose one long-lived spawned task is the libp2p/gossipsub swarm loop, created (idempotently) by wallet_net_start() and torn down by wallet_net_stop() via shutdown_timeout. What it delivers is pre-alpha communication: the first step towards Bitmessage’s capabilities, and a test environment that proves communication happens.

Creative Commons License reaction.la gpg key 154588427F2709CD9D7146B01C99BB982002C39F
This work is licensed under the Creative Commons Attribution 4.0 International License.