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
| Method | Description |
|---|---|
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 / Property | Description |
|---|---|
connect(to:) async throws | Dial a peer by endpoint. Adds to router and starts message handler. |
disconnect(_:) | Close connection and remove peer. |
connectedPeers | Array of currently connected PeerIDs. |
worker() | Get the NetworkCASWorker for this node. |
Content Operations
| Method | Description |
|---|---|
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
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
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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:
- Bucket 0: Peers where the first bit differs (half the keyspace — far away)
- Bucket 1: Peers sharing 1 prefix bit (quarter of the keyspace)
- Bucket 255: Peers sharing 255 prefix bits (extremely close neighbors)
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
| Tag | Message | Payload | Purpose |
|---|---|---|---|
| 0 | ping | 8 bytes: nonce | Liveness check |
| 1 | pong | 8 bytes: nonce | Liveness response |
| 2 | wantBlock | 2 + N bytes: CID | Request content by CID |
| 3 | block | 2 + N + 4 + M bytes | Deliver content |
| 4 | dontHave | 2 + N bytes: CID | Negative response |
| 5 | findNode | 2 + N bytes: target hash | DHT peer lookup |
| 6 | neighbors | Variable: endpoint list | DHT lookup response |
| 7 | announceBlock | 2 + N bytes: CID | Block 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) }
}
| Method | Behavior |
|---|---|
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:
- Advertising: Announces the node's service type, port, and public key as a TXT record
- Browsing: Discovers other Ivy nodes on the LAN and auto-connects
- Service type: Configurable via
IvyConfig.serviceType(default"_ivy._tcp")
bootstrapPeers in the config to specify known peers.
Content Routing Flow
When NetworkCASWorker.getLocal(cid:) is called:
- Peer selection:
Router.closestPeers(to: hash(cid))finds peers near the content in the keyspace - Reputation ranking: Candidates are filtered to connected peers and sorted by
Tally.reputation() - Parallel requests: Up to
maxConcurrentRequests(default 6) peers are asked simultaneously via.wantBlock - First response wins: The first
.blockresponse resolves the request; the timeout (default 15s) resolves to nil - Metrics recorded: Latency, success/failure, and bytes transferred are recorded in Tally for each peer