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.
-
Build the service discovery tutorial from the root project and run it. The Nix development shell provides the C-binding header and library, so nothing needs to be copied into
./lib:nix develop --command bash -c 'cmake -B build -S . && cmake --build build --target tutorial_9_service_discovery -j'nix develop --command ./build/tutorial/tutorial_9_service_discoveryExpected output (between the node's log lines):
=== Tutorial 9: Service Discovery ===Bootstrap node started: 12D3KooW...Advertiser connected to bootstrapDiscoverer connected to bootstrapAdvertiser advertising: "demo-chat-service"Advertising startedDiscoverer registering interest in "demo-chat-service"Discoverer looking up "demo-chat-service"...Discoverer found 1 provider(s):Peer: 12D3KooW...Service: demo-chat-service (data: version=1.0;capacity=100)Random lookup by advertiser...Found 2 random peer(s):...=== Tutorial 9 Complete ===- Peer IDs are non-deterministic across runs.
infoThe tutorial 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 provider(s). Exact peer counts and IDs 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 takes serviceId, serviceData and an optional// base64 advertisement (a signed XPR); "" lets the module build it.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-portable 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. Use install-portable, not install: .#install produces a development build (variant linux-amd64-dev) that the released logosctl skips with was installed for variant 'linux-amd64-dev' which is not supported on this platform, and module load then fails with MODULE_LOAD_FAILED.
-
In
logos-my-service-module, initialise a Git repository and run the install build:git init && git add -Anix build .#install-portable -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-portable -LThis produces:
result/modules/libp2p_module/├── manifest.json├── variant├── libp2p_module_plugin.so├── liblibp2p.so└── … # bundled runtime libraries (OpenSSL, Boost, TinyCBOR)liblibp2p.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:
printf 'modules_dirs:\n - %s\n - %s\n' \"$(cd ../logos-libp2p-module/result/modules && pwd)" "$(pwd)/result/modules" \| logosctl daemon config set -logosctl daemon start --detach -
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# → {"addrs":["/ip4/127.0.0.1/tcp/9000"],"peerId":"12D3KooW…"}logosctl call my_service_module advertise myservice/v1 version=1# → advertising myservice/v1logosctl call my_service_module discover myservice/v1# → [{"addrs":["/ip4/127.0.0.1/tcp/9000"],"peerId":"12D3KooW…",…}] (single node: only its own advertisement)logosctl call my_service_module stopDiscovery# → discovery stopped- With a single node,
discoverreturns only this node's own advertisement.getPeerInfoprints this node'speerIdand listen address, which you need in Step 5 to bootstrap other instances.
- With a single node,
-
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"]}'printf 'modules_dirs:\n - %s\n - %s\n' \"$(cd ../logos-libp2p-module/result/modules && pwd)" "$(pwd)/result/modules" \| logosctl --config-dir ~/.logosctl-bootstrap daemon config set -logosctl daemon start --detach --config-dir ~/.logosctl-bootstraplogosctl --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"]}]}'printf 'modules_dirs:\n - %s\n - %s\n' \"$(cd ../logos-libp2p-module/result/modules && pwd)" "$(pwd)/result/modules" \| logosctl --config-dir ~/.logosctl-advertiser daemon config set -logosctl daemon start --detach --config-dir ~/.logosctl-advertiserlogosctl --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"]}]}'printf 'modules_dirs:\n - %s\n - %s\n' \"$(cd ../logos-libp2p-module/result/modules && pwd)" "$(pwd)/result/modules" \| logosctl --config-dir ~/.logosctl-discoverer daemon config set -logosctl daemon start --detach --config-dir ~/.logosctl-discovererlogosctl --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\":\"dmVyc2lvbj0x\",\"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 theserviceDatathat the advertiser published, base64-encoded (dmVyc2lvbj0xisversion=1).
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.