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
relaynamed in the pool announcement. - Pool announcements are kind
2022events with plain JSON content and no tags. - Every other message is a kind
4event 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.
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.
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¶
One per peer, each sent on a new Tor circuit for tor pools. Output registration is complete when there are peers distinct addresses.
Input¶
The PSBT contains every registered output and exactly one input, the sender's.
- Version 2, locktime 0, input
nSequence0xfffffffe. - Outputs in BIP69 order, by amount and then by
scriptPubKeybytes. 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 finalscriptSig(PSBT field0x07) 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¶
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¶
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.