Skip to main content

Build a Logos Core module that uses the Service Discovery API

Get started with typed, service-keyed peer lookups in a live Logos network.

Version

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.

Prerequisites
  • 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/nix
    echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf
  • logosctl installed.

    • Install it by running curl -fsSL https://raw.githubusercontent.com/logos-co/logos-docs/main/resources/scripts/install-logosctl.sh | sudo sh

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 logosctl daemon 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.

  1. Clone the repository and enter the project directory:

    git clone https://github.com/logos-co/logos-libp2p-module
    cd logos-libp2p-module
  2. Build the module:

    nix build -L
    info

    The first-run Nix build can take 5–20 minutes to fetch dependencies; subsequent builds are cached. Estimated total time is 15–25 minutes.

  3. 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 lib
    cp "$CBIND/libp2p.h" lib/
    find /nix/store -name libp2p.so -path '*cbind*' -exec cp {} lib/ \;
  4. 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_discovery

    Expected output:

    Starting nodes...
    Advertiser: advertising demo-service
    Discoverer: registering interest in demo-service
    Discoverer: looking up demo-service
    Discoverer found 1 peer(s) advertising demo-service
    peer: 16Uiu2HAk... seq: 1359 addrs: 1
    Discoverer matched the advertiser: 16Uiu2HAk...
    Advertiser: random lookup
    Random lookup returned 2 peer(s)
    Advertiser: building a signed Extended Peer Record
    Signed XPR is 288 bytes
    Discoverer: unregistering interest in demo-service
    Advertiser: stopping advertising demo-service
    Done
    • Peer IDs are non-deterministic across runs.
    info

    The 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) and matched 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.

  1. From the parent directory, scaffold the module:

    cd ..
    nix run github:logos-co/logos-dev-boost -- init my_service_module --type module
    cd logos-my-service-module
    • The scaffold tool prefixes the output directory with logos-, producing logos-my-service-module.
  2. Replace the contents of metadata.json with the following to declare the libp2p_module dependency:

    {
    "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"]
    }
  3. Replace the contents of src/my_service_module_impl.h with 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();
    };
  4. Replace the contents of src/my_service_module_impl.cpp with 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";
    }
  5. Add libp2p_module as a flake input in flake.nix:

    inputs = {
    logos-module-builder.url = "github:logos-co/logos-module-builder";
    libp2p_module.url = "github:logos-co/logos-libp2p-module";
    };
    info

    For local development, overrideflake.nix at 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.

  1. In logos-my-service-module, initialise a Git repository and run the install build:

    git init && git add -A
    nix build .#install -L

    This produces:

    result/modules/my_service_module/
    ├── manifest.json
    ├── variant
    └── my_service_module_plugin.so
  2. In logos-libp2p-module, run the install build:

    cd ../logos-libp2p-module
    nix build .#install -L

    This produces:

    result/modules/libp2p_module/
    ├── manifest.json
    ├── variant
    ├── libp2p_module_plugin.so
    └── libp2p.so
    • libp2p.so is co-located automatically so the $ORIGIN RUNPATH resolves at runtime.

Step 4: Load the modules and verify the single-node flow

  1. Return to logos-my-service-module and pin a known, fixed listen address via the LIBP2P_MODULE_CONFIG environment variable:

    cd ../logos-my-service-module
    export LIBP2P_MODULE_CONFIG='{"addrs":["/ip4/127.0.0.1/tcp/9000"]}'
  2. 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
  3. Load both modules:

    logosctl module load libp2p_module
    logosctl module load my_service_module
  4. Drive the discovery lifecycle and verify each call returns immediately:

    logosctl call my_service_module startDiscovery
    # → discovery started

    logosctl 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/v1

    logosctl call my_service_module discover myservice/v1
    # → [] (single node: no second advertiser)

    logosctl call my_service_module stopDiscovery
    # → discovery stopped
    • discover returning [] is expected with a single node. getPeerInfo prints this node's peerId and listen address, which you need in Step 5 to bootstrap other instances.
  5. 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.

  1. In Terminal 1, start the bootstrap node:

    cd ../logos-my-service-module
    export 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/modules
    logosctl --config-dir ~/.logosctl-bootstrap module load libp2p_module
    logosctl --config-dir ~/.logosctl-bootstrap module load my_service_module
    logosctl --config-dir ~/.logosctl-bootstrap call my_service_module startDiscovery
    logosctl --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 peerId from the getPeerInfo output. Substitute it for <BOOTSTRAP_PEER_ID> in the next two terminals.
  2. In Terminal 2, start the advertiser:

    cd ../logos-my-service-module
    export 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/modules
    logosctl --config-dir ~/.logosctl-advertiser module load libp2p_module
    logosctl --config-dir ~/.logosctl-advertiser module load my_service_module
    logosctl --config-dir ~/.logosctl-advertiser call my_service_module startDiscovery
    logosctl --config-dir ~/.logosctl-advertiser call my_service_module advertise myservice/v1 version=1
  3. In Terminal 3, start the discoverer and look up the service:

    cd ../logos-my-service-module
    export 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/modules
    logosctl --config-dir ~/.logosctl-discoverer module load libp2p_module
    logosctl --config-dir ~/.logosctl-discoverer module load my_service_module
    logosctl --config-dir ~/.logosctl-discoverer call my_service_module startDiscovery
    logosctl --config-dir ~/.logosctl-discoverer call my_service_module discover myservice/v1

    Expected 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 peerId is the advertiser's, and addrs shows its listen port (9001), even though the discoverer only knew about the bootstrapping node. The services entry carries the serviceData (version=1) that A advertised.
    info

    A first discover returning [] means the advertisement has not yet propagated. Repeat the call after a few seconds.

  4. Tear down all three daemons:

    for D in bootstrap advertiser discoverer; do
    logosctl --config-dir ~/.logosctl-$D call my_service_module stopDiscovery
    logosctl --config-dir ~/.logosctl-$D daemon stop
    done

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.