Today a "network" is one P2p instance identified by {magic_bytes,
app_name, app_version}. Magic bytes are checked at channel setup
(src/net/channel.rs, raw frame read) before the version/verack handshake
checks app_name. Hostlists, refinement, and datastores are per-P2p.
Lilith spawns one P2p per configured network (bin/lilith/src/main.rs,
spawn_net()), each needing its own listener, datastore, and config section.
Apps construct their own P2p at startup from static seed lists.
Extension points already public in src/net and sufficient for an overlay
without core surgery:
ProtocolRegistry::register(session_flags, constructor) — attach custom
protocols per session type (protocol_registry.rs).#[macro_export] impl_p2p_message! (message.rs) — define new wire
messages with metering; already used by event_graph and fud.GetAddrsMessage/AddrsMessage gossip via ProtocolAddress establishes
the pattern ads should follow: relayed, unsigned, refinement-filtered.src/dht exists for content-keyed lookup (Kademlia) and is used by fud;
it is the wrong tool for membership (structured lookup paths are linkable).See proposal.md for motivation. Constraints that shape this design: no changes to channel framing, magic-byte gating, or handshake semantics; the overlay must not become a cross-subnet correlation point; no stable node identity may cross subnets.
Goals:
P2p networks: their own magic bytes, hostlist,
refinement, datastore — joined by direct dial after discovery.SubnetId.Non-Goals (design-level):
src/net semantics — additive exports only.Three alternatives were considered:
session/+channel.rs,
and mixes subnet traffic on one wire — a traffic-analysis surface that
violates the anonymity constraints.GetAddrs/Addrs (one global network carrying all
subnets): smallest diff, but merges all hostlists into one refinement
state, exposing cross-subnet membership in a node's address book and
connection churn.Swarm owns one overlay P2p plus a
HashMap<SubnetId, SubnetEntry>; each SubnetEntry owns an ordinary
spawned P2p. Overlay only bootstraps; subnet health remains
self-maintaining via existing refinery.SubnetId = blake3(canonical descriptor)descriptor := app_name || magic_bytes || version_constraint || secret?
SubnetId := blake3(descriptor)
secret present → unguessable ID: a non-member cannot even name the
subnet, giving obscurity-based access control (rendezvous-string style).version_constraint is part of the descriptor so a subnet's version
policy is fixed at creation; exact pin initially, ranges deferred.Alternative: human-readable subnet names — rejected: global names leak the set of private subnets into gossip and invite squatting.
SubnetAd { subnet_id, addrs: Vec<(Url, u64)>, ttl_secs }
AddrsMessage; the peer
an ad is received from is not its author → origin ambiguity.ttl expiry + refinery liveness
checks (lilith's existing whitelist-refinery pattern, retargeted at ads).Alternative: per-subnet signing keys. Gives poisoning resistance but tempts key reuse across subnets (linkability) and adds key management; deferred until refinement proves insufficient.
ProtocolSwarm, all outside src/net coreSubnetAd (gossip, unsolicited)
GetSubnets → Subnets (list known subnet_ids)
GetSubnetAddrs{subnet_id} → SubnetAddrs{subnet_id, addrs}
Defined via impl_p2p_message! with metering configurations and
MAX_BYTES estimates in the existing style. ProtocolSwarm is registered
via ProtocolRegistry on outbound+inbound sessions of the overlay P2p
only. GetSubnets responses are built from local ad-store state; queries
reveal participation in the overlay but not in any particular subnet.
SubnetAddrs, seed the subnet P2p's greylist
(via Hosts::insert, grey), dial. No ad is emitted.SwarmSwarm::join(subnet_descriptor) → resolve via overlay → spawn subnet
P2p with per-subnet p2p_datastore/hostlist paths derived from
SubnetId under a swarm-managed directory; register app protocols onto
that P2p's registry before start().Swarm::serve(subnet_descriptor, inbound_cfg) → join + advertise.Swarm::leave(subnet_id) → P2p::stop() + deregister; ads simply expire
via TTL (no "leave" message — a departure broadcast would create a
timing-correlation surface).start(). Watch item: executor
shutdown ordering when many subnet P2ps stop concurrently.One config section ([overlay]: accept addrs, datastore), no per-network
sections. Runs the overlay P2p with inbound_connections high,
outbound_connections 0 (unchanged posture: no outbound dialing), plus:
persistent ad store (ads survive restarts until TTL), refinery-based ad
expiry, and the spawns RPC retargeted at overlay stats (known subnets,
ad counts). Existing per-network sections keep working during migration
(lilith simply spawns those nets as before, alongside the overlay).
Desktop daemons and lilith run on always-on machines and carry the overlay;
mobile apps (and any short-lived client) join the overlay only to look
subnets up and leave. The distinction is declared through the existing
VersionMessage.features vector (src/net/message.rs), which is on the
wire today but sent empty: a persistent node advertises
("swarm-store", 1); a transient node sends no swarm feature. Role is
self-declared and unauthenticated — it is a hint for policy and load, never
a privilege.
| persistent | transient | |
|---|---|---|
| typical host | desktop daemon, lilith | mobile app doing a lookup |
| inbound addrs | typical (often onion) | none (inbound_connections: 0) |
| ad store | disk-backed, TTL + refinery | none (optional in-memory cache) |
| gossip relay | yes | only while connected (brief) |
| subnet serving | per-subnet opt-in (D5) | never |
| overlay outbound | default slots | minimal (1–2), query then leave |
| datastore/hostlist | persisted | overlay hostlist cache (TSV); no ad store |
| heartbeat tuning | default | longer intervals (battery, NAT) |
GetSubnets/
GetSubnetAddrs from whatever local state exists while connected. Role
changes what state exists, never message handling — role-specific wire
behavior would fingerprint peers and split the anonymity set.net::Settings.hostlist
TSV — zero new machinery) so later sessions dial cached overlay peers
first and fall back to configured seeds only on miss/failure. The cache
MUST NOT record queried or joined subnets — it contains overlay peer
addresses and nothing else. Local-device forensics trade-off: the cache
proves overlay participation but not subnet membership; privacy-maximal
deployments disable it (also weaker against stale-entry churn, handled by
normal greylist refinement).GetAddrs and simply keeps the ones whose
handshake carries the swarm-store feature. A dedicated
feature-filtered query can be added later inside swarm's message set if
wasted dials prove costly; not needed initially.NetworkProfile, disconnect after lookup), NAT (no inbound,
no hole punching required), metered data (small GetSubnetAddrs replies
bounded by metering).Alternative considered: no declared role at all (purely emergent — transients are just nodes that leave quickly). Rejected: without the feature bit, persistent nodes cannot preferentially keep slots for ad-carrying peers, and transients cannot find store-keeping peers without trial dialing everyone.
Illustrative signatures — names may shift during implementation; the shape
is what apps program against. Everything mirrors the existing P2p idiom:
async constructors returning Result, Arc pointers, StoppableTask
lifecycle, net::Settings for transport-level config.
/// Declared overlay role (D8)
pub enum SwarmRole {
/// Disk-backed ad store, gossip relay, may serve subnets
Persistent { datastore: PathBuf },
/// Lookup client; no ads, no inbound, optional overlay hostlist cache
/// (`None` leaves no on-device overlay trace)
Transient { hostlist: Option<PathBuf> },
}
/// Canonical subnet descriptor (D2)
pub struct SubnetDescriptor { /* app_name, magic_bytes, version, secret? */ }
impl SubnetDescriptor {
/// Pin a released network; shipped as constants in app code
pub const fn pinned(app_name: &str, magic_bytes: [u8; 4], version: &'static str) -> Self;
/// Secret-bearing descriptor for private subnets
pub fn private(app_name: &str, magic_bytes: [u8; 4], version: &str, secret: &[u8]) -> Self;
/// BLAKE3 of the canonical serialization
pub fn id(&self) -> SubnetId;
}
/// Handle to a joined or served subnet
pub struct SubnetHandle { /* ... */ }
impl SubnetHandle {
pub fn id(&self) -> SubnetId;
/// The subnet's own P2p instance, for app-level messaging
pub fn p2p(&self) -> P2pPtr;
}
pub struct Swarm { /* overlay P2p + subnet registry + ad store */ }
pub type SwarmPtr = Arc<Swarm>;
// Overlay settings: one seed list, forever
let overlay = net::Settings {
app_name: "swarm".into(),
magic_bytes: OVERLAY_MAGIC,
seeds: OVERLAY_SEEDS.into(),
inbound_connections: 64,
..Default::default()
};
let role = SwarmRole::Persistent {
datastore: "~/.local/share/darkirc/swarm/ads".into(),
};
let swarm = Swarm::new(role, overlay, ex.clone()).await?;
swarm.clone().start().await?;
// Pinned descriptor shipped with the app (D2)
const DARKIRC: SubnetDescriptor =
SubnetDescriptor::pinned("darkirc", [251, 229, 199, 181], "0.5.1");
// Client-only participation (default)
let subnet = swarm.join(&DARKIRC, darkirc_protocols).await?;
// Or serve it, with this subnet's own onion (D5)
let inbound = vec![Url::parse("tor://darkirc-7.onion:9440")?];
let subnet = swarm.serve(&DARKIRC, inbound, darkirc_protocols).await?;
// App messaging rides the subnet's ordinary P2p — unchanged app code
let _ = subnet.p2p();
// Later: silent leave (D6) — no departure message, ads expire by TTL
swarm.leave(DARKIRC.id()).await?;
darkirc_protocols is the registration closure the swarm runs against the
subnet P2p's protocol registry before start() — the same
registry.register(session_flags, init) hook register_default_protocols
uses internally. Apps keep registering their protocols exactly as today;
they just do it through the closure.
let overlay = net::Settings {
app_name: "swarm".into(),
magic_bytes: OVERLAY_MAGIC,
seeds: OVERLAY_SEEDS.into(),
outbound_connections: 2,
inbound_connections: 0,
..Default::default()
};
let role = SwarmRole::Transient {
// Cache overlay peers between sessions so later sessions dial cached
// peers first and only fall back to seeds. `None` for a device free
// of overlay traces.
hostlist: Some(cache_dir.join("overlay_hostlist.tsv")),
};
let swarm = Swarm::new(role, overlay, ex.clone()).await?;
// Lookup only: resolve addresses, no subnet participation, then disconnect
let addrs: Vec<Url> = swarm.lookup(&FUD_CHANNEL).await?;
// Or join for the duration of the app session (recommended over
// per-lookup connections — R7 battery-vs-mixing guidance)
let subnet = swarm.join(&FUD_CHANNEL, fud_protocols).await?;
let role = SwarmRole::Persistent { datastore: cfg.adstore };
let swarm = Swarm::new(role, overlay_settings, ex).await?;
swarm.clone().start().await?;
// Nothing else. No per-subnet config, no join calls: ads arrive by
// gossip, and the durable store + refinery (D7) make lilith the
// cold-start anchor. Legacy per-network sections still spawn ordinary
// P2p instances alongside, during migration.
serve() on a Transient swarm fails fast — the transient role has no
serving path (spec: swarm-overlay, node roles).join/serve take a descriptor, never a raw id: a caller cannot join a
subnet it cannot describe, and the spawned P2p still enforces the
subnet's own magic bytes and app_name handshake independently of the
overlay.start() via the
closure — there is no window where a subnet accepts connections without
its app protocols attached.lookup() answers from local state first (on a persistent node: the ad
store) and queries the overlay only on miss; on a transient node it may
reuse the session cache.swarm-store handshake feature (D8, no new messages); after the first
session, the transient hostlist cache (D8) means seeds are fallback-only.
If concentration persists, add a feature-filtered query inside swarm's
message set.src/swarm with the overlay protocol; lilith gains the overlay
section alongside legacy per-network sections. No app changes.Swarm optionally, keeping static seed lists as fallback;
pinned SubnetId constants added per app (values equal to existing
{app_name, magic_bytes, version} triples so current networks are
discoverable).SubnetId paths are all namespaced and
removable.ttl_secs default and per-subnet ad-store cap values (tune during
implementation against refinery intervals).GetSubnets responses should be rate-limited per peer beyond
generic metering (decide when metering thresholds are set).Swarm at all, or only
latency-tolerant apps do (darkirc, taud, fud) — an adoption-policy
question, not a protocol one.