# Protocol specification

This page describes the messages joinstr peers exchange, as implemented by the reference [Electrum plugin](../clients/electrum.md) 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](https://gitlab.com/invincible-privacy/joinstr/-/blob/main/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](https://github.com/nostr-protocol/nips/blob/master/04.md) 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.

```json
{
  "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](#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.

```json
{"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.

```json
{
  "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.

```json
{"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

```json
{"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

```json
{"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](https://github.com/bitcoin/bips/blob/master/bip-0069.mediawiki) 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](#amounts).
- 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](../how-it-works/security.md#checks-before-broadcast) and broadcasts it.

### Reregister

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

```json
{
  "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

```json
{"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.

```json
"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.
