Outbound uTP connections never completed against real peers (libutp-based, e.g. qBittorrent): the SYN got a valid ST_STATE reply but the peer's first ST_DATA was silently dropped and the connection stalled. Three aligned fixes: - Defer Send() until the connection is established, queueing data so the BT handshake goes out with a valid ack_nr instead of ack_nr=0 before the SYN is acked. Flushed once the establishing ST_STATE arrives. - On establish, baseline AckNumber to (ST_STATE.seq - 1), not ST_STATE.seq. libutp advertises its next-to-send seq on the ST_STATE, and its first real ST_DATA reuses that same seq, so acking seq made that first data look like a duplicate and get dropped by the reorder buffer. - Outgoing ST_STATE packets now advertise the next-to-send seq (SeqNumber+1) to match libutp, keeping our own acceptor consistent with the new baseline. - A pure-ack ST_STATE no longer advances AckNumber (only ST_DATA does, via the reorder buffer). Verified live against a qBittorrent seeder and across the full uTP unit suite (receive/reorder + two-real-socket passive-open, both directions).
bzTorrent
A .NET Standard 2.0 library for building BitTorrent clients and tools. Handles the protocol plumbing — tracker communication, peer wire connections, metadata parsing, peer discovery, and DHT — so you can focus on what your application does with torrents rather than the protocol details.
Installation
dotnet add package bzTorrent
Compatible with .NET Standard 2.0 and above (.NET 5/6/7/8/9, .NET Framework 4.6.1+, Xamarin, Unity, etc.).
Quick start
using bzTorrent;
using bzTorrent.Data;
using bzTorrent.IO;
// Load a .torrent file
var metadata = Metadata.FromFile("ubuntu.torrent");
// Or parse a magnet link
var metadata = MagnetLink.ResolveToMetadata("magnet:?xt=urn:btih:...");
Console.WriteLine(metadata.Name); // "ubuntu-24.04-desktop-amd64.iso"
Console.WriteLine(metadata.HashString); // "DAFC8C076CA2F3ED..."
Console.WriteLine(metadata.PieceHashes.Count); // number of pieces
Console.WriteLine(metadata.PieceSize); // bytes per piece
foreach (var file in metadata.GetFileInfos())
Console.WriteLine($"{file.Filename} ({file.FileSize} bytes, starts at byte {file.FileStartByte})");
foreach (var tracker in metadata.AnnounceList)
Console.WriteLine(tracker);
Finding peers
Trackers (HTTP and UDP)
using bzTorrent;
// HTTP tracker
ITrackerClient tracker = new HTTPTrackerClient(timeoutSeconds: 10);
// UDP tracker
ITrackerClient tracker = new UDPTrackerClient(timeoutSeconds: 10);
var info = tracker.Announce(
trackerUrl: "udp://tracker.opentrackr.org:1337/announce",
infoHash: metadata.HashString,
peerId: "-MY0001-123456789012",
downloaded: 0,
left: metadata.PieceHashes.Count * metadata.PieceSize,
uploaded: 0,
ipAddress: 0,
key: 0,
numWant: 200,
port: 6881,
compact: 0);
Console.WriteLine($"{info.Seeders} seeders, {info.Leechers} leechers");
foreach (var peer in info.Peers)
Console.WriteLine(peer); // IPEndPoint — IPv4 or IPv6
// Scrape (stats only, no peer list)
var stats = tracker.Scrape(trackerUrl, metadata.HashString);
Console.WriteLine($"{stats.Seeders} seeders, {stats.Leechers} leechers, {stats.Downloaded} completed");
IPv6 peers (BEP-7) are included automatically: HTTPTrackerClient merges the peers and peers6 fields from the tracker's response, and UDPTrackerClient returns whichever family the announce request itself went out on (IPv6 if the tracker hostname resolves to an AAAA record).
DHT (trackerless)
using bzTorrent.DHT;
var dht = new DHTClient();
dht.PeerFound += endpoint => Console.WriteLine($"DHT found peer: {endpoint}");
dht.Start();
// Bootstrap from the standard DHT bootstrap nodes, then search
var bootstrapNodes = new[]
{
new IPEndPoint(Dns.GetHostAddresses("router.bittorrent.com").First(), 6881),
new IPEndPoint(Dns.GetHostAddresses("router.utorrent.com").First(), 6881),
new IPEndPoint(Dns.GetHostAddresses("dht.transmissionbt.com").First(), 6881),
};
await dht.BootstrapAsync(bootstrapNodes);
dht.StartSearch(metadata.Hash); // searches continuously, re-querying every 5 minutes
// When done
dht.Dispose();
DHTClient uses a single dual-stack socket (BEP-32): bootstrap nodes, queries, and discovered peers can be IPv4 or IPv6 endpoints interchangeably — PeerFound fires with whichever family a peer used.
Local peer discovery (LPD / BEP-14)
using bzTorrent;
using bzTorrent.IO;
var lpd = new LocalPeerDiscovery<UDPSocket>();
lpd.NewPeer += (address, port, infoHash) =>
Console.WriteLine($"Local peer: {address}:{port}");
lpd.Open();
Connecting to a peer
using bzTorrent;
using bzTorrent.IO;
var connection = new PeerWireConnection<TCPSocket> { Timeout = 5 };
var client = new PeerWireClient(connection) { KeepConnectionAlive = true };
// Wire up events
client.HandshakeComplete += (pwc) => pwc.SendInterested();
client.BitField += (pwc, length, bitField) => pwc.SendInterested();
client.UnChoke += (pwc) => { /* now we can request pieces */ };
client.Choke += (pwc) => { /* stop sending requests */ };
client.Have += (pwc, pieceIndex) => { /* peer has this piece */ };
client.Piece += (pwc, index, offset, data) =>
{
// write data to disk at (index * pieceSize + offset)
};
client.DroppedConnection += (pwc) => Console.WriteLine("Disconnected");
// Connect and handshake
client.Connect(peerEndPoint);
client.Handshake(metadata.HashString, peerId);
// Drive the connection — call in a loop until it returns false
while (client.Process())
{
if (!client.ReceivedHandshake) continue;
// Request a piece block (pieceIndex, byteOffset, byteLength)
client.SendRequest(pieceIndex, offset, blockSize);
}
Requesting a piece in 16 KB blocks
BitTorrent pieces are typically 256 KB and must be requested in blocks of up to 16 KB:
var blockSize = 16 * 1024;
var pieceSize = metadata.PieceSize; // or smaller for the last piece
var blocks = (int)Math.Ceiling(pieceSize / (double)blockSize);
for (int b = 0; b < blocks; b++)
{
var offset = b * blockSize;
var length = (int)Math.Min(pieceSize - offset, blockSize);
client.SendRequest((uint)pieceIndex, (uint)offset, (uint)length);
}
uTP transport (BEP-29)
UTPSocket implements the Micro Transport Protocol (BEP-29) as a drop-in alternative to TCPSocket for PeerWireConnection<T> / PeerWireListener<T> — useful when you want BitTorrent traffic to back off under contention rather than crowding out other traffic on the link. It includes LEDBAT-based congestion control: the send window grows and shrinks based on measured one-way delay to the peer, per the algorithm in bzTorrent/Docs/utp-protocol.md.
using bzTorrent;
using bzTorrent.IO;
var connection = new PeerWireConnection<UTPSocket> { Timeout = 5 };
var client = new PeerWireClient(connection) { KeepConnectionAlive = true };
client.Connect(peerEndPoint);
client.Handshake(metadata.HashString, peerId);
Note:
UTPSocketis experimental against real-world/foreign uTP implementations (wire compatibility with e.g. libutp-based clients hasn't been verified — no real internet peer has been observed completing a uTP handshake with this library yet). It's internally self-consistent and tested end-to-end, though: retransmission (RTO-based, with Karn's algorithm and exponential backoff) and out-of-order reassembly work — seeUTPRetransmissionTracker/UTPReorderBuffer—Receive()is a real blocking byte-stream read so MSE/PE (below) works over uTP too, and passive-open (Listen/Accept) works: twoUTPSockets (orPeerWireListener/PeerWireConnectionbuilt on them) can complete a full handshake and exchange data bidirectionally over real UDP. PreferTCPSocketfor production use until real-world interop is verified.
Message Stream Encryption (MSE/PE)
Obfuscates the handshake and subsequent traffic (RC4) so simple deep packet inspection can't identify it as BitTorrent. Set EncryptionMode on the PeerWireConnection before connecting/handshaking:
using bzTorrent.IO;
var connection = new PeerWireConnection<TCPSocket>
{
Timeout = 5,
// PlainText — never attempt MSE (default)
// PreferEncryption — attempt MSE, fall back to plaintext if the peer won't encrypt
// RequireEncryption — attempt MSE, refuse the connection if it can't be encrypted
EncryptionMode = PeerEncryptionMode.PreferEncryption
};
var client = new PeerWireClient(connection);
client.Connect(peerEndPoint);
client.Handshake(metadata.HashString, peerId); // runs the MSE key exchange first, if enabled
// Check whether the negotiated payload ended up encrypted
client.Piece += (pwc, index, offset, data) =>
Console.WriteLine(pwc.IsEncrypted ? "encrypted piece" : "plaintext piece");
PeerEncryptionOptions (on connection.EncryptionOptions) controls the negotiation details:
connection.EncryptionOptions.MaxPaddingBytes = 512; // spec default
AddKnownInfoHash is required to accept encrypted incoming connections, since an incoming MSE handshake hides the infohash until matched against a known set. PeerWireConnection<T>.Accept() propagates EncryptionMode and EncryptionOptions (including known infohashes) to each accepted connection automatically:
var listener = new PeerWireConnection<TCPSocket> { EncryptionMode = PeerEncryptionMode.PreferEncryption };
listener.EncryptionOptions.AddKnownInfoHash(metadata.HashString);
listener.Listen(new IPEndPoint(IPAddress.Any, 6881));
var accepted = listener.Accept(); // inherits EncryptionMode + known infohashes
Note: the higher-level
PeerWireListener<T>convenience class (see Listening for incoming connections) does not currently useAccept()internally, so encryption settings aren't propagated to connections it hands you — use the pattern above directly if you need encrypted incoming connections today.
Protocol extensions
Extensions plug into PeerWireClient and handle the details of their respective BEPs automatically.
Fast extensions (BEP-6)
using bzTorrent.ProtocolExtensions;
var fast = new FastExtensions();
fast.HaveAll += (pwc) => pwc.SendInterested(); // seeder — has everything
fast.HaveNone += (pwc) => { }; // peer has nothing yet
fast.AllowedFast += (pwc, index) => { /* request this piece even when choked */ };
fast.SuggestPiece += (pwc, index) => { /* peer recommends this piece */ };
client.RegisterBTExtension(fast);
Extended protocol container (BEP-10)
BEP-10 is required to use PEX, metadata exchange, and tracker exchange. Register it once and add sub-extensions to it:
using bzTorrent.ProtocolExtensions;
var extProtocol = new ExtendedProtocolExtensions();
// Add sub-extensions (see below)
extProtocol.RegisterProtocolExtension(client, pex);
extProtocol.RegisterProtocolExtension(client, trackerExchange);
extProtocol.RegisterProtocolExtension(client, utMetadata);
client.RegisterBTExtension(extProtocol);
Peer exchange (BEP-11)
Peers share peer lists with each other. Wire up Added to grow your peer pool for free:
var pex = new UTPeerExchange();
pex.Added += (pwc, ext, endpoint, flags) => { /* add endpoint to your peer pool */ };
pex.Dropped += (pwc, ext, endpoint) => { /* peer left the swarm */ };
extProtocol.RegisterProtocolExtension(client, pex);
Metadata exchange / magnet links (BEP-9)
Fetch the full .torrent info dictionary from peers when you only have a magnet link:
var utMetadata = new UTMetadata();
utMetadata.MetaDataReceived += (pwc, ext, infoDict) =>
{
// infoDict is a BDict (bzBencode) containing the full info dictionary
// Load it into a Metadata object:
var metadata = new Metadata();
metadata.Load(magnetLink); // sets hash + trackers from the magnet link
metadata.Load(infoDict); // populates pieces, files, name
};
extProtocol.RegisterProtocolExtension(client, utMetadata);
Tracker exchange (BEP-28)
Peers share tracker URLs they know about:
var trackerExchange = new LTTrackerExchange();
trackerExchange.TrackerAdded += (pwc, ext, trackerUrl) =>
{
// announce to the new tracker
};
extProtocol.RegisterProtocolExtension(client, trackerExchange);
DHT port advertisement (BEP-5)
Peers advertise their DHT port during the handshake:
var dhtPort = new DHTPortExtension();
dhtPort.Port += (pwc, port) =>
{
// peer's DHT node is listening on port
};
client.RegisterBTExtension(dhtPort);
Holepunch (BEP-55)
Lets a peer (A) that can't connect directly to another peer (C) it learned about via PEX ask a peer it IS connected to (B, if also connected to C) to relay a NAT hole-punch. This only implements the wire format and events — deciding when to rendezvous, tracking who's connected to whom, and retrying the connection is left to the consumer, same as the other extensions:
var holepunch = new UTHolepunchExtension();
holepunch.Rendezvous += (pwc, ext, target) => { /* we're B: relay by SendConnect to target and to the requester */ };
holepunch.Connect += (pwc, ext, target) => { /* we've been told to (re)try connecting to target */ };
holepunch.Error += (pwc, ext, target, code) => { /* rendezvous failed, see UTHolepunchErrorCode */ };
extProtocol.RegisterProtocolExtension(client, holepunch);
Note: the payload byte layout is implemented to the best available understanding of BEP-55 — verify against the published BEP text before relying on it for interop with other clients, the same caveat as this library's other experimental pieces.
WebSeeds (BEP-19)
Fetch piece data over plain HTTP(S) from URLs the torrent publishes, instead of (or alongside) peers — useful when the swarm has few seeders:
using bzTorrent;
if (metadata.WebSeeds.Count > 0)
{
IWebSeedClient webSeed = new HTTPWebSeedClient(timeoutSeconds: 10);
// Same (pieceIndex, offset, length) coordinates as PeerWireClient.SendRequest.
var block = webSeed.GetPieceBlock(metadata.WebSeeds.First(), metadata, pieceIndex, offset, length);
WritePieceData(pieceIndex, offset, block);
}
GetPieceBlock maps the request onto whichever file(s) it spans and issues one ranged HTTP GET per file, per BEP-19: a single-file torrent's URL addresses the file directly, a multi-file torrent's URL is treated as a directory with the torrent name and file path appended.
Writing piece data to files
MetadataFileInfo.FileStartByte gives the byte offset of each file within the overall torrent data stream. Use this to map piece data to the correct file position:
void WritePieceData(IMetadata metadata, string outputDir, int pieceIndex, int offset, byte[] data)
{
var absoluteStart = (long)pieceIndex * metadata.PieceSize + offset;
var absoluteEnd = absoluteStart + data.Length;
foreach (var file in metadata.GetFileInfos())
{
var fileEnd = file.FileStartByte + file.FileSize;
if (absoluteEnd <= file.FileStartByte || absoluteStart >= fileEnd)
continue;
var fileOffset = Math.Max(absoluteStart - file.FileStartByte, 0);
var dataOffset = (int)Math.Max(file.FileStartByte - absoluteStart, 0);
var writeLength = (int)Math.Min(data.Length - dataOffset, file.FileSize - fileOffset);
var path = Path.GetFullPath(file.Filename, outputDir);
using var fs = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.ReadWrite);
fs.Seek(fileOffset, SeekOrigin.Begin);
fs.Write(data, dataOffset, writeLength);
}
}
Listening for incoming connections
using bzTorrent;
using bzTorrent.IO;
var listener = new PeerWireListener<TCPSocket>(port: 6881);
listener.NewPeer += (client) =>
{
// client is a PeerWireClient — wire up events and call Process() as above
};
listener.Start();
Namespaces
| Namespace | Contents |
|---|---|
bzTorrent |
PeerWireClient, PeerWireListener, LocalPeerDiscovery, tracker clients, IWebSeedClient/HTTPWebSeedClient |
bzTorrent.Data |
Metadata, MagnetLink, MetadataFileInfo, MetadataPieceInfo |
bzTorrent.DHT |
DHTClient, DHTNode, DHTRoutingTable |
bzTorrent.IO |
PeerWireConnection, TCPSocket, UDPSocket, UTPSocket |
bzTorrent.ProtocolExtensions |
FastExtensions, ExtendedProtocolExtensions, UTPeerExchange, UTMetadata, LTTrackerExchange, DHTPortExtension, UTHolepunchExtension |
Implemented BEPs
| BEP | Description |
|---|---|
| BEP-3 | BitTorrent protocol — peer wire, tracker HTTP announce |
| BEP-5 | DHT protocol |
| BEP-6 | Fast extensions |
| BEP-7 | IPv6 tracker extension |
| BEP-9 | Extension for peers to send metadata files (magnet link metadata fetch) |
| BEP-10 | Extension protocol |
| BEP-11 | Peer exchange (PEX) |
| BEP-14 | Local service discovery |
| BEP-15 | UDP tracker protocol |
| BEP-19 | WebSeed — HTTP/FTP seeding |
| BEP-28 | Tracker exchange protocol |
| BEP-32 | IPv6 DHT extension |
| BEP-55 | Holepunch extension |
| — | Message Stream Encryption / Protocol Encryption (MSE/PE) — not a formal BEP, but widely implemented |
License
BSD 3-Clause. See LICENSE.