Build a Logos Core module that uses the Service Discovery API
Get started with typed, service-keyed peer lookups in a live Logos network.
This document is accurate for Testnet v0.2.1.
Applications on the Logos network need a protocol-agnostic way to find peers offering specific services—mix nodes, relay nodes, storage providers—at runtime without hard-coding topology or peer lists. The Service Discovery API enables any Logos Core module to perform typed, service-keyed peer lookups that work from lightweight client nodes that do not participate in DHT routing, unblocking any app that needs to wire itself into a live Logos network service. This procedure covers how to write and run a Logos Core module that calls the libp2p_module Service Discovery API to advertise a named service to the network and discover other peers offering that same service.
-
A supported OS:
- Linux: Ubuntu 22.04+
- macOS: 14+
-
2 GB RAM (sufficient for a local two-module test)
-
Nix with flakes enabled.
- Install from nixos.org, then enable flakes:
mkdir -p ~/.config/nixecho 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf -
logosctlinstalled.- Install it by running
curl -fsSL https://raw.githubusercontent.com/logos-co/logos-docs/main/resources/scripts/install-logosctl.sh | sudo sh
- Install it by running
What to expect
- You can advertise a named service from a Logos Core module and discover peers offering the same service via the Kad-DHT, without hard-coding peer addresses.
- You can verify the full discovery flow locally across three
logosctldaemon instances, each with its own session and listen port. - You have a reusable module scaffold with typed
disco*wrappers that you can extend for production service types.
Step 1: Smoke-test the bundled example binary (Optional)
Build and run the self-contained two-node demo to confirm the module and its C bindings are working before writing your own module.
-
Clone the repository and enter the project directory:
git clone https://github.com/logos-co/logos-libp2p-modulecd logos-libp2p-module -
Build the module:
nix build -LinfoThe first-run Nix build can take 5–20 minutes to fetch dependencies; subsequent builds are cached. Estimated total time is 15–25 minutes.
-
Vendor the C-binding header and shared library that
logos_module()expects in./lib:CBIND=$(find /nix/store -maxdepth 4 -name libp2p.h -path '*cbind*' | head -1 | xargs dirname)mkdir -p libcp "$CBIND/libp2p.h" lib/find /nix/store -name libp2p.so -path '*cbind*' -exec cp {} lib/ \; -
Build the example target from the root project and run it:
nix develop --command bash -c 'cmake -B build -S . && cmake --build build --target example_service_discovery -j'./build/examples/example_service_discoveryExpected output:
Starting nodes...Advertiser: advertising demo-serviceDiscoverer: registering interest in demo-serviceDiscoverer: looking up demo-serviceDiscoverer found 1 peer(s) advertising demo-servicepeer: 16Uiu2HAk... seq: 1359 addrs: 1Discoverer matched the advertiser: 16Uiu2HAk...Advertiser: random lookupRandom lookup returned 2 peer(s)Advertiser: building a signed Extended Peer RecordSigned XPR is 288 bytesDiscoverer: unregistering interest in demo-serviceAdvertiser: stopping advertising demo-serviceDone- Peer IDs are non-deterministic across runs.
infoThe demo runs a bootstrap node plus an advertiser and a discoverer, so the discoverer finds the advertiser through the DHT—a successful run prints
found 1 peer(s)andmatched the advertiser. Exact peer counts, IDs, and the XPR byte size vary per run.
Step 2: Scaffold the new Logos Core module
Run the scaffold tool from the parent directory to generate the module skeleton, then declare the libp2p_module dependency.
-
From the parent directory, scaffold the module:
cd ..nix run github:logos-co/logos-dev-boost -- init my_service_module --type modulecd logos-my-service-module- The scaffold tool prefixes the output directory with
logos-, producinglogos-my-service-module.
- The scaffold tool prefixes the output directory with
-
Replace the contents of
metadata.jsonwith the following to declare thelibp2p_moduledependency:{"name": "my_service_module","version": "1.0.0","description": "Service discovery demo module","type": "core","interface": "universal","main": "my_service_module_plugin","dependencies": ["libp2p_module"]} -
Replace the contents of
src/my_service_module_impl.hwith the following:// my_service_module_impl.h — inherit LogosModuleContext:#pragma once#include <string>#include "logos_module_context.h"class MyServiceModuleImpl : public LogosModuleContext {public:std::string startDiscovery();std::string getPeerInfo();std::string advertise(const std::string& serviceId, const std::string& serviceData);std::string discover(const std::string& serviceId);std::string stopDiscovery();}; -
Replace the contents of
src/my_service_module_impl.cppwith the following:#include "my_service_module_impl.h"#include "logos_sdk.h" // generated; defines: struct LogosModules { Libp2pModule libp2p_module; };#include <thread>#include <chrono>std::string MyServiceModuleImpl::startDiscovery() {auto r = modules().libp2p_module.start();if (!r.success) return "start failed: " + r.error;auto r2 = modules().libp2p_module.discoStart();if (!r2.success) return "discoStart failed: " + r2.error;return "discovery started";}// Returns this node's own peer record (peerId + listen addrs). Use it on a// bootstrap node to obtain the peerId/addr that other instances bootstrap against.std::string MyServiceModuleImpl::getPeerInfo() {auto r = modules().libp2p_module.peerInfo();if (!r.success) return "peerInfo failed: " + r.error;return r.value.is_string() ? r.value.get<std::string>() : r.value.dump();}std::string MyServiceModuleImpl::advertise(const std::string& serviceId,const std::string& serviceData) {// discoStartAdvertising requires BOTH serviceId and serviceData.auto r = modules().libp2p_module.discoStartAdvertising(serviceId, serviceData);if (!r.success) return "advertise failed: " + r.error;return "advertising " + serviceId;}std::string MyServiceModuleImpl::discover(const std::string& serviceId) {auto r = modules().libp2p_module.discoRegisterInterest(serviceId);if (!r.success) return "registerInterest failed: " + r.error;std::this_thread::sleep_for(std::chrono::milliseconds(500));// "" serviceData = match any advertisement of this serviceId.auto r2 = modules().libp2p_module.discoLookup(serviceId, "");if (!r2.success) return "lookup failed: " + r2.error;return r2.value.is_string() ? r2.value.get<std::string>() : r2.value.dump();}std::string MyServiceModuleImpl::stopDiscovery() {auto r = modules().libp2p_module.discoStop();if (!r.success) return "discoStop failed: " + r.error;auto r2 = modules().libp2p_module.stop();if (!r2.success) return "stop failed: " + r2.error;return "discovery stopped";} -
Add
libp2p_moduleas a flake input inflake.nix:inputs = {logos-module-builder.url = "github:logos-co/logos-module-builder";libp2p_module.url = "github:logos-co/logos-libp2p-module";};infoFor local development, override
flake.nixat build time:nix build --override-input libp2p_module path:../logos-libp2p-module
Step 3: Build both modules
The .#install target runs lgpm internally and produces the directory structure logosctl requires. You only write metadata.json; the install target generates manifest.json, variant, and co-locates all .so files automatically.
-
In
logos-my-service-module, initialise a Git repository and run the install build:git init && git add -Anix build .#install -LThis produces:
result/modules/my_service_module/├── manifest.json├── variant└── my_service_module_plugin.so -
In
logos-libp2p-module, run the install build:cd ../logos-libp2p-modulenix build .#install -LThis produces:
result/modules/libp2p_module/├── manifest.json├── variant├── libp2p_module_plugin.so└── libp2p.solibp2p.sois co-located automatically so the$ORIGINRUNPATH resolves at runtime.
Step 4: Load the modules and verify the single-node flow
-
Return to
logos-my-service-moduleand pin a known, fixed listen address via theLIBP2P_MODULE_CONFIGenvironment variable:cd ../logos-my-service-moduleexport LIBP2P_MODULE_CONFIG='{"addrs":["/ip4/127.0.0.1/tcp/9000"]}' -
Start the daemon, detached so this terminal stays free, pointing at both module directories:
logosctl daemon start --detach \--modules-dir ../logos-libp2p-module/result/modules \--modules-dir ./result/modules -
Load both modules:
logosctl module load libp2p_modulelogosctl module load my_service_module -
Drive the discovery lifecycle and verify each call returns immediately:
logosctl call my_service_module startDiscovery# → discovery startedlogosctl call my_service_module getPeerInfo# → {"peerId":"16Uiu2…","addrs":["/ip4/127.0.0.1/tcp/9000"]}logosctl call my_service_module advertise myservice/v1 version=1# → advertising myservice/v1logosctl call my_service_module discover myservice/v1# → [] (single node: no second advertiser)logosctl call my_service_module stopDiscovery# → discovery stoppeddiscoverreturning[]is expected with a single node.getPeerInfoprints this node'speerIdand listen address, which you need in Step 5 to bootstrap other instances.
-
Shut down the daemon:
logosctl daemon stop
Step 5: Run three-node local discovery
Run three logosctl daemon instances on one machine to see the Service Discovery API work: a bootstrap node, an advertiser, and a discoverer. The advertiser and discoverer are configured only with the bootstrap node as their bootstrap node—when the discoverer's lookup returns the advertiser's peer record, the advertisement provably travelled through the DHT, not over a direct A↔B link.
Each daemon needs its own session (--config-dir) and LIBP2P_MODULE_CONFIG with a distinct listen port as separate sessions are what let three independent instances coexist on one machine. Run each block in a separate terminal window.
-
In Terminal 1, start the bootstrap node:
cd ../logos-my-service-moduleexport LIBP2P_MODULE_CONFIG='{"addrs":["/ip4/127.0.0.1/tcp/9000"]}'logosctl daemon start --detach --config-dir ~/.logosctl-bootstrap \--modules-dir ../logos-libp2p-module/result/modules --modules-dir ./result/moduleslogosctl --config-dir ~/.logosctl-bootstrap module load libp2p_modulelogosctl --config-dir ~/.logosctl-bootstrap module load my_service_modulelogosctl --config-dir ~/.logosctl-bootstrap call my_service_module startDiscoverylogosctl --config-dir ~/.logosctl-bootstrap call my_service_module getPeerInfo# → note the "peerId" value; the bootstrap node listens on /ip4/127.0.0.1/tcp/9000- Copy the bootstrap node's
peerIdfrom thegetPeerInfooutput. Substitute it for<BOOTSTRAP_PEER_ID>in the next two terminals.
- Copy the bootstrap node's
-
In Terminal 2, start the advertiser:
cd ../logos-my-service-moduleexport LIBP2P_MODULE_CONFIG='{"addrs":["/ip4/127.0.0.1/tcp/9001"],"bootstrapNodes":[{"peerId":"<BOOTSTRAP_PEER_ID>","addrs":["/ip4/127.0.0.1/tcp/9000"]}]}'logosctl daemon start --detach --config-dir ~/.logosctl-advertiser \--modules-dir ../logos-libp2p-module/result/modules --modules-dir ./result/moduleslogosctl --config-dir ~/.logosctl-advertiser module load libp2p_modulelogosctl --config-dir ~/.logosctl-advertiser module load my_service_modulelogosctl --config-dir ~/.logosctl-advertiser call my_service_module startDiscoverylogosctl --config-dir ~/.logosctl-advertiser call my_service_module advertise myservice/v1 version=1 -
In Terminal 3, start the discoverer and look up the service:
cd ../logos-my-service-moduleexport LIBP2P_MODULE_CONFIG='{"addrs":["/ip4/127.0.0.1/tcp/9002"],"bootstrapNodes":[{"peerId":"<BOOTSTRAP_PEER_ID>","addrs":["/ip4/127.0.0.1/tcp/9000"]}]}'logosctl daemon start --detach --config-dir ~/.logosctl-discoverer \--modules-dir ../logos-libp2p-module/result/modules --modules-dir ./result/moduleslogosctl --config-dir ~/.logosctl-discoverer module load libp2p_modulelogosctl --config-dir ~/.logosctl-discoverer module load my_service_modulelogosctl --config-dir ~/.logosctl-discoverer call my_service_module startDiscoverylogosctl --config-dir ~/.logosctl-discoverer call my_service_module discover myservice/v1Expected output:
{"method":"discover","module":"my_service_module","result":"[{\"addrs\":[\"/ip4/127.0.0.1/tcp/9001\"],\"peerId\":\"<ADVERTISER_PEER_ID>\",\"seqNo\":1536,\"services\":[{\"data\":\"version=1\",\"id\":\"myservice/v1\"}]}]","status":"ok"}- The returned
peerIdis the advertiser's, andaddrsshows its listen port (9001), even though the discoverer only knew about the bootstrapping node. Theservicesentry carries theserviceData(version=1) that A advertised.
infoA first
discoverreturning[]means the advertisement has not yet propagated. Repeat the call after a few seconds. - The returned
-
Tear down all three daemons:
for D in bootstrap advertiser discoverer; dologosctl --config-dir ~/.logosctl-$D call my_service_module stopDiscoverylogosctl --config-dir ~/.logosctl-$D daemon stopdone
Troubleshooting service discovery
Why does discover return [] even after waiting?
The Kad-DHT needs a few seconds to propagate the advertisement from the advertiser through the bootstrap node to the discoverer. Repeat logosctl --config-dir ~/.logosctl-discoverer call my_service_module discover myservice/v1 after 5–10 seconds. If it still returns empty, confirm that the advertiser's advertise call succeeded and that both the advertiser and the discoverer share the same <BOOTSTRAP_PEER_ID> for the bootstrap node.