Skip to content

Protocol specification

This page describes the messages joinstr peers exchange, as implemented by the reference Electrum plugin version 0.4.2. Version 0.4.1 sends the same messages. Use it when you build a client or debug a round.

The original design notes are in NIP.md in the main repository. Where NIP.md and this page differ, this page follows the code.

Transport

  • All events go to one relay, the relay named in the pool announcement.
  • Pool announcements are kind 2022 events with plain JSON content and no tags.
  • Every other message is a kind 4 event encrypted with NIP-04 and tagged ["p", <recipient pubkey>].
  • Message bodies are JSON as written by Python's json.dumps, with a space after : and ,. Some clients find message types by matching the text "type": "<name>", so keep that spacing.

Keys

Key Made by Used for
Pool key Creator, new for each pool Signs the announcement. Every pool message is signed with it and encrypted to it.
Join key Joiner, new for each join Signs the encrypted join_pool request
Credentials key Creator, new for each reply Signs the encrypted credentials sent to one joiner

The pool's private key is shared with every peer in the credentials. Holding it is what makes someone a member of the pool.

Pool announcement

Kind 2022, signed by the pool key.

{
  "type": "new_pool",
  "id": "a1b2c3d4e51791880000",
  "public_key": "<pool pubkey, 32 byte hex>",
  "denomination": 0.001,
  "peers": 5,
  "timeout": 1791880600,
  "relay": "wss://nos.lol",
  "fee_rate": 3.2,
  "transport": "tor",
  "vpn_gateway": ""
}
Field Type Meaning
type string Always new_pool
id string 10 random hex characters followed by the creation time in unix seconds
public_key string Pool public key. Must equal the event's pubkey.
denomination number Denomination in BTC
peers integer Number of peers, at least 2
timeout integer Unix time when the pool expires
relay string wss:// or ws:// URL that carries all pool traffic
fee_rate number Fee rate in sat/vB, may have decimals
transport string tor or vpn
vpn_gateway string Riseup gateway host for vpn pools, empty otherwise
autct object Optional, see aut-ct

Clients ignore an announcement whose event is not signed by public_key, that has expired or whose values are out of range.

Join request

Kind 4 from a new join key to the pool key.

{"type": "join_pool"}

For an aut-ct pool it also carries autct_proof and autct_token.

Credentials

Kind 4 from a new key to the joiner's join key. The body repeats every announced field and adds the pool private key.

{
  "id": "a1b2c3d4e51791880000",
  "public_key": "<pool pubkey>",
  "denomination": 0.001,
  "peers": 5,
  "timeout": 1791880600,
  "relay": "wss://nos.lol",
  "private_key": "<pool private key, 32 byte hex>",
  "fee_rate": 3.2,
  "transport": "tor",
  "vpn_gateway": ""
}

A joiner accepts credentials only if private_key derives public_key and every other field matches the announcement, including any autct block.

The creator can answer a join request with a rejection instead.

{"type": "reject", "reason": "missing_proof"}

Reasons are missing_proof, invalid_proof and duplicate_token.

Pool messages

All of the following are kind 4 events signed by the pool key and encrypted to the pool key, so every member can read them.

Output

{"address": "bc1q...", "type": "output"}

One per peer, each sent on a new Tor circuit for tor pools. Output registration is complete when there are peers distinct addresses.

Input

{"psbt": "<base64 PSBT>", "type": "input"}

The PSBT contains every registered output and exactly one input, the sender's.

  • Version 2, locktime 0, input nSequence 0xfffffffe.
  • Outputs in BIP69 order, by amount and then by scriptPubKey bytes. Sorting by address string is not the same.
  • Every output has the amount from the fee formula.
  • The input is signed with SIGHASH_ALL|ANYONECANPAY (0x81) and finalized. For native segwit inputs, include an empty final scriptSig (PSBT field 0x07) so that Electrum treats the input as complete.
  • BIP32 derivation paths are removed.

A peer collects at most peers inputs.

Combination

Each peer merges all input PSBTs into one transaction with inputs in BIP69 order by (txid, vout) and the shared outputs, then runs the checks and broadcasts it.

Reregister

Sent in the recovery phase by peers whose input was collected.

{
  "type": "reregister",
  "address": "bc1q...",
  "sig": "<base64 of json.dumps({\"Y0\": ..., \"S\": [...], \"C\": ...})>",
  "key_image": "<Y0>",
  "pool_id": "a1b2c3d4e51791880000"
}

sig is a linkable ring signature (LSAG) over the sorted hex public keys of all collected inputs and the message SHA256(address + pool_id). Peers drop duplicate key images. Ring public keys are taken from the input witnesses, so only P2WPKH inputs can take part.

Resign

{"type": "resign", "psbt": "<base64 PSBT>"}

The recovery transaction holds the collected inputs and the re-registered outputs, both in BIP69 order. It is signed with SIGHASH_ALL. Its fee assumes 100 × outputs + 68 × inputs + 100 vbytes and the denomination is truncated to whole sats.

Amounts

output = round(denomination × 100,000,000) − floor(fee_rate × (100 × peers)) ÷ peers

A joiner's input must be worth between the denomination plus 500 and the denomination plus 5000 sats. The joiner refuses to sign if input − output exceeds 15000 sats, if the output is below the dust limit of any output type in the pool or if the input alone covers every output.

aut-ct

An aut-ct pool adds this block to the announcement.

"autct": {
  "keyset": "autct-830000-100000-1-2-1024.aks",
  "min_amount": 100000,
  "min_confirmations": 1,
  "script_type": "p2tr"
}

The creator's own proof is included in the block as autct_proof and autct_token. The proof context is the hex of SHA256(id + public_key). The verifier returns a key image for each proof and the creator rejects a key image that was already used.

Timeouts

Step Behaviour
Waiting for joins, outputs or inputs Poll the relay every 5 seconds, up to 360 times
Recovery start Only when input polling ends with some but not all inputs
Reregister and resign 120 seconds each. The round is abandoned unless every ring member re-registers.
Pool expiry timeout in the announcement

Versioning

Since 0.4.1 the plugin rejects announcements that are not signed by the pool key, so it cannot join pools made by 0.4.0. Clients should treat the format on this page as the current one.