Protocol
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 |
| 798 | 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) |
Per-slot coin flip: each slot independently picks bonded (weighted) or uniform-random based on bondless_makers_allowance |
cheapest |
Lowest fee first |
weighted |
Exponentially weighted by inverse fee |
random |
Uniform random selection |
Fidelity bond selection details (fidelity_bond_weighted):
The default algorithm uses a per-slot Bernoulli trial (matching the reference JoinMarket implementation):
- Pre-filter: When
bondless_require_zero_feeis enabled (default), bondless offers (no fidelity bond) that charge a non-zero absolute fee are removed. This prevents attackers from flooding the orderbook with fee-charging bondless offers. - Per-slot selection: For each of the
nslots independently: - With probability
bondless_makers_allowance(default 0.2): pick uniformly at random from all remaining offers (bonded and bondless compete equally). - Otherwise: pick from the bonded pool weighted by
fidelity_bond_value. - Fallback: If the chosen pool is empty, the other pool is tried, then uniform random.
This design ensures that when few bondless makers exist, each has naturally low individual selection probability (~allowance / total_offers per slot), avoiding taker fingerprinting. When many bondless zero-fee makers are available, roughly n * bondless_makers_allowance appear in the final set (e.g., 2 out of 10 with 20% allowance). The uniform-random slots also benefit smaller bonded makers.
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 |
|---|---|
peerlist_features |
Supports extended peerlist format with feature flags in F: field |
ping |
Supports application-level PING/PONG heartbeat liveness checks |
neutrino_compat |
Can provide extended UTXO format with scriptPubKey and blockheight for own UTXOs |
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 |
Both full-node and neutrino joinmarket-ng makers advertise neutrino_compat because both can provide metadata for their own wallet UTXOs. Neutrino takers require this feature to verify maker UTXOs via compact block filters. Reference implementation makers do not advertise features, so neutrino takers filter them out during the auth phase.
Handshake Integration:
{
"proto-ver": 5,
"features": {"peerlist_features": true, "ping": true, "neutrino_compat": true}
}
The features dict is ignored by reference implementation but preserved for our peers.
Heartbeat and Idle Eviction
Directory servers run an application-level heartbeat to detect stale Tor connections that remain open but unresponsive.
- Every peer message updates
last_seen - On each heartbeat sweep, peers idle past
heartbeat_idle_thresholdare probed - Ping-capable peers receive
PING(type=798) and must answer withPONG(type=799) withinheartbeat_pong_wait - Non-ping makers receive a unicast
!orderbookprobe as a compatibility fallback - Peers idle past
heartbeat_hard_evictare evicted unconditionally
Defaults match joinmarket-rs behavior for interoperability:
- Sweep interval: 60s
- Idle probe threshold: 600s (10 min)
- Hard eviction: 1500s (25 min)
- PONG wait: 30s