Ivy Networking

Peer-to-peer networking layer. Kademlia DHT routing, TCP transport via swift-nio, reputation-weighted content routing, and local peer discovery.

.package(url: "https://github.com/treehauslabs/Ivy.git", from: "1.0.0")

Ivy Actor

actor

The main node. Manages connections, routes content requests, and maintains the DHT.

public actor Ivy {
    public let config: IvyConfig
    public let tally: Tally
    public let router: Router
    public let localID: PeerID
    public let group: EventLoopGroup
    public weak var delegate: IvyDelegate?

    public init(
        config: IvyConfig,
        group: EventLoopGroup = MultiThreadedEventLoopGroup.singleton
    )
}

Lifecycle

MethodDescription
start() async throws Start TCP listener, local discovery (if enabled), and connect to bootstrap peers.
stop() async Close server, stop discovery, disconnect all peers.

Peer Management

Method / PropertyDescription
connect(to:) async throwsDial a peer by endpoint. Adds to router and starts message handler.
disconnect(_:)Close connection and remove peer.
connectedPeersArray of currently connected PeerIDs.
worker()Get the NetworkCASWorker for this node.

Content Operations

MethodDescription
announceBlock(cid:) async Tell all connected peers you have a block. Respects Tally gating.
broadcastBlock(cid:data:) async Push block data to all connected peers.
handleBlockRequest(cid:from:) async Serve a block request. Checks Tally, fetches from local chain, sends .block or .dontHave.

DHT

findNode(target:) → [PeerEndpoint]

Look up peers close to a target key in the DHT. Returns the locally-known closest peers and queries up to 3 of them for their closest peers.

PeerConnection

A TCP connection to a single peer, built on swift-nio Channel.

public final class PeerConnection: @unchecked Sendable {
    public let id: PeerID
    public let endpoint: PeerEndpoint
    public var messages: AsyncStream<Message>

    public static func dial(
        endpoint: PeerEndpoint,
        group: EventLoopGroup
    ) async throws -> PeerConnection

    public func send(_ message: Message) async throws
    public func cancel()
}

Inbound messages are delivered via AsyncStream<Message>. The stream finishes when the connection closes.

NIO Pipeline

TCP bytes → MessageFrameDecoder → PeerChannelHandler → AsyncStream<Message> │ │ ├── length-prefix decode ├── feed to PeerConnection └── Message.deserialize └── close detection

MessageFrameDecoder handles buffering and 4-byte big-endian length prefix decoding, then deserializes the payload into a Message enum value.

PeerEndpoint

public struct PeerEndpoint: Sendable, Equatable {
    public let publicKey: String
    public let host: String
    public let port: UInt16
}

Router (Kademlia DHT)

A Kademlia-style routing table with 256 k-buckets.

public struct Router: Sendable {
    public init(localID: PeerID, k: Int = 20)
}

Methods

MethodDescription
addPeer(_:endpoint:tally:) Add or update a peer. When bucket is full, evicts the peer with lowest Tally reputation.
closestPeers(to:count:) Return the count closest peers to a target hash by XOR distance.
allPeers() Return all peers across all buckets.
peerCount() Total number of peers in the routing table.
bucketIndex(for:) Get the k-bucket index for a given peer key.

Static Utilities

MethodDescription
Router.hash(_:)SHA-256 hash of a string, returned as [UInt8].
Router.commonPrefixLength(_:_:)XOR-based common prefix length between two hashes.
Router.xorDistance(_:_:)Byte-wise XOR distance between two hashes.

BucketEntry

public struct BucketEntry: Sendable {
    public let id: PeerID
    public let hash: [UInt8]
    public let endpoint: PeerEndpoint
    public var lastSeen: ContinuousClock.Instant
}

How K-Buckets Work

The 256-bit SHA-256 keyspace is divided into 256 buckets by Common Prefix Length (CPL) between the local node's hash and each peer's hash:

Each bucket holds at most k entries (default 20). When full, the peer with the lowest Tally reputation is evicted.

Wire Protocol

All messages are serialized to a binary format with a 1-byte tag followed by a message-specific payload.

public enum Message: Sendable {
    case ping(nonce: UInt64)
    case pong(nonce: UInt64)
    case wantBlock(cid: String)
    case block(cid: String, data: Data)
    case dontHave(cid: String)
    case findNode(target: Data)
    case neighbors([PeerEndpoint])
    case announceBlock(cid: String)
}

Message Types

TagMessagePayloadPurpose
0ping8 bytes: nonceLiveness check
1pong8 bytes: nonceLiveness response
2wantBlock2 + N bytes: CIDRequest content by CID
3block2 + N + 4 + M bytesDeliver content
4dontHave2 + N bytes: CIDNegative response
5findNode2 + N bytes: target hashDHT peer lookup
6neighborsVariable: endpoint listDHT lookup response
7announceBlock2 + N bytes: CIDBlock availability

Framing

public static func frame(_ message: Message) -> Data

Wraps a serialized message in a 4-byte big-endian length prefix. Maximum frame size enforced at 64 MB.

IvyConfig

public struct IvyConfig: Sendable {
    public let publicKey: String
    public let listenPort: UInt16             // default: 4001
    public let bootstrapPeers: [PeerEndpoint] // default: []
    public let enableLocalDiscovery: Bool     // default: true
    public let tallyConfig: TallyConfig       // default: .default
    public let kBucketSize: Int              // default: 20
    public let maxConcurrentRequests: Int    // default: 6
    public let requestTimeout: Duration      // default: 15s
    public let serviceType: String           // default: "_ivy._tcp"
}

IvyDelegate

Optional protocol for receiving lifecycle and content events:

public protocol IvyDelegate: AnyObject, Sendable {
    func ivy(_ ivy: Ivy, didConnect peer: PeerID)
    func ivy(_ ivy: Ivy, didDisconnect peer: PeerID)
    func ivy(_ ivy: Ivy, didReceiveBlockAnnouncement cid: String, from peer: PeerID)
    func ivy(_ ivy: Ivy, didReceiveBlock cid: String, data: Data, from peer: PeerID)
}

All methods have default empty implementations — implement only the ones you need.

NetworkCASWorker

actor AcornCASWorker

Bridges the Ivy network layer into the AcornCASWorker chain. When the local chain misses, this worker asks the peer network.

public actor NetworkCASWorker: AcornCASWorker {
    public var timeout: Duration? { .seconds(30) }
}
MethodBehavior
has(cid:) Attempts to fetch from network, returns true if found.
getLocal(cid:) Calls Ivy.fetchBlock(cid:) — selects closest, highest-reputation peers, sends .wantBlock, waits for .block response or timeout.
storeLocal(cid:data:) No-op. Network is read-only in the chain model.

Local Discovery

On Apple platforms (#if canImport(Network)), Ivy can discover peers on the local network via Bonjour/mDNS:

Linux
Local discovery requires Network.framework and is only available on Apple platforms. On Linux, use bootstrapPeers in the config to specify known peers.

Content Routing Flow

When NetworkCASWorker.getLocal(cid:) is called:

  1. Peer selection: Router.closestPeers(to: hash(cid)) finds peers near the content in the keyspace
  2. Reputation ranking: Candidates are filtered to connected peers and sorted by Tally.reputation()
  3. Parallel requests: Up to maxConcurrentRequests (default 6) peers are asked simultaneously via .wantBlock
  4. First response wins: The first .block response resolves the request; the timeout (default 15s) resolves to nil
  5. Metrics recorded: Latency, success/failure, and bytes transferred are recorded in Tally for each peer