Protocol
Transport Layer
All messages use JSON-line envelopes terminated with \r\n:
{"type": <message_type>, "line": "<payload>"}
Message Types:
| Code | Name | Description |
|---|---|---|
| 685 | PRIVMSG | Private message between two peers |
| 687 | PUBMSG | Public broadcast to all peers |
| 789 | PEERLIST | Directory sends list of connected peers |
| 791 | GETPEERLIST | Request peer list from directory |
| 793 | HANDSHAKE | Peer handshake (sent by both sides) |
| 795 | DN_HANDSHAKE | Directory-only handshake response |
| 797 | PING | Keep-alive ping |
| 799 | PONG | Ping response |
| 801 | DISCONNECT | Graceful disconnect |
JoinMarket Messages
Inside the line field of PRIVMSG/PUBMSG, JoinMarket messages follow this format:
!command [[field1] [field2] ...]
For private messages with routing:
{from_nick}!{to_nick}!{command} {arguments}
Fields are separated by single whitespace (multiple spaces not allowed).
CoinJoin Flow
Protocol Commands:
| Command | Encrypted | Plaintext OK | Phase | Description |
|---|---|---|---|---|
!orderbook |
No | Yes | 1 | Request offers from makers |
!reloffer, !absoffer |
No | Yes | 1 | Maker offer responses (via PRIVMSG) |
!fill |
No | Yes | 2 | Taker fills offer with NaCl pubkey + PoDLE commitment |
!pubkey |
No | Yes | 2 | Maker responds with NaCl pubkey |
!error |
No | Yes | Any | Error notification |
!push |
No | Yes | 5 | Request maker to broadcast transaction |
!tbond |
No | Yes | 1 | Fidelity bond proof (with offers) |
!auth |
Yes | No | 3 | Taker reveals PoDLE proof (encrypted) |
!ioauth |
Yes | No | 3 | Maker sends UTXOs + addresses (encrypted) |
!tx |
Yes | No | 4 | Taker sends unsigned transaction (encrypted) |
!sig |
Yes | No | 4 | Maker signs inputs (encrypted, one per input) |
!hp2 |
No | Yes | 3 | PoDLE commitment blacklist broadcast |
Note: Rules enforced at message_channel layer. All encrypted messages are base64-encoded.
Phase 1: Orderbook Discovery
- Taker connects to directory servers
- Sends
!orderbookrequest (public broadcast) - Makers respond via PRIVMSG with
!relofferor!absoffer - Taker collects offers, filters stale/incompatible, selects makers
Phase 2: Fill Request
- Taker sends
!fillwith: order ID, amount, NaCl pubkey, PoDLE commitment - Selected makers respond with
!pubkey(their NaCl pubkey) - From here, all messages are NaCl encrypted
Phase 3: Authentication
- Taker sends
!auth: reveals PoDLE proof, UTXO info, CoinJoin destination - Maker verifies PoDLE proof and taker's UTXO
- Maker sends
!ioauth: their UTXOs + CoinJoin/change destinations - Maker broadcasts
!hp2to blacklist commitment network-wide (via ephemeral identity)
Commitment Broadcast (!hp2) Timing and Privacy:
The !hp2 broadcast is sent after !ioauth, not before. This is intentional: broadcasting early would risk other makers in the same transaction seeing the commitment and blacklisting it before they have processed the same taker's !auth, causing spurious rejections.
For source obfuscation, the maker does not broadcast !hp2 on its own long-lived directory connection. Instead:
- Maker opens new connections to all directory servers using a fresh random nick and unique Tor circuit (via SOCKS5 stream isolation with a random credential)
- Broadcasts
!hp2 <commitment>as pubmsg on each ephemeral connection - Closes all ephemeral connections
This prevents any party (directory servers, other peers, observers) from correlating the !hp2 broadcast with the maker that participated in the CoinJoin. The broadcast is best-effort: connection failures are logged but do not affect the CoinJoin flow.
When a maker receives a relay request (!hp2 via privmsg from another maker), it uses the same ephemeral identity approach to re-broadcast, rather than publishing on its own connection.
Phase 4: Transaction Signing
- Taker builds unsigned transaction with all inputs/outputs
- Sends
!txto each maker - Makers verify transaction (critical security checks), sign, return
!sig - Taker assembles fully signed transaction
Phase 5: Broadcast
Broadcast policies (configurable):
| Policy | Behavior |
|---|---|
SELF |
Broadcast via own backend |
RANDOM_PEER |
Try makers sequentially, fall back to self |
MULTIPLE_PEERS |
Broadcast to N random makers (default 3), fall back to self |
NOT_SELF |
Try makers only, no fallback |
Default is MULTIPLE_PEERS for redundancy.
Direct vs Relay Connections
JoinMarket supports two routing modes:
Direct Peer Connections (Preferred):
- Taker connects directly to maker's onion address
- Bypasses directory for private messages
- Better privacy (directory doesn't see message metadata)
- Lower latency after initial connection
- Default behavior (
prefer_direct_connections=True)
Directory Relay (Fallback):
- Messages routed through directory servers
- Works when direct connection fails
- Higher latency, directory sees metadata
- Reliable fallback for restrictive networks
Channel Consistency: Once a channel is established for a session, all subsequent messages must use the same channel. This prevents session confusion attacks.
Handshake Protocol
There are two distinct handshake flows depending on whether the remote peer is a directory or a regular peer (maker):
Client -> Directory:
- Client sends HANDSHAKE (793) with client format (
"directory": false,"proto-ver","location-string") - Directory responds with DN_HANDSHAKE (795) with server format (
"directory": true,"proto-ver-min","proto-ver-max","accepted")
Taker -> Maker (Direct Connection):
- Taker connects to maker's onion service
- Both sides send HANDSHAKE (793) to each other with client format -- this is a symmetric exchange
- Both sides process the received handshake and mark the peer as handshaked
- Important: Makers must NOT send DN_HANDSHAKE (795) -- only directories use that message type. The reference implementation taker rejects DN_HANDSHAKE from non-directory peers.
Nick Format
Nicks are derived from ephemeral keypairs:
J + version + base58(sha256(pubkey)[:14])
Example: J54JdT1AFotjmpmH (16 chars, v5 peer)
This enables: - Anti-spoofing via message signatures - Nick recovery across message channels
Anti-Replay Protection: All private messages include <pubkey> <signature> fields. The signed plaintext includes hostid (directory onion or onion-network for direct) preventing replay across channels.
Multi-part Messages
- Unencrypted messages may contain multiple commands (split on
!) - Used for
!relofferand!absoffercombined announcements - NOT allowed for encrypted messages
Reference Implementation Compatibility
Orderbook Request Behavior:
- Reference orderbook watcher requests offers once at startup
- Our implementation requests on startup + periodically
- Makers should respond to every
!orderbookrequest
Stale Offer Filtering:
- Offers older than
max_offer_age(default 1 hour) are filtered - Maker disconnects are tracked for filtering
Known Directory Servers:
| Network | Type | Address |
|---|---|---|
| Mainnet | Reference | jmarketxf5wc4aldf3slm5u6726zsky52bqnfv6qyxe5hnafgly6yuyd.onion:5222 |
| Mainnet | JM-NG | nakamotourflxwjnjpnrk7yc2nhkf6r62ed4gdfxmmn5f4saw5q5qoyd.onion:5222 |
Maker Selection Algorithm
After collecting offers, the taker selects makers through three phases:
Phase 1 - Filtering: Remove offers that don't meet criteria (amount range, fee limits, offer type, ignored makers).
Phase 2 - Deduplication: If a maker advertises multiple offers under the same nick, only the cheapest offer is kept. This ensures makers cannot game selection by flooding the orderbook.
Phase 3 - Selection: Choose n makers from the deduplicated list using one of these algorithms:
| Algorithm | Behavior |
|---|---|
fidelity_bond_weighted (default) |
Mixed strategy: ~87.5% slots filled by bond-weighted selection, remaining slots randomly from all offers |
cheapest |
Lowest fee first |
weighted |
Exponentially weighted by inverse fee |
random |
Uniform random selection |
Key Point: Selection probability is proportional to the maker identity (nick), not the number of offers. A maker with 5 offers has the same selection probability as a maker with 1 offer (assuming both pass filters).
Maker Replacement on Non-Response:
When makers fail to respond, the taker can automatically select replacements instead of aborting:
- Configuration:
max_maker_replacement_attempts(default: 3, range: 0-10) - Failed makers added to ignored list for the session
- New makers go through the full fill/auth flow
- If not enough replacements available, CoinJoin aborts
Implementation: taker/src/taker/orderbook.py
Multi-Channel Message Deduplication
When connected to N directory servers, each message is received N times. The deduplication system prevents:
- Processing the same protocol message multiple times (expensive operations like
!auth,!tx) - Rate limiter counting duplicates as violations
- Log spam from duplicate messages
Message Fingerprinting: Messages identified by from_nick:command:first_arg:
- alice:fill:order123 - Fill request for order 123
- bob:pubkey:abc123 - Pubkey response
Time-Based Window: Duplicates within a 30-second window are dropped.
Implementation:
- Maker (
maker/bot.py): UsesMessageDeduplicatorto filter incoming messages - Taker (
taker/taker.py): UsesResponseDeduplicatorinwait_for_responses() - Orderbook: Uses
(counterparty, oid)as key for offer deduplication
Feature Flags System
This implementation uses feature flags instead of protocol version bumps to enable progressive capability adoption while maintaining backward compatibility.
Why feature flags?
- Reference implementation only accepts
proto-ver=5- version bumps would break interoperability - Features can be adopted independently without "all or nothing" upgrades
- Peers advertise what they support; both sides negotiate per-session
Available Features:
| Feature | Description |
|---|---|
extended_peerlist |
Supports extended peerlist format with feature flags in F: field |
neutrino_compat |
Supports extended UTXO format with scriptPubKey and blockheight |
Extended Peerlist Format:
nick;location;F:feature1+feature2
The + separator (not ,) avoids ambiguity since peerlist entries are comma-separated.
Neutrino Compatibility:
Extended UTXO format includes scriptPubKey + block height for verification:
| Format | Example |
|---|---|
| Legacy | txid:vout |
| Extended | txid:vout:scriptpubkey:height |
Handshake Integration:
{
"proto-ver": 5,
"features": {"extended_peerlist": true, "neutrino_compat": true}
}
The features dict is ignored by reference implementation but preserved for our peers.