LOGOS-CAPABILITY-DISCOVERY
- Status: raw
- Category: Standards Track
- Editor: Arunima Chaudhuri arunima@status.im
> Note: This specification is currently a WIP and undergoing a high rate of changes.
Abstract
This RFC defines the Logos Capability Discovery protocol, a discovery mechanism inspired by DISC-NG service discovery built on top of Kad-dht.
The protocol enables nodes to:
- Advertise their participation in specific services
- Efficiently discover other peers participating in those services
In this RFC, the terms "capability" and "service" are used interchangeably. Within Logos, a node’s “capabilities” map directly to the “services” it participates in. Similarly, "peer" and "node" refer to the same entity: a participant in the Logos Discovery network.
Logos discovery extends Kad-dht toward a multi-service, resilient discovery layer, enhancing reliability while maintaining compatibility with existing Kad-dht behavior. For everything else that isn't explicitly stated herein, it is safe to assume behaviour similar to Kad-dht.
Motivation
In decentralized networks supporting multiple services, efficient peer discovery for specific services is critical. Traditional approaches face several challenges:
- Inefficiency: Random-walk–based discovery is inefficient for unpopular services.
- Load imbalance: A naive approach where nodes advertise their service at DHT peers whose IDs are closest to the service ID leads to hotspots and overload at popular services.
- Scalability: Discovery must scale logarithmically across many distinct services.
Logos discovery addresses these through:
- Service-specific routing tables
- Adaptive advertisement placement with admission control
- Improved lookup operations balancing efficiency and resilience
Format Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in 2119.
Protocol Roles
The Logos capability discovery protocol defines three roles that nodes can perform:
Advertiser
Advertisers are nodes that participate in a service and want to be discovered by other peers.
Responsibilities:
- Advertisers SHOULD register advertisements for their service across registrars
- Advertisers SHOULD handle registration responses
Discoverer
Discoverers are nodes attempting to find peers that provide a specific service.
Responsibilities:
- Discoverers SHOULD query registrars for advertisements of a service
Registrar
Registrars are nodes that store and serve advertisements.
Responsibilities:
- Registrars MUST use a waiting time based admission control mechanism to decide whether to store an advertisement coming from an advertiser or not.
- Registrars SHOULD respond to query requests for advertisements coming from discoverers.
Definitions
DHT Routing Table
Every participant in the kad-dht peer discovery layer
maintains the peer routing table KadDHT(peerID).
It is a distributed key-value store with
peer IDs
as key against their matching
signed peer records values.
It is centered on the node's own peerID.
> “Centered on” means the table is organized using that ID as the reference point for computing distances with other peers and assigning peers to buckets.
Bucket
Each table is organized into buckets. A bucket is a container that stores information about peers at a particular distance range from the center ID.
For simplicity in this RFC, we represent each bucket as a list of peer IDs. However, in a full implementation, each entry in the bucket MUST store a complete peer information necessary to enable communication.
Bucket Size:
The number of entries a bucket can hold is implementation-dependent.
- Smaller buckets → lower memory usage but reduced resilience to churn
- Larger buckets → better redundancy but increased maintenance overhead
Service
A service is a logical sub-network within the larger peer-to-peer network. It represents a specific capability a node supports — for example, a particular protocol or functionality it offers. A service MUST be identified by a libp2p protocol ID via the identify protocol.
Service ID
The service ID service_id_hash MUST be the SHA-256 hash of the protocol ID.
For example:
| Service Identifier | Service ID |
|---|---|
/waku/store/1.0.0 | 313a14f48b3617b0ac87daabd61c1f1f1bf6a59126da455909b7b11155e0eb8e |
/libp2p/mix/1.2.0 | 9c55878d86e575916b267195b34125336c83056dffc9a184069bcb126a78115d |
Advertisement Cache
An advertisement cache ad_cache is a bounded storage structure
maintained by registrars to store accepted advertisements.
Advertise Table
For every service it participates in, an advertiser node MUST maintain an
advertise table AdvT(service_id_hash) centered on service_id_hash.
The table MAY be initialized using
peers from the advertiser’s KadDHT(peerID) routing table.
It SHOULD be updated opportunistically through interactions with
registrars during the advertisement process.
Each bucket in the advertise table contains a list of registrar peers at a
particular XOR distance range from service_id_hash, which are candidates for placing
advertisements.
Search Table
For every service it attempts to discover, a discoverer node MUST maintain a
search table DiscT(service_id_hash) centered on service_id_hash.
The table MAY be initialized using
peers from the discoverer’sKadDHT(peerID) routing table.
It SHOULD be updated through interactions with
registrars during lookup operations.
Each bucket in the search table contains a list of registrar peers at a
particular XOR distance range from service_id_hash, which discoverers can query to
retrieve advertisements for that service.
Registrar Table
For every service for which it stores and serves advertisements,
a registrar node SHOULD maintain a registrar table RegT(service_id_hash)
centered on service_id_hash.
The table MAY be initialized using peers from the
registrar’s KadDHT(peerID) routing table.
It SHOULD be updated opportunistically
through interactions with advertisers and discoverers.
Each bucket in the registrar table contains a list of peer nodes
at a particular XOR distance range from service_id_hash.
Registrars use this table to return
closerPeers during REGISTER and GET_ADS responses,
enabling advertisers and discoverers to
refine their service-specific routing tables.
Address
A multiaddress is a standardized way used in libp2p to represent network addresses. Implementations SHOULD use multiaddresses for peer connectivity. However, implementations MAY use alternative address representations if they:
- Remain interoperable
- Convey sufficient information (IP + port) to establish connections
Signature
Refer to Peer Ids and Keys
to know about supported signatures.
In the base Kad DHT specification, signatures are optional,
typically implemented as a PKI signature over the tuple (key || value || author).
In this RFC digital signatures MUST be used to
authenticate advertisements and tickets.
Expiry Time E
E is advertisement expiry time in seconds.
The expiry time E is a system wide parameter,
not an individual advertisement field or parameter of an individual registrar.
Data Structures
Advertisement
An advertisement is a data structure
indicating that a specific node participates in a service.
In this RFC we refer to advertisement objects as ads.
For a single advertisement object we use ad.
message Advertisement {
// Service identifier (32-byte SHA-256 hash)
bytes service_id_hash = 1;
// Peer ID of advertiser (32-byte hash of public key)
bytes peerID = 2;
// Multiaddrs of advertiser
repeated bytes addrs = 3;
// Ed25519 signature over (service_id_hash || peerID || addrs)
bytes signature = 4;
// Optional: Service-specific metadata
optional bytes metadata = 5;
// Unix timestamp in seconds
uint64 timestamp = 6;
}
Advertisements MUST include service_id_hash, peerID, addrs and signature fields.
Advertisements MAY include metadata and timestamp fields.
The signature field MUST be a Ed25519 signature over the concatenation of
(service_id_hash || peerID || addrs).
Refer to Signature section for more details.
Implementations MUST verify this signature before accepting an advertisement.
Ticket
Tickets are digitally signed objects issued by registrars to advertisers to reliably indicate how long an advertiser already waited for admission.
message Ticket {
// Copy of the original advertisement
Advertisement ad = 1;
// Ticket creation timestamp (Unix time in seconds)
uint64 t_init = 2;
// Last modification timestamp (Unix time in seconds)
uint64 t_mod = 3;
// Remaining wait time in seconds
uint32 t_wait_for = 4;
// Ed25519 signature over (ad || t_init || t_mod || t_wait_for)
bytes signature = 5;
}
Tickets MUST include ad, t_init, t_mod, t_wait_for and signature fields.
The signature field MUST be an Ed25519 signature over the concatenation of
(ad || t_init || t_mod || t_wait_for).
Refer to Signature section for more details.
Implementations MUST verify this signature before accepting a ticket.
Protocol Specifications
System Parameters
The system parameters are derived directly from the DISC-NG paper. Implementations SHOULD modify them as needed based on specific requirements.
| Parameter | Default Value | Description |
|---|---|---|
K_register | 3 | Max number of active (i.e. unexpired) registrations + ongoing registration attempts per bucket. |
K_lookup | 5 | For each bucket in the search table, number of random registrar nodes queried by discoverers |
F_lookup | 30 | number of advertisers to find in the lookup process. We stop lookup process when we have found these many advertisers |
F_return | 10 | max number of service-specific peers returned from a single registrar |
E | 900 seconds | Advertisement expiry time (15 minutes) |
C | 1,000 | Advertisement cache capacity |
P_occ | 10 | Occupancy exponent for waiting time calculation |
G | 10⁻⁷ | Safety parameter for waiting time calculation |
δ | 1 second | Registration window time |
m | 16 | Number of buckets for advertise table, search table |
Distance
The distance d between any two keys in Logos Capability Discovery
MUST be calculated using the bitwise XOR applied to their 256-bit SHA-256 representations.
This provides a deterministic, uniform, and symmetric way to measure proximity in the keyspace.
The keyspace is the entire numerical range of possible peerID and service_id_hash
— the 256-bit space in which all SHA-256–derived IDs exist.
XOR MUST be used to measure distances between them in the keyspace.
For every node in the network, the peerID is unique.
In this system, both peerID and the service_id_hash are 256-bit SHA-256 hashes.
Thus both belong to the same keyspace.
Advertise table AdvT(service_id_hash), search table DiscT(service_id_hash)
and registrar table RegT(service_id_hash) MUST be centered on service_id_hash
while KadDHT(peerID) table is centered on peerID.
When inserting a node into AdvT(service_id_hash), DiscT(service_id_hash) or RegT(service_id_hash),
the bucket index into which the node will be inserted MUST be determined by:
- x = reference ID which is the
service_id_hash - y = target peer ID
peerID - L = 256 = bit length of IDs
m= 16 = number of buckets in the advertise/search tabled = x ⊕ y = service_id_hash ⊕ peerID= bitwise XOR distance (interpreted as an unsigned integer)
The bucket index i where y is placed in x's advertise/search table is:
- For
d > 0,i = min( ⌊ (m / 256) * (256 − 1 − ⌊log₂(d)⌋) ⌋ , m − 1 ) - For
d = 0,i = m - 1which is the same ID case
If we further simplify it, then:
- Let
lz = CLZ(d)= number of leading zeros in the 256-bit representation ofd i = min( ⌊ (lz * m) / 256 ⌋ , m − 1 )
Lower-index buckets represent peers far away from service_id_hash in the keyspace,
while higher-index buckets contain peers closer to service_id_hash.
This property allows efficient logarithmic routing:
each hop moves to a peer that shares a longer prefix of bits
with the target service_id_hash.
This formula MUST also used when we bootstrap peers.
Bootstrapping MAY be done from the KadDHT(peerID) table.
For every peer present in the table from where we bootstrap,
we MUST use the same formula to place them in AdvT(service_id_hash),
DiscT(service_id_hash) and RegT(service_id_hash) buckets.
Initially the density of peers in the search table DiscT(service_id_hash)
and advertise table AdvT(service_id_hash) around service_id_hash might be low or even null
particularly when service_id_hash and peerID are distant in the keyspace
(as KadDHT(peerID) is centered on peerID).
The buckets SHOULD be filled opportunistically
while interacting with peers during the search or advertisement process.
Registrars, apart from responding to queries,
SHOULD return a list of peers as response.closerPeers .
These peers SHOULD be added to buckets in AdvT(service_id_hash) and DiscT(service_id_hash).
While adding peers implementations MUST use the same formula.
Note:
The response.closerPeers field returned by registrars SHOULD include
a list of peer information object which contains both peer IDs and addresses,
as the latter is required to contact peers.
In this RFC, we simplify representation by listing only peer IDs,
but full implementations SHOULD include address information.
RPC Messages
All RPC messages MUST be sent using the libp2p Kad-dht message format with new message types added for Logos discovery operations.
Message Types
The following message types MUST be added to the Kad-dht Message.MessageType enum:
enum MessageType {
// ... existing Kad-dht message types ...
REGISTER = 6;
GET_ADS = 7;
}
REGISTER Message
REGISTER Request
Advertisers SHOULD send REGISTER request message to registrars
to admit the advertiser's advertisemnet for a service
into the registrar's ad_cache.
message Message {
MessageType type = 1; // REGISTER
bytes key = 2; // service_id_hash
Advertisement ad = 3; // The advertisement to register
optional Ticket ticket = 4; // Optional: ticket from previous attempt
}
Advertisers SHOULD include the service_id_hash in the key field
and the advertisement in the ad field of the request.
If this is a retry attempt, advertisers SHOULD include
the latest ticket received from the registrar.
REGISTER Response
REGISTER response SHOULD be sent by registrars to advertisers.
enum RegistrationStatus {
CONFIRMED = 0; // Advertisement accepted
WAIT = 1; // wait, ticket provided
REJECTED = 2; // Advertisement rejected
}
message Message {
MessageType type = 1; // REGISTER
RegistrationStatus status = 2;
optional Ticket ticket = 3; // Provided if status = WAIT
repeated Peer closerPeers = 4; // Peers for populating advertise table
}
Registrars SHOULD set the status field to indicate the result of the registration attempt.
If status is WAIT, registrars MUST provide a valid ticket.
Registrars SHOULD include closerPeers to help populate the advertiser's table.
GET_ADS Message
GET_ADS Request
Discoverers send GET_ADS request message to registrars
to get advertisements for a particular service.
message Message {
MessageType type = 1; // GET_ADS
bytes key = 2; // service_id_hash to look up
}
Discoverers SHOULD include the service_id_hash they are searching for in the key field.
GET_ADS Response
Registrars SHOULD respond to discoverer's GET_ADS request
using the following response structure.
message Message {
MessageType type = 1; // GET_ADS
repeated Advertisement ads = 2; // Up to F_return advertisements
repeated Peer closerPeers = 3; // Peers for populating search table
}
Registrars MUST return up to F_return advertisements for the requested service.
Registrars SHOULD include closerPeers to help populate the discoverer's search table.
Sequence Diagram
Advertisement Placement
Overview
ADVERTISE(service_id_hash) lets advertisers publish itself
as a participant in a particular service_id_hash .
It spreads advertisements for its service across multiple registrars, such that other peers can find it efficiently.
Advertisement Algorithm
Advertisers place advertisements across multiple registrars using the ADVERTISE() algorithm.
The advertisers run ADVERTISE() periodically.
Implementations may choose the interval based on their requirements.
procedure ADVERTISE(service_id_hash):
ongoing ← MAP<bucketIndex; LIST<registrars>>
AdvT(service_id_hash) ← KadDHT(peerID)
for i in 0, 1, ..., m-1:
while ongoing[i].size < K_register:
registrar ← AdvT(service_id_hash).getBucket(i).getRandomNode()
if registrar = None:
break
end if
ongoing[i].add(registrar)
ad.service_id_hash ← service_id_hash
ad.peerID ← peerID
ad.addrs ← node.addrs
ad.timestamp ← NOW()
SIGN(ad)
async(ADVERTISE_SINGLE(registrar, ad, i, service_id_hash))
end while
end for
end procedure
procedure ADVERTISE_SINGLE(registrar, ad, i, service_id_hash):
ticket ← None
while True:
response ← registrar.Register(ad, ticket)
AdvT(service_id_hash).add(response.closerPeers)
if response.status = Confirmed:
SLEEP(E)
break
else if response.status = Wait:
SLEEP(min(E, response.ticket.t_wait_for))
ticket ← response.ticket
else:
break
end if
end while
ongoing[i].remove(registrar)
end procedure
Refer to the Advertiser Algorithms Explanation section for a detailed explanation.
Service Discovery
Overview
Discoverers are nodes attempting to find peers that
provide a specific service identified by service_id_hash.
Discovery Table DiscT(service_id_hash) Requirements
For each service that a discoverer wants to find,
it MUST instantiate a search table DiscT(service_id_hash),
centered on that service_id_hash.
Discoverers MAY bootstrap DiscT(service_id_hash) by copying existing entries
from KadDHT(peerID) already maintained by the node.
For every peer present in the table from where we bootstrap,
we MUST use the formula described in the Distance section to place them in buckets.
DiscT(service_id_hash) SHOULD be maintained through interactions
with registrars during lookup operations.
Lookup Requirements
The LOOKUP(service_id_hash) is carried out by discoverer nodes to query registrar nodes
to get advertisements of a particular service_id_hash.
The LOOKUP(service_id_hash) procedure MUST work as a gradual search
on the search table DiscT(service_id_hash) of the service whose advertisements it wants.
The LOOKUP(service_id_hash) MUST start from far buckets (b_0)
which has registrar nodes with fewer shared bits with serviceid_hash
and moving to buckets `(b(m-1))containing registrar nodes with
higher number of shared bits or closer toservice_id_hash`.
To perform a lookup, discoverers:
- SHOULD query
K_lookuprandom registrar nodes from every bucket ofDiscT(service_id_hash). - MUST verify the signature of each advertisement received before accepting it.
- SHOULD add closer peers returned by registrars
in the response to
DiscT(service_id_hash)to improve future lookups. - SHOULD retrieve at most
F_returnadvertisement peers from a single registrar. - SHOULD run the lookup process periodically. Implementations can choose the interval based on their requirements.
Lookup Algorithm
We RECOMMEND that the following algorithm be used to implement the service discovery requirements specified above. Implementations MAY use alternative algorithms as long as they satisfy requirements specified in the previous section.
procedure LOOKUP(service_id_hash):
DiscT(service_id_hash) ← KadDHT(peerID)
foundPeers ← SET<Peers>
for i in 0, 1, ..., m-1:
for j in 0, ..., K_lookup - 1:
peer ← DiscT(service_id_hash).getBucket(i).getRandomNode()
if peer = None:
break
end if
response ← peer.GetAds(service_id_hash)
for ad in response.ads:
assert(ad.hasValidSignature())
foundPeers.add(ad.peerID)
if foundPeers.size ≥ F_lookup:
break
end if
end for
DiscT(service_id_hash).add(response.closerPeers)
if foundPeers.size ≥ F_lookup:
return foundPeers
end if
end for
end for
return foundPeers
end procedure
Refer to the Lookup Algorithm Explanation section for the detailed explanation.
Admission Protocol
Overview
Registrars are nodes that store and serve advertisements. They play a critical role in the Logos discovery network by acting as intermediaries between advertisers and discoverers.
Admission Control Requirements
Registrars MUST use a waiting time based admission protocol
to admit advertisements into their ad_cache.
The mechanism does not require registrars to maintain
any state for each ongoing request preventing DoS attacks.
When a registrar node receives a REGISTER request from an advertiser node
to admit its ad for a service into the ad_cache,
the registrar MUST process the request according to the following requirements:
- The registrar MUST NOT admit an advertisement
if an identical
adalready exists in thead_cache. - The Registrar MUST calculate waiting time for the advertisement
using the formula specified in the
Waiting Time Calculation section.
The waiting time determines how long the advertiser must wait
before the
adcan be admitted to thead_cache. - If no
ticketis provided in theREGISTERrequest then this is the advertiser's first registration attempt for thead. The registrar MUST create a newticketand return the signedticketto the advertiser with statusWait. - If the advertiser provides a
ticketin theREGISTERrequest from a previous attempt:- The registrar MUST verify the
ticket.signatureis valid and was issued by this registrar. - The registrar MUST verify that
ticket.admatches theadin the current request - The registrar MUST verify that the
adis still not in thead_cache - The registrar MUST verify the retry is within the registration window
- If any verification fails, the registrar MUST reject the request
- The registrar MUST recalculate the waiting time based on current cache state
- The registrar MUST calculate remaining wait time:
t_remaining = t_wait - (NOW() - ticket.t_init). This ensures advertisers accumulate waiting time across retries
- The registrar MUST verify the
- If
t_remaining ≤ 0, the registrar MUST add theadto thead_cachewithad.Timestampset to current Unix time. The registrar SHOULD return response withstatus = Confirmed - If
t_remaining > 0, the advertiser SHOULD continue waiting. The registrar MUST issue a newticketwith updatedticket.t_modandticket.t_wait_for = MIN(E, t_remaining). The registrar MUST sign the newticket. The registrar SHOULD return response with statusWaitand the new signedticket. - The registrar SHOULD include a list of closer peers (
response.closerPeers) to help the advertiser improve its advertise table.
ad_cache maintainence:
- The total size of the
ad_cacheMUST be limited by its capacityC. - Each
adstored in thead_cacheMUST have an associated expiry timeE, after which theadis MUST be automatically removed. - When processing or periodically cleaning,
the registrar MUST check
if currentTime - ad.Timestamp > E. If true, theadis expired and MUST be removed from the cache. ad_cacheMUST NOT store duplicatead.
Registration Flow
We RECOMMEND that the following algorithm be used by registrars to implement the admission control requirements specified above.
Refer to the Register Message section
for the request and response structure of REGISTER.
procedure REGISTER(ad, ticket):
assert(ad not in ad_cache)
response.ticket.ad ← ad
t_wait ← CALCULATE_WAITING_TIME(ad)
if ticket.empty():
t_remaining ← t_wait
response.ticket.t_init ← NOW()
response.ticket.t_mod ← NOW()
else:
assert(ticket.hasValidSignature())
assert(ticket.ad = ad)
assert(ad.notInAdCache())
t_scheduled ← ticket.t_mod + ticket.t_wait_for
assert(t_scheduled ≤ NOW() ≤ t_scheduled + δ)
t_remaining ← t_wait - (NOW() - ticket.t_init)
end if
if t_remaining ≤ 0:
ad_cache.add(ad)
response.status ← Confirmed
else:
response.status ← Wait
response.ticket.t_wait_for ← MIN(E, t_remaining)
response.ticket.t_mod ← NOW()
SIGN(response.ticket)
end if
response.closerPeers ← GETPEERS(ad.service_id_hash)
return response
end procedure
Refer to the Register Algorithm Explanation section for detailed explanation.
Lookup Response Algorithm
Registrars respond to GET_ADS requests from discoverers
using the LOOKUP_RESPONSE() algorithm.
Refer to GET_ADS Message section
for the request and response structure of GET_ADS.
procedure LOOKUP_RESPONSE(service_id_hash):
response.ads ← ad_cache.getAdvertisements(service_id_hash)[:F_return]
response.closerPeers ← GETPEERS(service_id_hash)
return response
end procedure
- Fetch all
adsforservice_id_hashfrom the registrar’sad_cache. Then return up toF_returnof them (a system parameter limiting how manyadsare sent per query by a registrar). - Call the
GETPEERS(service_id_hash)function to get a list of peers from across the registrar’s routing tableRegT(service_id_hash). - Send the assembled response (advertisements + closer peers) back to the discoverer.
Peer Table Updates
While responding to both REGISTER requests by advertisers
and GET_ADS request by discoverers,
the contacted registrar node also returns a list of peers.
To get this list of peers, the registrar runs the GETPEERS(service_id_hash) algorithm.
Both advertisers and discoverers update their
service-specific tables using this list of peers.
procedure GETPEERS(service_id_hash):
peers ← SET<peers>
RegT(service_id_hash) ← KadDHT(peerID)
for i in 0, 1, ..., m-1:
peer ← b_i(service_id_hash).getRandomNode()
if peer ≠ None:
peers.add(peer)
end if
end for
return peers
end procedure
peersis initialized as an empty set to avoid storing duplicates- The registrar table
RegT(service_id_hash)is initialized from the node’sKadDHT(peerID)routing table. Refer to the Distance section on how to add peers. - Go through all
mbuckets in the registrar’s table — from farthest to closest relative to theservice_id_hash.- Pick one random peer from bucket
i.getRandomNode()function remembers already returned nodes and never returns the same one twice. - If peer returned is not null then we move on to next bucket. Else we try to get another peer in the same bucket
- Pick one random peer from bucket
- Return
peerswhich contains one peer from every bucket ofRegT(service_id_hash).
Malicious registrars could return large numbers of malicious nodes in a specific bucket.
To limit this risk, a node communicating with a registrar asks it to return a single peer per bucket
from registrar’s view of the routing table RegT(service_id_hash).
Contacting registrars in consecutive buckets divides the search space by a constant factor,
and allows learning new peers from more densely-populated routing tables towards the destination.
The procedure mitigates the risk of having malicious peers polluting the table
while still learning rare peers in buckets close to service_id_hash.
Waiting Time Calculation
Formula
The waiting time is the time advertisers have to
wait before their ad is admitted to the ad_cache.
The waiting time is given based on the ad itself
and the current state of the registrar’s ad_cache.
The waiting time for an advertisement MUST be calculated using:
w(ad) = E × (1/(1 - c/C)^P_occ) × (c(ad.service_id_hash)/C + score(getIP(ad.addrs)) + G)
c: Current cache occupancyc(ad.service_id_hash): Number of advertisements forservice_id_hashin cachegetIP(ad.addrs)is a function to get the IP address from the multiaddress of the advertisement.score(getIP(ad.addrs)): IP similarity score (0 to 1). Refer to the IP Similarity Score section
Section System Parameters can be referred for the definitions of the remaining parameters in the formula.
Issuing waiting times promote diversity in the ad_cache.
It results in high waiting times and slower admission for malicious advertisers
using Sybil identities from a limited number of IP addresses.
It also promotes less popular services with fast admission
ensuring fairness and robustness against failures of single registrars.
Scaling
The waiting time is normalized by the ad’s expiry time E.
It binds waiting time to E and allows us to reason about the number of incoming requests
regardless of the time each ad spends in the ad_cache.
Occupancy Score
occupancy_score = 1 / (1 - c/C)^P_occ
The occupancy score increases progressively as the cache fills:
- When
c << C: Score ≈ 1 (minimal impact) - As the
ad_cachefills up, the score will be amplified by the divisor of the equation. The higher the value ofP_occ, the faster the increase. Implementations should consider this while setting the value forP_occ - As
c → C: Score → ∞ (prevents overflow)
Service Similarity
service_similarity = c(ad.service_id_hash) / C
The service similarity score promotes diversity:
- Low when
service_id_hashhas few advertisements in cache. Thus lower waiting time. - High when
service_id_hashdominates the cache. Thus higher waiting time.
IP Similarity Score
The IP similarity score is used to detect and limit Sybil attacks where malicious actors create multiple advertisements from the same network or IP prefix.
Registrars MUST use an IP similarity score to
limit the number of ads coming from the same subnetwork
by increasing their waiting time.
The IP similarity mechanism:
- MUST calculate a score ranging from 0 to 1, where:
- A score closer to 1 indicates IPs sharing similar prefixes (potential Sybil behavior)
- A score closer to 0 indicates diverse IPs (legitimate behavior)
- MUST track IP addresses of
adscurrently in thead_cache. - MUST update its tracking structure when:
- A new
adis admitted to thead_cache: MUST add the IP - An
adexpires after timeE: MUST remove IP if there are no other activeadsfrom the same IP
- A new
- MUST calculate the IP similarity score every time a waiting time is calculated for a new registration attempt.
Tree Structure
We RECOMMEND using an IP tree data structure
to efficiently track and calculate IP similarity scores.
An IP tree is a binary tree that stores
IPs used by ads currently present in the ad_cache.
This data structure provides logarithmic time complexity
for insertion, deletion, and score calculation.
Implementations MAY use alternative data structures
as long as they satisfy the requirements specified above.
The recommended IP tree has the following structure:
- Each tree vertex stores a
IP_countershowing how many IPs pass through that node. - Apart from root, the IP tree is a 32-level binary tree
- The
IP_counterof every vertex of the tree is initially set to 0. - edges represent consecutive 0s or 1s in a binary representation of IPv4 addresses.
- While inserting an IPv4 address into the tree using
ADD_IP_TO_TREE()algorithm,IP_counters of all the visited vertices are increased by 1. The visited path is the binary representation of the IPv4 address. IPv4 addresses are inserted into the tree only when they are admitted to thead_cache. - The IP tree is traversed to calculate the IP score using
CALCULATE_IP_SCORE()every time the waiting time is calculated. - When an
adexpires afterEtheadis removed from thead_cacheand the IP tree is also updated using theREMOVE_FROM_IP_TREE()algorithm by decreasing theIP_counters on the path. The path is the binary representation of the IPv4 address. - the root
IP_counterstores the number of IPv4 addresses that are currently present in thead_cache
ADD_IP_TO_TREE() algorithm
When an ad is admitted to the ad_cache,
its IPv4 address MUST be added to the IP tracking structure.
We RECOMMEND the ADD_IP_TO_TREE() algorithm for adding IPv4 addresses to the IP tree.
This algorithm ensures efficient insertion with O(32) time complexity.
Implementations MAY use alternative approaches as long as they maintain
accurate IP tracking for similarity calculation.
procedure ADD_IP_TO_TREE(tree, IP):
v ← tree.root
bits ← IP.toBinary()
for i in 0, 1, ..., 31:
v.IP_counter ← v.IP_counter + 1
if bits[i] = 0:
v ← v.left
else:
v ← v.right
end if
end for
end procedure
- Start from the root node of the tree.
Initialize current node variable
vto root of the treetree.root. - Convert the IP address into its binary form (32 bits) and sore in variable
bits - Go through each bit of the IP address, from the most significant (leftmost
0) to the least (rightmost31).- Increase the
IP_counterfor the current nodev.IP_counter. This records that another IP passed through this vertexv(i.e., shares this prefix). - Move to the next node in the tree. Go left
v.leftif the current bitbits[i]is0. Go rightv.rightif it’s1. This follows the path corresponding to the IP’s binary representation.
- Increase the
CALCULATE_IP_SCORE() algorithm
Every time a waiting time is calculated for a registration attempt, the registrar MUST calculate the IP similarity score for the advertiser's IP address. This score determines how similar the IP is to other IPs already in the cache.
We RECOMMEND the CALCULATE_IP_SCORE() algorithm for calculating IP similarity scores.
This algorithm traverses the IP tree to detect how many IPs share common prefixes,
providing an effective measure of potential Sybil behavior.
Implementations MAY use alternative approaches
as long as they accurately measure IP similarity on a 0-1 scale.
procedure CALCULATE_IP_SCORE(tree, IP):
v ← tree.root
score ← 0
bits ← IP.toBinary()
for i in 0, 1, ..., 31:
if bits[i] = 0:
v ← v.left
else:
v ← v.right
end if
if v.IP_counter > tree.root.IP_counter / 2^i:
score ← score + 1
end if
end for
return score / 32
end procedure
- Start from the root node of the tree.
- Initialize the similarity score
scoreto 0. This score will later show how common the IP’s prefix is among existing IPs. - Convert the IP address into its binary form (32 bits) and sore in variable
bits - Go through each bit of the IP address,
from the most significant (leftmost
0) to the least (rightmost31).1. Move to the next node in the tree.
Go left `v.left` if the current bit `bits[i]` is `0`. Go right `v.right` if it’s `1`.
This follows the path corresponding to the IP’s binary representation.
2. Check if this node’s `IP_counter` is larger than expected in a perfectly balanced tree.
If it is, that means too many IPs share this prefix,
so increase the similarity score `score` by 1. - Divide the total score by 32 (the number of bits in the IP) and return it.
REMOVE_FROM_IP_TREE() algorithm
When an ad expires after time E,
the registrar MUST remove the ad from the ad_cache.
The registrar MUST also remove the IP from IP tracking structure
if there are no other active ad in ad_cache from the same IP.
We RECOMMEND the REMOVE_FROM_IP_TREE() algorithm for removing IPv4 addresses from the IP tree.
This algorithm ensures efficient deletion with O(32) time complexity.
Implementations MAY use alternative approaches as long as they maintain accurate IP tracking.
procedure REMOVE_FROM_IP_TREE(tree, IP):
v ← tree.root
bits ← IP.toBinary()
for i in 0, 1, ..., 31:
v.IP_counter ← v.IP_counter - 1
if bits[i] = 0:
v ← v.left
else:
v ← v.right
end if
end for
end procedure
Implementations can extend the IP tree algorithms to IPv6 by using a 128-level binary tree, corresponding to the 128-bit length of IPv6 addresses.
Safety Parameter
The safety parameter G ensures waiting times never reach zero even when:
- Service similarity is zero (new service).
- IP similarity is zero (completely distinct IP)
It prevents ad_cache overflow in cases when attackers try to
send ads for random services or from diverse IPs.
Lower Bound Enforcement
To prevent "ticket grinding" attacks where advertisers repeatedly request new tickets hoping for better waiting times, registrars MUST enforce lower bounds:
Invariant: A new waiting time w_2 at time t_2
cannot be smaller than a previous waiting time w_1 at time t_1
(where t_1 < t_2) by more than the elapsed time:
w_2 ≥ w_1 - (t_2 - t_1)
Thus registrars MUST maintain lower bound state for:
- Each service in the cache:
bound(service_id_hash)andtimestamp(service_id_hash) - Each IP prefix in the IP tree:
bound(IP)andtimestamp(IP)
The total waiting time will respect the lower bound if lower bound is enforced on these.
These two sets have a bounded size as number of ads present in the ad_cache
at a time is bounded by the cache capacity C.
How SHOULD lower bound be calculated for service IDs:
When new service_id_hash enters the cache, bound(service_id_hash) is set to 0,
and a timestamp(service_id_hash) is set to the current time.
When a new ticket request arrives for the same service_id_hash,
the registrar calculates the service waiting time w_s and then applies the lower-bound rule:
w_s = max(w_s, bound(service_id_hash) - timestamp(service_id_hash))
The values bound(service_id_hash) and timestamp(service_id_hash)
are updated whenever a new ticket is issued
and the condition w_s > (bound(service_id_hash) - timestamp(service_id_hash))is satisfied.
How SHOULD lower bound be calculated for IPs: Registrars enforce lower-bound state for the advertiser’s IP address using IP tree (refer to the IP Similarity Score section).
Implementation Notes
Client and Server Mode
Logos discovery respects the client/server mode distinction from the base Kad-dht specification:
- Server mode nodes: MAY be Discoverer, Advertiser or Registrar
- Client mode nodes: MUST be only Discoverer
Implementations MAY include incentivization mechanisms to encourage peers to participate as advertisers or registrars, rather than operating solely in client mode. This helps prevent free-riding behavior, ensures a fair distribution of network load, and maintains the overall resilience and availability of the discovery layer. Incentivization mechanisms are beyond the scope of this RFC.
References
[0][Kademlia: A Peer-to-Peer Information System Based on the XOR Metric](https://pdos.csail.mit.edu/~petar/papers/maymounkov-kademlia-lncs.pdf)
[1][DISC-NG: Robust Service Discovery in the Ethereum Global Network](https://ieeexplore.ieee.org/document/10629017)
[2][libp2p Kademlia DHT specification](https://github.com/libp2p/specs/blob/master/kad-dht/README.md)
[3][Go implementation](https://github.com/libp2p/go-libp2p-kad-dht)
Appendix
This appendix provides detailed explanations of some algorithms and helper procedures referenced throughout this RFC. To maintain clarity and readability in the main specification, the body contains only the concise pseudocode and high-level descriptions.
Advertiser Algorithms Explanation
Refer to the Advertisement Algorithm section for the pseudocode.
ADVERTISE() algorithm explanation
- Initialize a map
ongoingfor tracking which registrars are currently being advertised to. - Initialize the advertise table
AdvT(service_id_hash)by bootstrapping peers from the advertiser’sKadDHT(peerID)routing table. (Refer to the Distance section) - Iterate over all buckets (i = 0 through
m-1), wheremis the number of buckets inAdvT(service_id_hash)andongoingmap. Each bucket corresponds to a particular distance from theservice_id_hash.1. `ongoing[i]` contains list of registrars with active (unexpired) registrations
or ongoing registration attempts at a distance `i`
from the `service_id_hash` of the service that the advertiser is advertising for.
2. Advertisers continuously maintain up to `K_register` active (unexpired) registrations
or ongoing registration attempts in every bucket of the `ongoing` map for its service.
Increasing `K_register` makes the advertiser easier to find
at the cost of increased communication and storage costs.
3. Pick a random registrar from bucket `i` of `AdvT(service_id_hash)` to advertise to.
- `AdvT(service_id_hash).getBucket(i)` → returns a list of registrars in bucket `i`
from the advertise table `AdvT(service_id_hash)`
- `.getRandomNode()` → function returns a random registrar node.
The advertiser tries to place its advertisement into that registrar.
The function remembers already returned nodes
and never returns the same one twice during the same `ad` placement process.
If there are no peers, it returns `None`.
4. if we get a peer then we add that to that bucket `ongoing[i]`
5. Build the advertisement object `ad` containing `service_id_hash`, `peerID`, `addrs`, and `timestamp`
(Refer to the [Advertisement section](#advertisement)) .
Then it is signed by the advertiser using the node’s private key (Ed25519 signature)
6. Then send this `ad` asynchronously to the selected registrar.
The helper `ADVERTISE_SINGLE()` will handle registration to a single registrar.
Asynchronous execution allows multiple `ads` (to multiple registrars) to proceed in parallel.
ADVERTISE_SINGLE() algorithm explanation
ADVERTISE_SINGLE() algorithm handles registration to one registrar at a time
- Initialize
tickettoNoneas we have not yet got any ticket from registrar - Keep trying until the registrar confirms or rejects the
ad.- Send the
adto the registrar usingRegisterrequest. Request structure is described in section Register Message Structure. If we already have a ticket, include it in the request. - The registrar replies with a
response. Refer to the Register Message Structure section for the response structure - Add the list of peers returned by the registrar
response.closerPeersto the advertise tableAdvT(service_id_hash). Refer to the [Distance](#distance section) on how to add. These help improve the table for future use. - If the registrar accepted the advertisement successfully,
wait for
Eseconds, then stop retrying because theadis already registered. - If the registrar says “wait” (its cache is full or overloaded),
sleep for the time written in the ticket
ticket.t_wait_for(but not more thanE). Then updateticketwith the new one from the registrar, and try again. - If the registrar rejects the ad, stop trying with this registrar.
- Send the
- Remove this registrar from the
ongoingmap in bucket i (ongoing[i]), since we’ve finished trying with it.
Discoverer Algorithms
LOOKUP(service_id_hash) algorithm explanation
Refer to the Lookup Algorithm section for the pseudocode.
- The Discovery Table
DiscT(service_id_hash)is initialized by bootstrapping peers from the discoverer’sKadDHT(peerID)routing table. (refer to the Distance section) - Create an empty set
foundPeersto store unique advertisers peer IDs discovered during the lookup. - Go through each bucket of the search table
DiscT(service_id_hash)— from farthest (b₀) to closest (bₘ₋₁) to the service IDservice_id_hash. For each bucket, query up toK_lookuprandom peers.1. Pick a random registrar node from bucket `i` of the search table `DiscT(service_id_hash)` to query
1. `DiscT(service_id_hash).getBucket(i)` → returns a list of registrars
in bucket `i` from the search table `DiscT(service_id_hash)`
2. `.getRandomNode()` → function returns a random registrar node.
The discover queries this node to get `ads` for a particular service ID `service_id_hash`.
The function remembers already returned nodes and never returns the same one twice.
If there are no peers, it returns `None`.
2. A `GET_ADS` request is sent to the selected registrar peer.
Refer to the [GET_ADS Message section](#get_ads-message) to see the request and response structure for `GET_ADS`.
The response returned by the registrar node is stored in `response`
3. The `response` contains a list of advertisements `response.ads`.
A queried registrar returns at most `F_return` advertisements.
If it returns more we can just randomly keep `F_return` of them.
For each advertisement returned:
1. Verify its digital signature for authenticity.
2. Add the advertiser’s peer ID `ad.peerID` to the list `foundPeers`.
4. The `response` also contains a list of peers `response.closerPeers`
that is inserted into the search table `DiscT(service_id_hash)`.
Refer to the [Distance section](#distance) for how it is added.
5. Stop early if enough advertiser peers (`F_lookup`) have been found — no need to continue searching.
For popular services `F_lookup` advertisers are generally found in the initial phase
from the farther buckets and the search terminates.
But for unpopular ones it might take longer but not more than `O(log N)`
where N is number of nodes participating in the network as registrars.
6. If early stop doesn’t happen then the search stops when no unqueried registrars remain in any of the buckets. - Return
foundPeerswhich is the final list of discovered advertisers that provide serviceservice_id_hash
Making the advertisers and discoverers walk towards service_id_hash in a similar fashion
guarantees that the two processes overlap and contact a similar set of registrars that relay the ads.
At the same time, contacting random registrars in each encountered bucket using getRandomNode()
makes it difficult for an attacker to strategically place malicious registrars in the network.
The first bucket b_0(service_id_hash) covers the largest fraction of the key space
as it corresponds to peers with no common prefix to service_id_hash (i.e. 50% of all the registrars).
Placing malicious registrars in this fraction of the key space
to impact service discovery process would require considerable resources.
Subsequent buckets cover smaller fractions of the key space,
making it easier for the attacker to place Sybils
but also increasing the chance of advertisers already gathering enough ads in previous buckets.
Parameters F_return and F_lookup play an important role in setting a compromise between security and efficiency.
A small value of F_return << F_lookup increases the diversity of the source of ads received by the discoverer
but increases search time, and requires reaching buckets covering smaller key ranges where eclipse risks are higher.
On the other hand, similar values for F_lookup and F_return reduce overheads
but increase the danger of a discoverer receiving ads uniquely from malicious nodes.
Finally, low values of F_lookup stop the search operation early,
before reaching registrars close to the service hash, contributing to a more balanced load distribution.
Implementations should consider these trade-offs carefully when selecting appropriate values.
Registrar Algorithms
REGISTER() algorithm explanation
Refer to the Registration Flow section for the pseudocode
- Make sure this advertisement
adis not already in the registrar’s advertisement cachead_cache. Duplicates are not allowed. An advertiser can place at most oneadfor a specificservice_id_hashin thead_cacheof a given registrar. - Prepare a response ticket
response.ticketlinked to thisad. - Then calculate how long the advertiser should wait
t_waitbefore being admitted. Refer to the Waiting Time Calculation section for details. - Check if this is the first registration attempt (no ticket yet):
- If yes then it’s the first try. The advertiser must wait for the full waiting time
t_wait. The ticket’s creation timet_initand last-modified timet_modare both set toNOW(). - If no, then this is a retry, so a previous ticket exists.
- Validate that the ticket is properly signed by the registrar,
belongs to this same advertisement and that the
adis still not already in thead_cache. - Ensure the retry is happening within the allowed time window
δafter the scheduled time. If the advertiser waits too long or too short, the ticket is invalid. - Calculate how much waiting time is left
t_remainingby subtracting how long the advertiser has already waited (NOW() - ticket.t_init) fromt_wait.
- Validate that the ticket is properly signed by the registrar,
belongs to this same advertisement and that the
- If yes then it’s the first try. The advertiser must wait for the full waiting time
- Check if the remaining waiting time
t_remainingis less than or equal to 0. This means the waiting time is over.t_remainingcan be 0 also when the registrar decides that the advertiser doesn’t have to wait for admission to thead_cache(waiting timet_waitis 0).1. If yes, add the `ad` to `ad_cache` and confirm registration.
The advertisement is now officially registered.
2. If no, then there is still time to wait.
In this case registrar does not store `ad` but instead issues a ticket.
1. set `reponse.status` to `wait`
2. Update the ticket with the new remaining waiting time `t_wait_for`
3. Update the ticket last modification time `t_mod`
4. Sign the ticket again. The advertiser will retry later using this new ticket. - Add a list of peers closer to the
ad.service_id_hashusing theGETPEERS()function to the response (the advertiser uses this to update its advertise tableAdvT(service_id_hash)). - Send the full response back to the advertiser
Upon receiving a ticket, the advertiser waits for the specified t_wait time
before trying to register again with the same registrar.
Each new registration attempt must include the latest ticket issued by that registrar.
A ticket can only be used within a limited registration window δ,
which is chosen to cover the maximum expected delay between the advertiser and registrar.
This rule prevents attackers from collecting many tickets, accumulating waiting times,
and submitting them all at once to overload the registrar.
Advertisers only read the waiting time t_wait from the ticket —
they do not use the creation time t_init or the modification time t_mod.
Therefore, clock synchronization between advertisers and registrars is not required.
The waiting time t_wait is not fixed.
Each time an advertiser tries to register,
the registrar recalculates a new waiting time based on its current cache state.
The remaining time t_remaining is then computed as the difference between
the new waiting time and the time the advertiser has already waited, as recorded in the ticket.
With every retry, the advertiser accumulates waiting time and will eventually be admitted.
However, if the advertiser misses its registration window or fails to include the last ticket,
it loses all accumulated waiting time and must restart the registration process from the beginning.
Implementations must consider these factors while deciding the registration window δ time.
This design lets registrars prioritize advertisers that have waited longer
without keeping any per-request state before the ad is admitted to the cache.
Because waiting times are recalculated and tickets are stored only on the advertiser’s side,
the registrar is protected from memory exhaustion
and DoS attacks caused by inactive or malicious advertisers.