# joinstr
joinstr is a coinjoin implementation for bitcoin that uses [nostr](https://github.com/nostr-protocol/nostr) relays for coordination. There is no central coordinator. Anyone can create a pool and anyone can join one. Every participant checks and broadcasts the final transaction on their own.
A pool brings together a fixed number of peers who each put in one coin of about the same value and get back one output of exactly the pool denomination at a fresh address. Inputs are signed with `SIGHASH_ALL|ANYONECANPAY`, which lets each peer sign their own input before seeing the others and keeps coordination to a few simple steps.
joinstr started as a proof of concept in August 2022. Today it has an Electrum plugin, a web UI available in the Umbrel app store, a REST daemon with a CLI, an Android app, a Sparrow Wallet fork and a Rust library with Dart bindings.
- :material-rocket-launch: **[Quickstart](https://docs.joinstr.xyz/getting-started/quickstart.md)**
Pick a client and make your first coinjoin.
- :material-cog-outline: **[How it works](https://docs.joinstr.xyz/how-it-works/index.md)**
Pools, rounds, signatures and what each peer checks.
- :material-file-document-outline: **[Protocol](https://docs.joinstr.xyz/protocol/index.md)**
Event kinds, message formats and validation rules.
- :material-robot-outline: **[For LLMs and agents](https://docs.joinstr.xyz/llms.md)**
`llms.txt`, markdown sources and a compact brief.
## Clients
| Client | Platform | Wallet backend | Latest release |
|---|---|---|---|
| [Electrum plugin](https://docs.joinstr.xyz/clients/electrum.md) | Linux, macOS, Windows | Electrum server | 0.4.2 |
| [Web UI](https://docs.joinstr.xyz/clients/web-ui.md) | Browser, Umbrel | Bitcoin Core | Umbrel app 0.1.2 |
| [Daemon and CLI](https://docs.joinstr.xyz/clients/daemon-cli.md) | Any system with Python 3 | Bitcoin Core | in repository |
| [Android app](https://docs.joinstr.xyz/clients/android.md) | Android 7.0 or newer | Bitcoin Core | 0.3.0 |
| [Sparrow](https://docs.joinstr.xyz/clients/sparrow.md) | Linux, Windows | Any Sparrow server | 0.2.0 |
| [Rust and Dart library](https://docs.joinstr.xyz/clients/rust.md) | Library | Electrum server | in repository |
## Key properties
- **No coordinator.** Pools are announced and run over ordinary nostr relays. A pool uses one relay, chosen by its creator.
- **One denomination per pool.** Every output in the coinjoin has the same value and there are no change outputs.
- **Tor by default.** The Electrum plugin, web UI, daemon, Android app and Sparrow send relay traffic through Tor on separate circuits, so a relay cannot link a peer's input to their output by IP address.
- **Fresh keys.** Each pool has its own nostr key pair and each joining peer uses a new one, so nothing ties a pool to a long lived nostr identity.
- **Optional sybil resistance.** The Electrum plugin and Sparrow can require peers to prove ownership of a coin in a published set with [aut-ct](https://docs.joinstr.xyz/how-it-works/sybil-resistance.md), without revealing which coin.
- **No fees to anyone but miners.** Peers share the mining fee. A paid relay may charge its own fee.
## Links
- [joinstr.xyz](https://joinstr.xyz), the project website
- [gitlab.com/invincible-privacy](https://gitlab.com/invincible-privacy), the source code
- [forum.joinstr.xyz](https://forum.joinstr.xyz), the community forum
- [mutinynet.joinstr.xyz](https://mutinynet.joinstr.xyz), live test pools on Mutinynet
---
# Quickstart
This page gets you from nothing to a finished coinjoin. Try it on a test network first. [Mutinynet](https://docs.joinstr.xyz/getting-started/mutinynet.md) has a public pool every 30 minutes and signet works with any client.
## 1. Choose a client
| If you use | Start with |
|---|---|
| Electrum on a desktop | [Electrum plugin](https://docs.joinstr.xyz/clients/electrum.md) |
| An Umbrel node | [Web UI from the Umbrel app store](https://docs.joinstr.xyz/clients/web-ui.md#umbrel) |
| Bitcoin Core on a server | [Daemon and CLI](https://docs.joinstr.xyz/clients/daemon-cli.md) or the [web UI](https://docs.joinstr.xyz/clients/web-ui.md) |
| An Android phone and your own node | [Android app](https://docs.joinstr.xyz/clients/android.md) |
| Sparrow Wallet | [Sparrow fork](https://docs.joinstr.xyz/clients/sparrow.md) |
| Your own wallet software | [Rust and Dart library](https://docs.joinstr.xyz/clients/rust.md) |
The Electrum plugin, the daemon and a self hosted web UI need Tor running locally as a SOCKS5 proxy, usually on `127.0.0.1:9050`. The Android app and the Umbrel app start their own Tor.
## 2. Prepare a coin
A pool has one denomination. Every peer puts in one confirmed coin and gets back one output of the denomination minus a share of the mining fee. There is no change output, so the coin you put in must be close to the denomination.
The Electrum plugin, the web UI and the Android app accept a coin worth between **denomination + 500 sats** and **denomination + 5000 sats**. For a 0.001 BTC pool that is a coin between 100,500 and 105,000 sats. The CLI allows up to denomination + 15000 sats. Anything above your output goes to the mining fee, so pick a coin near the low end.
To make such a coin, send the exact amount to a new address in your own wallet and wait for one confirmation. Use coin control so that the coin you will mix is not merged with other coins.
## 3. Create or join a pool
- **Join** if a pool with your denomination is open. Open the list of other pools, pick one and click **Join**.
- **Create** if there is none. Enter the denomination and the number of peers and click **Create**. The pool stays open for 10 minutes in the Electrum plugin GUI.
Once enough peers have joined, the client registers a fresh output address for you, asks you to pick your coin and signs it. When every peer has signed, each client checks the full transaction and broadcasts it.
## 4. After the coinjoin
Your new output has the same value as every other output in the transaction. Treat it like any other coin with privacy in mind.
- Do not merge it with coins from before the coinjoin.
- Do not send it back to an address you used before.
- More rounds with new peers add more privacy.
## Next steps
- Learn the terms in [Concepts](https://docs.joinstr.xyz/getting-started/concepts.md).
- Read [How it works](https://docs.joinstr.xyz/how-it-works/index.md) to see what each peer checks.
- Check the [FAQ](https://docs.joinstr.xyz/faq.md) for common questions.
---
# Concepts
The terms below are used across the docs and the clients.
**Pool**
: A single coinjoin round. A pool has an id, a denomination, a number of peers, a relay, a fee rate, a transport and an expiry time. The creator publishes it as a nostr event and the pool ends when the coinjoin is broadcast or the pool expires.
**Pool creator**
: The peer who announces the pool. The creator makes a new nostr key pair for the pool and hands its private key to every peer who joins. Apart from that the creator is an ordinary peer.
**Peer**
: A participant in a pool. Each peer contributes exactly one input and receives exactly one output.
**Denomination**
: The value every output is based on, set in BTC by the creator. Each output is the denomination minus an equal share of the mining fee.
**Relay**
: The nostr relay that carries all traffic for a pool. The creator picks it and it is part of the announcement. Any relay works, free or paid.
**Transport**
: How a peer hides their IP address from the relay. `tor` is the default. The Electrum plugin also offers `vpn`, where every peer connects through the same Riseup VPN gateway.
**Credentials**
: The message the creator sends to each peer who asks to join. It repeats the pool terms and includes the pool's private key, which every peer uses to read and write pool messages.
**Output registration**
: The first phase of a round. Every peer sends a fresh address for their output.
**Input registration**
: The second phase. Every peer builds a transaction with all registered outputs and their own single input, signs that input with `SIGHASH_ALL|ANYONECANPAY` and sends it as a PSBT.
**Finalization**
: Each peer combines all signed inputs into one transaction, checks it and broadcasts it. Because every peer does this, the coinjoin goes out even if some peers go offline after signing.
**Recovery**
: If some peers register an output but never sign an input, the round would stall. The peers who did sign re-register their output addresses with a ring signature over the signed inputs, which proves each address belongs to one of them without saying which. They then sign a smaller transaction with `SIGHASH_ALL` that leaves the missing peers out. See [How it works](https://docs.joinstr.xyz/how-it-works/index.md#recovery-when-peers-drop-out).
**aut-ct**
: Anonymous usage tokens from curve trees, a proof that you own a coin from a published set without saying which coin. A pool can require it to make flooding the pool with fake peers expensive. See [Sybil resistance](https://docs.joinstr.xyz/how-it-works/sybil-resistance.md).
**Keyset**
: The published set of coins that aut-ct proofs refer to. It is a file named `autct-----.aks`.
**SIGHASH_ALL|ANYONECANPAY**
: A signature hash type (`0x81`). The signature commits to all outputs and to the one input being signed, but not to the other inputs. It lets every peer sign before the other inputs are known.
---
# Try it on Mutinynet
[Mutinynet](https://mutinynet.com) is a signet with 30 second blocks and a public faucet. A bot runs a joinstr pool there every 30 minutes, so you can watch real coinjoins happen and take part in one without risking real coins.
Live status and history are at **[mutinynet.joinstr.xyz](https://mutinynet.joinstr.xyz)**.
## What the bot does
- Every 30 minutes it announces a pool with 5 to 15 peers and a different denomination each time.
- The pool is announced on `wss://nos.lol` and runs on `wss://relay.primal.net` with the `tor` transport.
- The pool stays open to other joinstr users for 5 minutes. After that the bot fills the empty slots with its own peers and completes the coinjoin.
- After each coinjoin it posts the pool details and the transaction id as a nostr note from `npub10rqusvwafsp4f3g8yaz3fvlng2lzufls294xzn4dd7rtc6nf2hus3uzw52`.
The bot is built on the [Rust library](https://docs.joinstr.xyz/clients/rust.md) and runs on Cloudflare Workers.
## Join a bot pool with Electrum
You need Electrum 4.6.2 or newer for Mutinynet support and the [Electrum plugin](https://docs.joinstr.xyz/clients/electrum.md) installed.
1. Start Electrum on Mutinynet with Tor running.
```sh
electrum --mutinynet
```
2. In **Tools > Network > Proxy**, enable a SOCKS5 proxy on `127.0.0.1:9050`.
3. Create a wallet and get coins from the [Mutinynet faucet](https://faucet.mutinynet.com).
4. Check [mutinynet.joinstr.xyz](https://mutinynet.joinstr.xyz) for the next pool. Once the bot has prepared it, the page shows its denomination and number of peers.
5. Send yourself a coin worth the denomination plus 500 to 5000 sats and wait for it to confirm. Blocks come every 30 seconds.
6. In the plugin settings, leave the relay at its default `wss://nos.lol` so the plugin sees the announcement.
7. When the pool is announced, open the **Coinjoin** tab, click **Other Pools**, select the pool and click **Join**. The plugin switches to the pool's own relay by itself.
The round finishes within a few minutes of the bot filling the pool. Your output shows up in the wallet history and on the [Mutinynet explorer](https://mutinynet.com).
## Run your own test pool
Any client works on signet or regtest too. Create a pool in one wallet, then join it from a second wallet or a second client on the same network. A pool needs at least two peers.
---
# Clients
joinstr has several independent clients. Pick the one that matches the wallet you already use.
| Client | Platform | Wallet backend | Transports | aut-ct | Source |
|---|---|---|---|---|---|
| [Electrum plugin](https://docs.joinstr.xyz/clients/electrum.md) | Linux, macOS, Windows | Electrum server | Tor, VPN | Yes | [invincible-privacy/joinstr](https://gitlab.com/invincible-privacy/joinstr/-/tree/main/plugin) |
| [Web UI](https://docs.joinstr.xyz/clients/web-ui.md) | Browser, Umbrel | Bitcoin Core | Tor | No | [invincible-privacy/joinstr](https://gitlab.com/invincible-privacy/joinstr/-/tree/main/gui) |
| [Daemon and CLI](https://docs.joinstr.xyz/clients/daemon-cli.md) | Python 3 | Bitcoin Core | Tor | No | [invincible-privacy/joinstr](https://gitlab.com/invincible-privacy/joinstr/-/tree/main/cli) |
| [Android app](https://docs.joinstr.xyz/clients/android.md) | Android 7.0 or newer | Bitcoin Core | Tor, VPN | No | [invincible-privacy/joinstr-kmp](https://gitlab.com/invincible-privacy/joinstr-kmp) |
| [Sparrow](https://docs.joinstr.xyz/clients/sparrow.md) | Linux, Windows | Any Sparrow server | Tor | Yes | [QcMrHyde/sparrow-joinstr](https://github.com/QcMrHyde/sparrow-joinstr) |
| [Rust and Dart library](https://docs.joinstr.xyz/clients/rust.md) | Library | Electrum server | Optional SOCKS5 | No | [rust-joinstr/joinstr](https://github.com/rust-joinstr/joinstr) |
The Electrum plugin is the reference implementation. The [protocol page](https://docs.joinstr.xyz/protocol/index.md) describes what it sends and checks.
## Release signing
Releases of the Electrum plugin and the Android app are signed with the PGP key of /dev/fd0.
```
9253 062A 4F92 D634 5908 5CA6 2D23 0520 212A 5901
```
Fetch it from keys.openpgp.org.
```sh
gpg --keyserver hkps://keys.openpgp.org --recv-keys 9253062A4F92D63459085CA62D230520212A5901
```
Each client page shows how to verify its download.
---
# Electrum plugin
The Electrum plugin adds a **Coinjoin** tab to [Electrum](https://electrum.org). It is the reference implementation of joinstr and the only client with every feature, including the VPN transport, aut-ct sybil resistance and headless use through the Electrum daemon.

| | |
|---|---|
| Latest version | 0.4.2 |
| Requires | Electrum 4.6.0 or newer, Tor |
| Source | [gitlab.com/invincible-privacy/joinstr/-/tree/main/plugin](https://gitlab.com/invincible-privacy/joinstr/-/tree/main/plugin) |
| Release notes | [doc/release-notes/plugin](https://gitlab.com/invincible-privacy/joinstr/-/tree/main/doc/release-notes/plugin) |
## Requirements
- **Electrum 4.6.0 or newer.** The plugin uses the plugin API that Electrum introduced in 4.6.0. Mutinynet needs 4.6.2 or newer.
- **Any Electrum build.** Since 0.4.2 the plugin works in the AppImage, the Windows and macOS binaries and Electrum run from Python sources. Earlier versions needed the `coincurve` package and only loaded in Electrum run from Python sources.
- **Tor.** The plugin refuses to talk to a relay without a SOCKS5 proxy, unless you create a VPN pool. Tor Browser provides one on port 9150 and the `tor` service on port 9050.
aut-ct pools also need the `websockets`, `autctapi` and `argon2-cffi` packages, which the Electrum binaries do not include, plus the [aut-ct](https://github.com/AdamISZ/aut-ct) binary. Use Electrum run from Python sources for them. See [aut-ct pools](#aut-ct-pools).
## Install
1. Download `joinstr.zip` and `joinstr.zip.asc` for the latest version from [plugin/zip/v0.4.2](https://gitlab.com/invincible-privacy/joinstr/-/tree/main/plugin/zip/v0.4.2).
2. Verify the signature.
```sh
gpg --keyserver hkps://keys.openpgp.org --recv-keys 9253062A4F92D63459085CA62D230520212A5901
gpg --verify joinstr.zip.asc joinstr.zip
```
The output must say `Good signature from "/dev/fd0 "`.
3. In Electrum, open **Tools > Plugins** and click **Add**. Select `joinstr.zip`.
4. The first time you add an external plugin, Electrum asks you to set up plugin authorization. Follow its prompts.
5. Enable **joinstr** in the plugin list. The **Coinjoin** tab appears.
To install from source instead, copy the contents of `plugin/` into a `joinstr` directory inside Electrum's plugins directory.
## Set up Tor
Open **Tools > Network > Proxy**, enable the proxy, choose SOCKS5 and enter `127.0.0.1` with port `9050` (or `9150` for Tor Browser).
The plugin opens a new Tor circuit for every nostr operation by sending a random SOCKS username. Relays on `localhost` are reached directly.
## Settings
Open **Tools > Plugins**, click **joinstr** and then **Settings**.
{ width="322" }
| Setting | Config key | Default |
|---|---|---|
| Nostr Relay | `plugins.joinstr.relay` | `wss://nos.lol` |
| Pool Denomination (BTC) | `plugins.joinstr.pool_denomination` | `0.001` |
| Number of peers | `plugins.joinstr.num_peers` | `3` |
| VPN Gateway | `plugins.joinstr.vpn_gateway` | first Riseup gateway |
| aut-ct API URL | `plugins.joinstr.autct_api_url` | `ws://127.0.0.1:23333` |
| aut-ct binary | `plugins.joinstr.autct_binary` | none |
The relay list offers the relays Electrum already knows. You can type any `wss://` URL. The relay you set here is where the plugin looks for pools and where your own pools run. When you join someone else's pool, the plugin uses the relay that pool names.
## Create a pool
1. Make sure you have a confirmed coin worth the denomination plus 500 to 5000 sats.
2. Open the **Coinjoin** tab.
3. Enter the **Denomination** in BTC and the number of **Peers**.
4. Leave **Tor** selected. Choose **VPN** only if every peer should share a Riseup gateway.
5. Leave **Keyset String** and **Proving Key** empty unless you want an [aut-ct pool](#aut-ct-pools).
6. Click **Create**.
The pool is announced on your relay and stays open for 10 minutes. You can also right click a confirmed coin in the **Coins** tab (**View > Show Coins**) and choose **Coinjoin > Create Pool**.
## Join a pool
1. Click **Other Pools**. The plugin lists open pools on your relay with their denomination, number of peers, time left, requirements and relay.
2. Select a pool and click **Join**. Right clicking a coin and choosing **Coinjoin > Join Pool** finds pools that fit that coin.
3. The plugin sends the join request and shows a **Relay Payment** QR code with your new one time nostr public key. If the pool's relay charges for writes, use it to pay the relay. For a free relay, or one you have already paid, click **Continue**.
## During the round
The rest happens on its own.
1. The plugin registers a fresh address from your wallet as your output.
2. When every peer has registered an output, it shows the coins that fit the pool. Pick one. The plugin signs it with `SIGHASH_ALL|ANYONECANPAY`.
3. It collects every peer's signed input, checks the transaction and broadcasts it through your Electrum server.
Before signing, the plugin refuses a round that would cost you more than 15000 sats or produce an output below the dust limit. Before broadcasting, it checks that all outputs are equal, that your output is present and that the fee is between 100 and 10000 sats per peer. See [Security model](https://docs.joinstr.xyz/how-it-works/security.md) for the full list.
Pools you created resume after Electrum restarts. **My Pools** lists them.
## Command line
The plugin adds four commands to the Electrum daemon. Enable the plugin and the Tor proxy, start the daemon and load a wallet first.
```sh
electrum --offline setconfig plugins.joinstr.enabled true
electrum --offline setconfig proxy "socks5:127.0.0.1:9050::"
electrum daemon -d
electrum load_wallet
```
| Command | What it does |
|---|---|
| `electrum joinstr_list_pools` | List open pools on the configured relay |
| `electrum joinstr_my_pools` | List pools created by the loaded wallet |
| `electrum joinstr_create_pool ` | Create a pool and put in the given coin |
| `electrum joinstr_join_pool ` | Join a pool with the given coin |
`joinstr_create_pool` also takes `--timeout ` (default 3600), `--transport tor|vpn` (default `tor`), `--keyset ` and `--proving_key `. `joinstr_join_pool` also takes `--proving_key `.
Pass the aut-ct proving key as a path to a file, or set the `JOINSTR_PROVING_KEY` environment variable. A key typed on the command line can be read from the process list.
One daemon runs one coinjoin wallet. It can take part in several pools at once.
## VPN pools
A pool created with the VPN transport names a Riseup gateway. Every peer who joins connects to that gateway, so all peers share one exit IP address for the round. The plugin fetches the gateway list and certificates from Riseup and starts `openvpn` through `pkexec`, so it needs `openvpn` installed and permission to run it.
VPN pools are optional and only supported by the Electrum plugin and the Android app. Tor is the default and the recommended transport.
## aut-ct pools
A pool can require each peer to prove they own a coin from a published keyset without revealing which one. See [Sybil resistance](https://docs.joinstr.xyz/how-it-works/sybil-resistance.md) for how it works.
You need the following.
- The `autct` binary from [AdamISZ/aut-ct](https://github.com/AdamISZ/aut-ct). Set its path in **aut-ct binary** and the plugin starts and stops an aut-ct server for each pool. Without it, run your own server and set **aut-ct API URL**.
- The keyset file, named `autct-----.aks`, in `/autct_keysets/`. Every peer needs the same file.
- A taproot proving key as a WIF, for a coin that is in the keyset. The coin is never spent. Electrum cannot export single key taproot keys, so the key comes from outside the wallet.
- A normal coin for the pool, as for any other pool.
To create an aut-ct pool, put the keyset file name in **Keyset String** and the proving key in **Proving Key**. To join one, click **Join** and enter the proving key when asked.
!!! warning "Known aut-ct issue"
The latest `autct` release, v0.1.4, cannot prove ownership of a taproot coin whose output key has an odd y coordinate. The fix is on the aut-ct `master` branch but not yet in a release. Build `autct` from `master`, or use a proving coin whose output key has an even y coordinate.
## Upgrading
0.4.2 sends the same messages as 0.4.1, so peers on either version can share a pool.
Pools created by 0.4.0 cannot be joined from 0.4.1 or later, because 0.4.0 announcements are not signed by the pool key. Finish or abandon such pools before you upgrade.
## Troubleshooting
**The plugin does not load.**
Electrum's log shows `No module named 'coincurve'`. You have 0.4.1 or older, which does not load in the Electrum binaries. Install 0.4.2.
**"Tor proxy required".**
Set a SOCKS5 proxy in **Tools > Network > Proxy** and check that Tor is running.
**No pools in Other Pools.**
Pools are only listed on the relay in your settings. Ask the pool creator which relay they use, or create a pool yourself.
**"Selected input is not within the required range".**
The coin must be worth the denomination plus 500 to 5000 sats. Send yourself a coin of the right size.
---
# Web UI and Umbrel
The web UI is a small Flask app that runs joinstr against a Bitcoin Core wallet. It is the app behind joinstr in the Umbrel app store and it can also be run on any machine with Python and a Bitcoin Core node.

| | |
|---|---|
| Umbrel app | [apps.umbrel.com/app/joinstr](https://apps.umbrel.com/app/joinstr), version 0.1.2 |
| Requires | Bitcoin Core with RPC, Tor, Python 3 |
| Transport | Tor only |
| Source | [gitlab.com/invincible-privacy/joinstr/-/tree/main/gui](https://gitlab.com/invincible-privacy/joinstr/-/tree/main/gui) |
## Umbrel
1. Install **Bitcoin Node** and then **Joinstr** from the Umbrel app store.
2. Open Joinstr. It connects to your node by itself, starts its own Tor and uses a Bitcoin Core wallet named `joinstr`, which it creates on first start.
3. Fund the `joinstr` wallet with a coin worth the pool denomination plus 500 to 5000 sats. Any tool that talks to your node works, for example `bitcoin-cli -rpcwallet=joinstr getnewaddress`.
4. Use **Create Pool**, **My Pools** and **Other Pools** as described below.
On Umbrel the settings page only shows the nostr relay. The node connection is managed by Umbrel.
## Run it yourself
You need Bitcoin Core with RPC enabled, a wallet with a coin of the right size and Tor listening on `127.0.0.1:9050`.
```sh
git clone https://gitlab.com/invincible-privacy/joinstr.git
cd joinstr
pip install -r gui/requirements.txt
python gui/joinstr-web.py --port 5000 --wallet
```
Open `http://127.0.0.1:5000`, go to **Settings** and enter the RPC URL, user name and password of your node and the nostr relay.
| Option | Default | Meaning |
|---|---|---|
| `--port` | `5000` | Port to listen on |
| `--host` | `127.0.0.1` | Address to bind |
| `--wallet` | node default | Bitcoin Core wallet to use |
| `--pools-file` | `pools.json` in the data directory | Where pools are stored |
| `--debug` | off | Flask debug mode, refused unless bound to localhost |
| Environment variable | Default | Meaning |
|---|---|---|
| `BITCOIN_RPC_URL` | `http://127.0.0.1:38332` | Node RPC URL |
| `BITCOIN_RPC_USER` | `user` | RPC user name |
| `BITCOIN_RPC_PASS` | `pass` | RPC password |
| `TOR_PROXY` | `127.0.0.1:9050` | Tor SOCKS5 proxy |
| `JOINSTR_DATA_DIR` | the `gui` directory | Where pools and sessions are stored |
| `JOINSTR_MANAGED` | unset | When `1`, RPC settings come only from the environment |
The default RPC port is the signet port. Point `BITCOIN_RPC_URL` at port 8332 for mainnet. An RPC URL typed on the **Settings** page must point at `127.0.0.1` or `localhost`, so that the page cannot be used to send your RPC password elsewhere.
The web UI never falls back to a direct connection. If Tor is not reachable, relay requests fail.
## Docker
The container build lives in [contrib/docker](https://gitlab.com/invincible-privacy/joinstr/-/tree/main/contrib/docker). The image runs Tor inside the container, creates or loads the wallet named in `JOINSTR_WALLET` (default `joinstr`) and serves the UI on port 5000.
```sh
cd contrib/docker
docker build --build-arg JOINSTR_REF=$(git rev-parse HEAD) -t joinstr-web:dev .
docker run --rm -p 127.0.0.1:5000:5000 \
-e JOINSTR_MANAGED=1 -e BITCOIN_RPC_URL=http://:8332 \
-e BITCOIN_RPC_USER= -e BITCOIN_RPC_PASS= \
joinstr-web:dev
```
## Using the web UI
- **Create Pool** asks for the denomination in BTC, the number of peers (2 to 10) and a timeout in seconds (60 to 3600).

- **Other Pools** lists open pools on your relay. Click **Join** on one.
- **My Pools** lists pools you created or joined. A pool interrupted by a restart can be resumed from here.
After joining, the UI waits for credentials, registers a new address from your wallet as your output, then asks you to pick a coin in the required range. It signs that coin with `SIGHASH_ALL|ANYONECANPAY`, waits for the other peers and finalizes the coinjoin. It broadcasts through your node.
---
# Daemon and CLI
`joinstrd` runs joinstr in the background against a Bitcoin Core wallet and exposes a REST API on localhost. `joinstr-cli` is a command line client for that API. Together they let you run coinjoins on a headless server or build joinstr into other software over HTTP.
| | |
|---|---|
| Requires | Python 3, Bitcoin Core with RPC, Tor |
| Transport | Tor only |
| Source | [gitlab.com/invincible-privacy/joinstr/-/tree/main/cli](https://gitlab.com/invincible-privacy/joinstr/-/tree/main/cli) |
## Install
```sh
git clone https://gitlab.com/invincible-privacy/joinstr.git
cd joinstr
pip install -r cli/requirements.txt
mkdir -p ~/.joinstr
cp cli/config.example.ini ~/.joinstr/config.ini
chmod 600 ~/.joinstr/config.ini
```
## Configure
`~/.joinstr/config.ini` holds the node connection, the relay and the Tor proxy. The daemon warns if other users can read the file.
```ini
[rpc]
url = http://127.0.0.1:38332
user =
password =
wallet =
[nostr]
relay = wss://nos.lol
[tor]
host = 127.0.0.1
port = 9050
```
The example uses the signet RPC port. Use `8332` for mainnet.
Tor is required. At start the daemon checks through the proxy that Tor works and exits if it does not. All relay and mempool.space traffic goes through Tor.
| Environment variable | Default | Meaning |
|---|---|---|
| `JOINSTR_DIR` | `~/.joinstr` | Directory with `config.ini` and state |
| `JOINSTRD_HOST` | `127.0.0.1` | Address the daemon binds |
| `JOINSTRD_PORT` | `8000` | Port the daemon listens on |
| `JOINSTR_API_URL` | `http://127.0.0.1:8000` | Daemon URL used by the CLI |
## Run
```sh
python cli/joinstrd.py
```
The daemon only answers requests whose `Host` header is a loopback address. Interactive API docs are served at `http://127.0.0.1:8000/docs` and `/redoc`, with the schema at `/openapi.json`.
## CLI
```sh
python cli/joinstr-cli.py [options]
```
| Command | What it does |
|---|---|
| `create --denomination --peers [--timeout ]` | Create a pool. Timeout defaults to 3600 seconds. |
| `list [--my \| --others]` | List your pools or open pools on the relay |
| `join [--pool-id ]` | Join a pool |
| `output [--address ] [--pool-id ]` | Register an output if it was not done automatically |
| `input [--pool-id ] [--txid --vout ]` | Pick and sign the coin for the active pool |
| `history` | Show finished coinjoins |
| `stop` | Stop the current operation |
The CLI signs your input with your node (`walletprocesspsbt` with `ALL|ANYONECANPAY`) and hands the signed PSBT to the daemon. It reads the `[rpc]` section of the same `config.ini`.
### A typical round
```sh
# creator
python cli/joinstr-cli.py create --denomination 0.001 --peers 3
# every other peer
python cli/joinstr-cli.py list --others
python cli/joinstr-cli.py join --pool-id
# everyone, once all outputs are registered
python cli/joinstr-cli.py input --txid --vout
```
## REST API
All endpoints are on `http://127.0.0.1:8000`. The full schema is at `/openapi.json`.
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/status` | Daemon status |
| `GET` | `/pools/my` | Pools you created or joined |
| `GET` | `/pools/others` | Open pools on the relay |
| `POST` | `/pool/create` | Create a pool |
| `POST` | `/pool/join` | Join a pool |
| `GET` | `/pool/info` | Details of the active pool |
| `GET` | `/pool/status` | Phase of the active pool |
| `POST` | `/pool/register-output` | Register an output address |
| `GET` | `/pool/my-output` | Your registered output |
| `GET` | `/pool/outputs` | All registered outputs |
| `POST` | `/pool/register-input` | Register a signed input PSBT |
| `GET` | `/pool/my-input` | Your registered input |
| `GET` | `/pool/inputs` | All registered inputs |
| `GET` | `/history` | Finished coinjoins |
| `POST` | `/stop` | Stop the current operation |
`/pool/register-input` takes a PSBT that is already signed, so the key can live anywhere. A hardware or air gapped signer can sign the registration PSBT with `SIGHASH_ALL|ANYONECANPAY` and the daemon does the rest.
## Fees and broadcast
- The pool fee rate comes from mempool.space (`hourFee`) over Tor, with 5 sat/vB as a fallback.
- A joiner refuses pools with a fee rate above 100 sat/vB and rounds that would cost more than 15000 sats.
- The final transaction is broadcast through your node. If the node rejects it for a reason other than its own policy, the daemon tries mempool.space over Tor on mainnet and signet.
---
# Android app
The joinstr app for Android is written in Kotlin Multiplatform. It uses a Bitcoin Core node you run yourself for coins, signing, fee estimates and broadcast. It ships its own Tor.

| | |
|---|---|
| Latest version | 0.3.0, released 2026-09-22 |
| Requires | Android 7.0 or newer, a Bitcoin Core node reachable from the phone |
| Networks | Mainnet, testnet4, signet, regtest (follows the node) |
| Transport | Tor (built in) or VPN |
| Source | [gitlab.com/invincible-privacy/joinstr-kmp](https://gitlab.com/invincible-privacy/joinstr-kmp) |
## Download and verify
Download from the [releases page](https://gitlab.com/invincible-privacy/joinstr-kmp/-/releases).
- [`Joinstr_v0.3.0_release.apk`](https://gitlab.com/invincible-privacy/joinstr-kmp/-/raw/v0.3.0/releases/Joinstr_v0.3.0_release.apk)
- [`Joinstr_v0.3.0_release.apk.asc`](https://gitlab.com/invincible-privacy/joinstr-kmp/-/raw/v0.3.0/releases/Joinstr_v0.3.0_release.apk.asc)
- [`SHA256SUMS_v0.3.0`](https://gitlab.com/invincible-privacy/joinstr-kmp/-/raw/v0.3.0/releases/SHA256SUMS_v0.3.0) and its [`.asc`](https://gitlab.com/invincible-privacy/joinstr-kmp/-/raw/v0.3.0/releases/SHA256SUMS_v0.3.0.asc)
Verify the files before installing.
```sh
gpg --keyserver hkps://keys.openpgp.org --recv-keys 9253062A4F92D63459085CA62D230520212A5901
gpg --verify Joinstr_v0.3.0_release.apk.asc Joinstr_v0.3.0_release.apk
sha256sum -c SHA256SUMS_v0.3.0
```
The APK is signed with a certificate whose SHA-256 fingerprint is the following. Tools such as `apksigner verify --print-certs` or AppVerifier show it.
```
9F:D6:8F:FB:5E:B3:2F:D6:FC:2B:5D:FB:C9:7B:23:46:C7:54:C4:62:6F:9F:44:41:19:7B:D5:73:AB:16:18:E8
```
Releases before 0.2.0 used a different certificate. If you have 0.1.x installed, uninstall it before installing 0.3.0.
## Prepare your node
The app talks to Bitcoin Core over RPC, usually on your home network. Let the node accept RPC from the phone's network and give it a user name and password. The example below is for a node at `192.168.1.10` in a `192.168.1.0/24` network.
```ini
server=1
rpcbind=127.0.0.1
rpcbind=192.168.1.10
rpcallowip=127.0.0.1
rpcallowip=192.168.1.0/24
rpcuser=
rpcpassword=
```
Add `signet=1` or `testnet4=1` to use a test network. Then put the RPC settings under the matching `[signet]` or `[testnet4]` section. Only allow RPC from networks you trust. RPC traffic is not encrypted.
The node needs a wallet holding the coin you want to mix.
## Set up the app
1. Open **Settings**.
2. Enter a **Nostr Relay** such as `wss://nos.lol`.
3. Enter the **Node URL** (for example `http://192.168.1.10`), **RPC Port**, **RPC Username** and **RPC Password**.
4. Pick the **Wallet** to use and tap **Save**.
5. On the home screen, check that the node version and network are shown.
The app starts Tor by itself and shows **Connected to Tor** once it is ready.
## Make a coinjoin
1. Prepare a coin in the node's wallet worth the denomination plus 500 to 5000 sats.
2. Open **Pools**. Either create a pool on **Create New Pool** with a denomination and number of peers, or open **View Other Pools** and tap **Join** on an existing one.
3. When all outputs are registered, pick your coin on the input registration screen and tap **Register**. Your node signs it with `SIGHASH_ALL|ANYONECANPAY`.
4. The **Coinjoin Timeline** shows each step. When every peer has signed, the app checks and broadcasts the transaction through your node.
5. Finished coinjoins appear under **History**.
Before signing, the app refuses rounds that would cost more than 15000 sats, outputs below the dust limit and registrations that could be broadcast on their own. It only accepts pool credentials from the creator of the pool you joined.
## Notes
- Pools that require an aut-ct proof show why they cannot be joined. aut-ct works in the desktop build only.
- Pool messages go to the relay the pool names, not the one in **Settings**.
- iOS and desktop builds can be made from source. Only Android has signed releases.
---
# Sparrow
[sparrow-joinstr](https://github.com/QcMrHyde/sparrow-joinstr) is a fork of [Sparrow Wallet](https://sparrowwallet.com) with a joinstr coinjoin window. It works with any wallet and server that Sparrow supports.
| | |
|---|---|
| Latest version | 0.2.0, based on Sparrow 2.3.2 |
| Platforms | Linux (`.deb`, `.tar.gz`), Windows (`.msi`, `.zip`) |
| Transport | Tor only |
| aut-ct | Yes |
| Source | [github.com/QcMrHyde/sparrow-joinstr](https://github.com/QcMrHyde/sparrow-joinstr) |
## Download and verify
Download the package for your system together with `manifest.txt` and `manifest.txt.floppy.asc` from the [v0.2.0 release](https://github.com/QcMrHyde/sparrow-joinstr/releases/tag/v0.2.0). The manifest is signed with the same key as the other joinstr releases.
```sh
gpg --keyserver hkps://keys.openpgp.org --recv-keys 9253062A4F92D63459085CA62D230520212A5901
gpg --verify manifest.txt.floppy.asc manifest.txt
sha256sum --check --ignore-missing manifest.txt
```
## Use
1. Open or create a wallet in Sparrow and connect to a server as usual.
2. Open **Tools > Coinjoin**.
3. Under **Settings**, set the nostr relay (default `wss://nos.lol`) and, for aut-ct pools, the aut-ct API URL.
4. Use **New pool** to create a pool, or **Other pools** to join one. **My Pools** and **History** show your pools and finished coinjoins.
**New pool** asks for the denomination, number of peers, fee rate (defaults to the wallet's estimate) and timeout in minutes. For an aut-ct pool, also fill in **aut-ct Keyset** and optionally a **Proving Key**. Without a proving key, Sparrow uses a taproot coin from the wallet.
## Behaviour
- Every joinstr request goes through Tor on a new circuit. If Tor is not running, the request is refused instead of being sent from your real IP address. A relay on `localhost` is reached directly.
- Pools that advertise the VPN transport are refused. Tor is the only transport this client supports.
- The client supports aut-ct sybil resistance. It also supports ring signature recovery when some peers do not sign.
---
# Rust and Dart library
[rust-joinstr](https://github.com/rust-joinstr/joinstr) is a Rust implementation of joinstr for wallet developers. It includes a nostr client, an Electrum client and a P2WPKH signer. It also has Dart and Flutter bindings for mobile wallets. Its research and development is sponsored by [Bull Bitcoin](https://www.bullbitcoin.com/).
| | |
|---|---|
| Language | Rust (2021 edition), Dart bindings with flutter_rust_bridge 2.12.0 |
| Wallet backend | Electrum server |
| Signer | P2WPKH hot signer from a BIP39 mnemonic |
| Tor | Optional SOCKS5 proxy for nostr and Electrum connections |
| License | MIT |
| Source | [github.com/rust-joinstr/joinstr](https://github.com/rust-joinstr/joinstr) |
## Crates
| Crate | Path | Purpose |
|---|---|---|
| `joinstr` | `rust/joinstr` | Pools, coinjoin rounds and the blocking `interface` API |
| `joinstr_wallet` | `rust/joinstr_wallet` | Wallet settings types |
| `simple_nostr_client` | `rust/simple_nostr_client` | Minimal nostr client |
| `simple_electrum_client` | `rust/simple_electrum_client` | Minimal Electrum client |
| `socks5` | `rust/socks5` | SOCKS5 client with remote DNS and a new Tor circuit per connection |
| `joinstr_flutter` | `dart` | Dart and Flutter bindings |
## Rust API
The `joinstr::interface` module has blocking functions that cover a full round.
| Function | Purpose |
|---|---|
| `list_coins(mnemonics, electrum_address, electrum_port, range, network, proxy)` | Scan a range of addresses of a BIP39 wallet for coins |
| `list_pools(back, timeout, relay, proxy)` | List pools announced on a relay in the last `back` seconds |
| `initiate_coinjoin(config: PoolConfig, peer: PeerConfig)` | Create a pool and run it until the coinjoin is broadcast |
| `join_coinjoin(pool: Pool, peer: PeerConfig)` | Join a pool and run it until the coinjoin is broadcast |
`initiate_coinjoin_with_progress` and `join_coinjoin_with_progress` take a callback that receives each step of the round, for a live progress view.
`PoolConfig` sets the denomination, fee rate, maximum duration, number of peers and network. `PeerConfig` sets the mnemonic, the Electrum server, the input coin, the output address, the relay, the network and an optional proxy such as `127.0.0.1:9050`.
Add it from git.
```toml
[dependencies]
joinstr = { git = "https://github.com/rust-joinstr/joinstr" }
```
## Dart and Flutter
`joinstr_flutter` wraps the same four functions through flutter_rust_bridge. Pools cross the boundary as JSON. The generated bridge code is committed, so the package builds as checked out from git. The package ships an Android example app and supports iOS.
```yaml
dependencies:
joinstr_flutter:
git:
url: https://github.com/rust-joinstr/joinstr
path: dart
```
Android apps that use the package must build with Gradle 8. The vendored cargokit calls `project.exec {}`, which Gradle 9 removed.
## Used by
The [Mutinynet bot](https://docs.joinstr.xyz/getting-started/mutinynet.md) runs a joinstr pool every 30 minutes with a copy of rust-joinstr compiled for Cloudflare Workers.
---
# How it works
joinstr builds a coinjoin from one input and one output per peer. Peers find each other and exchange messages through a nostr relay. Nobody runs a coordinator. The peer who creates a pool only announces it and hands out its key.
The protocol rests on one idea from the post [Coinjoin with less steps using ALL|ANYONECANPAY](https://lists.linuxfoundation.org/pipermail/bitcoin-dev/2023-May/021696.html). If outputs are registered first, each peer can sign their own input with `SIGHASH_ALL|ANYONECANPAY` as soon as all outputs are known. That signature commits to every output but to no other input, so the signed inputs can simply be merged into one valid transaction.

## 1. Announce
The creator picks a denomination, a number of peers, a relay and a transport. The client makes a new nostr key pair for the pool and publishes a kind `2022` event signed with it. The event carries the pool id, public key, denomination, number of peers, expiry time, relay, fee rate and transport. A pool can also require an [aut-ct](https://docs.joinstr.xyz/how-it-works/sybil-resistance.md) proof.
## 2. Join and credentials
A peer who wants to join creates a new nostr key and sends an encrypted `join_pool` request to the pool's public key. The creator answers with the credentials, which repeat the pool terms and include the pool's private key. The joiner accepts them only if that private key matches the announced public key and the terms match the announcement.
From now on every pool message is encrypted to the pool key and signed with it. All peers can read all messages, but the relay and outsiders cannot.
## 3. Output registration
Each peer takes a fresh address from their wallet and publishes it as an `output` message. With Tor, each message goes out on a new circuit, so the relay cannot tie the output to the peer's earlier messages by IP address.
Registration ends when the pool has as many outputs as peers.
## 4. Input registration
Each peer now knows every output. They build a transaction with all outputs in [BIP69](https://github.com/bitcoin/bips/blob/master/bip-0069.mediawiki) order and their own single input, then sign that input with `SIGHASH_ALL|ANYONECANPAY`. The signed PSBT is published as an `input` message.
Before signing, a peer checks what the round costs them. They refuse if the output is below the dust limit, if the cost is above 15000 sats or if the PSBT could be broadcast on its own.
## 5. Combine and check
Every peer collects all inputs, merges them into one transaction and checks it.
- All outputs are equal and there is one output per peer.
- Their own output is present.
- No address is used twice and no input address appears as an output.
- The fee is between 100 and 10000 sats per peer.
## 6. Broadcast
Every peer broadcasts the same transaction through their own wallet backend. One copy is enough for the coinjoin to reach the network, so it goes through even if some peers go offline after signing.
## Recovery when peers drop out
A peer can register an output and then never sign an input. Without a fix, the round would stall and the honest peers would have revealed their outputs for nothing.
When input registration times out with some inputs signed, the plugin starts a recovery.
1. The peers who signed re-register their output addresses in `reregister` messages. Each one carries a linkable ring signature over the public keys of all signed inputs and the message `SHA256(address + pool id)`. It proves the address belongs to one of the signers without saying which one. The key image stops a peer from registering twice.
2. If every signer re-registers, each peer builds a smaller transaction with only the signed inputs and the re-registered outputs and signs it with plain `SIGHASH_ALL` in a `resign` message.
3. The smaller transaction is combined, checked and broadcast as usual.
Each recovery phase waits 120 seconds. If not every signer re-registers, the round is abandoned.
## Timing
| Step | Time |
|---|---|
| Pool open for joins | 10 minutes from the Electrum GUI, set per pool elsewhere |
| Waiting for outputs or inputs | up to 30 minutes (360 polls every 5 seconds) |
| Each recovery phase | 120 seconds |
## Further reading
- [Privacy](https://docs.joinstr.xyz/how-it-works/privacy.md) covers what each party can learn.
- [Fees](https://docs.joinstr.xyz/how-it-works/fees.md) shows how the output amount is computed.
- [Protocol](https://docs.joinstr.xyz/protocol/index.md) lists every message and field.
---
# Privacy
A coinjoin hides which input paid for which output. joinstr adds a few rules on top so that the coordination layer does not undo that.
## What each party sees
| Party | Sees | Does not see |
|---|---|---|
| Chain observer | One transaction with equal outputs, one per peer | Which input belongs to which output |
| Relay | Encrypted events, their timing and the Tor exit they came from | Pool contents, addresses or PSBTs |
| Other peers | Every output address and every signed input | Which output belongs to which input |
| Pool creator | The same as other peers | Anything more, apart from knowing they created the pool |
Peers see all outputs and all inputs because each one has to check and sign the full transaction. What they cannot see is the link between them, as long as a peer's output and input messages cannot be tied together.
## How the link is kept hidden
**New keys.** The pool has its own nostr key pair. Every joining peer uses a new key for the join request. No long lived nostr identity is attached to a pool.
**Encryption.** After the join, every message is encrypted to the pool key. Only peers holding the pool's private key can read it.
**Tor circuits.** With the `tor` transport, clients send each nostr operation through a new Tor circuit by using a random SOCKS user name. The output message and the input message of a peer leave from different Tor exits, so the relay cannot match them by IP address.
**Fresh output addresses.** Clients take an unused address for every output. The Electrum plugin also refuses an input from an address that already takes part in another pool.
**No change.** There is no change output that could be traced back to an input.
**Equal outputs.** All outputs have the same value, so amounts do not reveal who owns which output.
## VPN transport
The Electrum plugin and the Android app also offer a `vpn` transport. The pool names a Riseup VPN gateway and every peer connects through it, so all peers share one exit IP address. The relay sees the same IP address for every peer and cannot tell them apart by it. A VPN pool also means trusting the VPN provider with your traffic. Tor is the default and the recommended choice.
## What joinstr does not protect against
- **Timing.** A relay sees when messages arrive. Messages sent on new circuits but at predictable times give it some signal.
- **Your wallet backend.** An Electrum server learns which addresses your wallet asks about. Use your own server, or your own Bitcoin Core node, for the best privacy.
- **Spending carelessly.** Merging a coinjoin output with older coins, or sending it to an address that identifies you, links it again.
- **Small pools.** A pool with two peers hides you among two. More peers give a larger anonymity set.
- **Sybil peers.** If the other peers in a pool are all the same person, they know which output is yours. [aut-ct](https://docs.joinstr.xyz/how-it-works/sybil-resistance.md) makes this more expensive.
---
# Sybil resistance
In a sybil attack one person fills most of a pool with fake peers. If every other peer in your pool is the same attacker, they know which output is yours. Fake peers can also join pools and never sign, which wastes everyone's time.
joinstr makes this more expensive with [aut-ct](https://github.com/AdamISZ/aut-ct), anonymous usage tokens from curve trees, written by Adam Gibson. It is optional and set per pool.
## How it works
A pool that uses aut-ct names a **keyset**, a published list of taproot coins that meet some minimum value and age. To join, a peer proves that they control one coin in that list without revealing which one.
Each proof produces a **key image** that is the same every time the same coin is used for the same pool. The pool creator records key images and rejects a second join with one that was already used. So one coin buys one seat in a pool. An attacker needs as many qualifying coins as seats they want to fill.
The proof is bound to the pool with the context `SHA256(pool id + pool public key)`, so it cannot be replayed in another pool. The creator's own key image is reserved when the pool is created.
The proving coin is never spent and never revealed. It does not have to be the coin you mix.
## Keysets
A keyset file is named after the rules its coins meet.
```
autct-----.aks
```
For example `autct-830000-100000-1-2-1024.aks` lists taproot coins at block 830000 worth at least 100000 sats with at least one confirmation. A pool advertises its keyset by file name and every peer needs the same file.
## Who supports it
| Client | aut-ct |
|---|---|
| Electrum plugin | Create and join |
| Sparrow | Create and join |
| Android app | Shows why an aut-ct pool cannot be joined |
| Web UI, daemon, Rust library | Not supported |
See [Electrum plugin, aut-ct pools](https://docs.joinstr.xyz/clients/electrum.md#aut-ct-pools) for setup.
## Limits
- An attacker with many qualifying coins can still fill a pool. aut-ct raises the cost, it does not remove the attack.
- The latest `autct` release, v0.1.4, cannot prove ownership of a coin whose taproot output key has an odd y coordinate. The fix is on the aut-ct `master` branch and not yet released.
- Ring signatures in the [recovery phase](https://docs.joinstr.xyz/how-it-works/index.md#recovery-when-peers-drop-out) are a separate mechanism. They limit the damage when peers drop out and work in any pool whose inputs are P2WPKH.
---
# Fees
joinstr has no coordinator fee. Peers pay the mining fee between them. A paid relay may charge its own fee for writing events.
## Output amount
The pool announcement carries a fee rate in sat/vB. The output amount is computed from it, assuming 100 vbytes per peer.
```
output = round(denomination × 100,000,000) − floor(fee_rate × 100 × peers) ÷ peers
```
The division rounds down. Every output in the pool gets exactly this amount.
## What you pay
There is no change output, so everything your input holds above your output goes to the miners.
```
your cost = your input − output
```
Clients only let you use a coin worth the denomination plus 500 to 5000 sats, so your cost is the per peer fee share plus whatever your coin holds above the denomination. Pick a coin close to the denomination plus 500 sats to keep it low.
### Example
A pool of 0.001 BTC with 5 peers and a fee rate of 10 sat/vB.
| | sats |
|---|---|
| Denomination | 100,000 |
| Fee share per peer, `floor(10 × 100 × 5) ÷ 5` | 1,000 |
| Output for every peer | 99,000 |
| Your input | 100,600 |
| Your cost | 1,600 |
## Limits every peer checks
| Check | Limit |
|---|---|
| Your input | denomination + 500 to denomination + 5000 sats (the CLI allows up to + 15000) |
| Your output | at or above the dust limit for every output type in the pool |
| Your cost | at most 15000 sats |
| Total fee of the coinjoin | between 100 and 10000 sats per peer |
| Fee rate (daemon) | at most 100 sat/vB |
A creator who announces an extreme fee rate cannot drain joiners, because each joiner refuses to sign a round that would cost them more than 15000 sats.
The dust limit is 294 sats for P2WPKH, 330 for P2TR and P2WSH, 540 for P2SH and 546 for P2PKH.
## Where the fee rate comes from
| Client | Source |
|---|---|
| Electrum plugin | Electrum's fee estimate, 3 sat/vB if none |
| Daemon and CLI | mempool.space `hourFee` over Tor, 5 sat/vB if unreachable |
| Web UI | Fixed 3 sat/vB |
| Android app | The node's `estimatesmartfee`, falling back to its minimum mempool fee |
| Sparrow | The wallet's fee estimate, editable when creating a pool |
## Recovery transactions
If the [recovery phase](https://docs.joinstr.xyz/how-it-works/index.md#recovery-when-peers-drop-out) runs, the smaller transaction is sized as `100 × outputs + 68 × inputs + 100` vbytes and the fee is split the same way.
## Relay fees
Any nostr relay can be used. Some relays charge for writing events. When you join a pool, the Electrum plugin shows a QR code with your one time nostr public key so you can pay the pool's relay if it asks. Free relays need nothing.
---
# Security model
joinstr has no trusted coordinator, so every peer protects themselves. Your coin can only be spent by a transaction you signed and your client only signs one that pays you your output.
## Assumptions
- Relays can drop, delay or replay events, but cannot read encrypted pool messages or forge signatures.
- Any peer, including the pool creator, may be malicious.
- Your wallet backend (Electrum server or Bitcoin Core) reports your own coins honestly.
## Checks before signing
A peer signs only after these checks pass.
| Check | Stops |
|---|---|
| The pool announcement is signed by the pool key it names | Fake or altered pool announcements |
| Credentials carry the private key of the announced pool and the same terms | Someone other than the creator handing out credentials or changing the terms |
| Your output is in the transaction with the computed amount | A round that does not pay you |
| Your cost is at most 15000 sats and your output is above dust | A creator draining joiners with an extreme fee rate |
| Your input does not cover all outputs on its own | A registration PSBT that someone could broadcast alone, spending your whole coin to pay every output |
| Your coin is one the wallet considers spendable | Spending frozen or locked coins |
## Checks before broadcast
| Check | Stops |
|---|---|
| All outputs are equal and there is one per peer | Unequal outputs that would break the anonymity set |
| No output address repeats and no input address appears as an output | Address reuse that links coins |
| No two inputs spend from the same address | Inputs that link to each other |
| Total fee is between 100 and 10000 sats per peer | Transactions that overpay or would not confirm |
## Denial of service
An attacker can join a pool and never sign, so that the round stalls. joinstr limits this in three ways.
- **Ring signature recovery.** Honest peers who signed can finish without the missing peers. See [recovery](https://docs.joinstr.xyz/how-it-works/index.md#recovery-when-peers-drop-out).
- **aut-ct.** A pool can require each peer to prove ownership of a coin from a keyset, so filling seats costs real coins. See [Sybil resistance](https://docs.joinstr.xyz/how-it-works/sybil-resistance.md).
- **Relay choice.** A pool runs on one relay chosen by the creator. Paid or private relays raise the cost of spam.
## Known limits
- A pool creator learns nothing more than other peers, but can choose a relay that logs timing.
- A relay can refuse to carry a pool's events. The pool then fails and no coins move.
- Peers who drop out after registering an output cost honest peers time, even when recovery succeeds.
## Reporting vulnerabilities
See the [security policy](https://docs.joinstr.xyz/community/security-policy.md).
---
# Comparison
joinstr is one of several coinjoin designs. The table compares it with the best known alternatives.
| | joinstr | JoinMarket | Whirlpool | WabiSabi |
|---|---|---|---|---|
| Coordinator | None, pools run over nostr relays | None, takers coordinate makers over IRC and onion message channels | Central | Central |
| Who sets a round | Any user creates a pool | The taker | The coordinator | The coordinator |
| Fees | Mining fee only, optional relay fee | Mining fee plus maker fees paid by the taker | Mining fee plus a pool entry fee | Mining fee plus any coordinator fee |
| Fidelity bonds | Not used | Used by makers | Not used | Not used |
| Outputs | One denomination per pool, no change | Equal coinjoin outputs plus change | Fixed pool denominations, no change in mixes | Many standard denominations, change possible |
| Signature hash | `ALL\|ANYONECANPAY` | `ALL` | `ALL` | `ALL` |
| Network privacy | Tor by default, optional VPN | Tor | Tor | Tor |
| Languages | Python, Kotlin, Java, Rust, Dart | Python | Java | C# |
| Coordinators today | None needed | None needed | [Ashigaru](https://ashigaru.rs) runs one | Independent coordinators such as [kruw](https://kruw.io) |
## What sets joinstr apart
- **No server to run or trust.** Anyone can start a pool on any relay. If a relay goes away, new pools simply use another one.
- **Fewer steps.** Registering outputs first and signing with `SIGHASH_ALL|ANYONECANPAY` removes the need for a coordinator to collect inputs before anyone signs.
- **No fees beyond mining.** There is no maker or coordinator to pay.
## Tradeoffs
- Pools need enough peers at the same time. A coordinator or a market of makers can fill rounds faster.
- Peers see every input and output of their round. Only the link between them stays hidden.
- Peers who join and never sign can stall a round. Ring signature recovery and aut-ct reduce the damage but do not remove it.
## Links
- [JoinMarket](https://github.com/JoinMarket-Org/joinmarket-clientserver)
- [Whirlpool](https://github.com/Samourai-Wallet/Whirlpool) and [Ashigaru](https://ashigaru.rs)
- [WabiSabi](https://github.com/WalletWasabi/WabiSabi) and [Wasabi Wallet](https://github.com/WalletWasabi/WalletWasabi)
---
# Protocol specification
This page describes the messages joinstr peers exchange, as implemented by the reference [Electrum plugin](https://docs.joinstr.xyz/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", ]`.
- 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": ""`, 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": "",
"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": "",
"denomination": 0.001,
"peers": 5,
"timeout": 1791880600,
"relay": "wss://nos.lol",
"private_key": "",
"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": "", "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](https://docs.joinstr.xyz/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": "",
"key_image": "",
"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": ""}
```
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.
---
# Research
These projects explore where joinstr could go next. They are experimental, run on test networks and are not part of the released clients. Do not use them with real coins.
| Project | Idea | Status |
|---|---|---|
| [joinstr v2](https://docs.joinstr.xyz/research/joinstr-v2.md) | Peers fund one shared MuSig2 output and pre-sign every exit from it | Proof of concept on signet, with a browser playground |
| [joinstr on Ark](https://docs.joinstr.xyz/research/joinstr-ark.md) | A joinstr pool of Ark VTXOs settled in one Bark round | Proof of concept on regtest and signet |
Ideas and feedback are welcome on the [forum](https://forum.joinstr.xyz).
---
# joinstr v2
joinstr v2 is a proof of concept for coinjoin pools built from MuSig2 and pre-signed transactions.
Peers still find each other on nostr. Instead of one transaction with equal outputs, they fund a single taproot output that they own together with MuSig2. Before anyone signs the funding transaction, they all sign the transactions that pay every peer out of it.
- Each exit has one input and two outputs and is spent with a single key path signature, so on chain it looks like an ordinary taproot payment with change.
- The amount of a payout depends only on its denomination and its place in the chain, never on whose it is.
- Ring signatures hide from the other peers which payout belongs to which coin. They also stop anyone from adding payouts that nobody paid for, so a pool cannot be blocked that way.
- Funding inputs are signed with `SIGHASH_ALL`, not `ANYONECANPAY`, because the pre-signed exits commit to the funding transaction id.
## Pool types
- A **chain**, where peers leave one after the other in a fixed order.
- **Any order**, where a pool of 3 to 7 peers signs an exit for every order in which its peers may leave, so each peer can leave alone whenever it wants.
- A **tree** of exits for larger pools.
- Exits that pay several peers at once, earlier exits that pay a higher fee rate, P2A anchors for fee bumping and several denominations in one pool.
Five pools ran on signet, from 5 to 100 peers. The [signet demo](https://gitlab.com/1440000bytes/joinstr-v2/-/blob/main/docs/signet-demo.md) links every transaction and lists every pre-signed transaction in full.
## Links
- [gitlab.com/1440000bytes/joinstr-v2](https://gitlab.com/1440000bytes/joinstr-v2) has the source and the specification.
- [docs/design.md](https://gitlab.com/1440000bytes/joinstr-v2/-/blob/main/docs/design.md) explains the design step by step.
- The [playground](https://1440000bytes.gitlab.io/joinstr-v2-playground/) simulates pools in the browser, with no node or coins needed. It can replay the signet pools or build new ones and shows what each party sees.
---
# joinstr on Ark
joinstr-ark is a proof of concept that mixes [Ark](https://ark-protocol.org) VTXOs instead of onchain coins, on [Bark](https://gitlab.com/ark-bitcoin/bark), Second's Ark implementation.
Peers meet in a joinstr pool on nostr and register fresh output keys anonymously. All of their VTXOs then go into one Bark round as a single participation. The Ark server sees N inputs and N equal outputs but cannot tell which input paid which output. Either every peer gets a new VTXO or none does.
It runs against an unmodified Ark server (`captaind` 0.7.1) on regtest. It also ran on signet against Second's public Ark server, mixing three 50,000 sat VTXOs in one round.
The nostr transport comes from the [Rust library](https://docs.joinstr.xyz/clients/rust.md).
## Links
- [gitlab.com/1440000bytes/joinstr-ark](https://gitlab.com/1440000bytes/joinstr-ark) has the source and the run instructions.
- [docs/signet-demo.md](https://gitlab.com/1440000bytes/joinstr-ark/-/blob/main/docs/signet-demo.md) documents the signet run.
---
# Contributing
joinstr is free software under the MIT License. Contributions of code, design, testing, research and docs are welcome.
## Repositories
| Repository | Contents |
|---|---|
| [invincible-privacy/joinstr](https://gitlab.com/invincible-privacy/joinstr) | Electrum plugin (`plugin/`), web UI (`gui/`), daemon and CLI (`cli/`), container build (`contrib/docker`), release notes and NIP.md |
| [invincible-privacy/joinstr-kmp](https://gitlab.com/invincible-privacy/joinstr-kmp) | Kotlin Multiplatform app for Android, desktop and iOS |
| [QcMrHyde/sparrow-joinstr](https://github.com/QcMrHyde/sparrow-joinstr) | Sparrow Wallet fork |
| [rust-joinstr/joinstr](https://github.com/rust-joinstr/joinstr) | Rust library and Dart bindings |
| [1440000bytes/docs.joinstr.xyz](https://gitlab.com/1440000bytes/docs.joinstr.xyz) | These docs |
## Workflow
1. Open an issue first for anything larger than a small fix, so the approach can be agreed.
2. Fork the repository and create a branch from the latest `main`.
3. Keep each merge request to one change, with tests where the code has them.
4. Rebase on `main` before you open the merge request.
## Running the tests
The main repository has three Python test suites that run in GitLab CI on every branch and merge request. The plugin suite uses `electrum_ecc` with the system libsecp256k1, like Electrum itself. It uses `coincurve` only for a reference implementation it compares against.
```sh
sudo apt-get install libsecp256k1-dev
ELECTRUM_ECC_DONT_COMPILE=1 pip install pytest aiohttp coincurve "electrum-ecc>=0.0.4,<0.1"
python -m pytest plugin/tests/ -v
pip install -r cli/requirements.txt pytest
python -m pytest cli/tests/
pip install -r gui/requirements.txt pytest
python -m pytest gui/tests/
```
Some plugin tests run against a real Electrum and a regtest node when you set `JOINSTR_REAL_ELECTRUM=1` and `JOINSTR_BITCOIN_CLI="bitcoin-cli -regtest"`.
The Android app builds with JDK 17. See its [README](https://gitlab.com/invincible-privacy/joinstr-kmp) for the Gradle tasks of each platform.
## Design
UI and UX work happens in the [joinstr Figma file](https://www.figma.com/file/UtMGwoJPpL8FNUmsdqYoeQ/joinstr-%F0%9F%A6%83-Design). Screenshots in these docs are taken in dark mode.
## Research
Privacy researchers can review the [protocol](https://docs.joinstr.xyz/protocol/index.md), the [security model](https://docs.joinstr.xyz/how-it-works/security.md) and the [research projects](https://docs.joinstr.xyz/research/index.md). Report vulnerabilities through the [security policy](https://docs.joinstr.xyz/community/security-policy.md) and share ideas on the [forum](https://forum.joinstr.xyz).
## Docs
These docs are built with MkDocs Material from markdown in [1440000bytes/docs.joinstr.xyz](https://gitlab.com/1440000bytes/docs.joinstr.xyz). Every page has an edit link. Preview your changes locally with these commands.
```sh
pip install -r requirements.txt
mkdocs serve
```
---
# Security policy
## Reporting a vulnerability
Report vulnerabilities privately as a [confidential issue](https://docs.gitlab.com/user/project/issues/confidential_issues/) on [gitlab.com/invincible-privacy/joinstr](https://gitlab.com/invincible-privacy/joinstr/-/issues/new). Mark the issue confidential before you submit it, so only maintainers can see it.
Please include the affected version, the steps to reproduce and the impact you expect. A proof of concept helps.
Do not open a public issue, post on the forum or test against other people's pools until the problem is fixed.
## Scope
The [hall of fame](https://hof.joinstr.xyz) covers the Electrum plugin, the `plugin/` directory of [invincible-privacy/joinstr](https://gitlab.com/invincible-privacy/joinstr/-/tree/main/plugin). Reports on other clients are welcome through the same channel or the issue tracker of that client.
There is no bug bounty. Researchers who report valid vulnerabilities in the plugin are credited in the [hall of fame](https://hof.joinstr.xyz).
## Verifying releases
Electrum plugin, Android and Sparrow releases are signed with the PGP key `9253062A4F92D63459085CA62D230520212A5901`. Each [client page](https://docs.joinstr.xyz/clients/index.md#release-signing) shows how to verify a download.
---
# Community and support
## Forum
[forum.joinstr.xyz](https://forum.joinstr.xyz) is the place for questions, ideas and announcements. It runs on nostr, so you can log in with any nostr key.
## Relay
`wss://relay.joinstr.xyz` is the project's NIP-29 group relay. It hosts the forum. It is not a coinjoin relay, so pools should use any general purpose relay instead.
## Issues
- [invincible-privacy/joinstr issues](https://gitlab.com/invincible-privacy/joinstr/-/issues) for the Electrum plugin, web UI, daemon and CLI
- [invincible-privacy/joinstr-kmp issues](https://gitlab.com/invincible-privacy/joinstr-kmp/-/issues) for the Android app
- [QcMrHyde/sparrow-joinstr issues](https://github.com/QcMrHyde/sparrow-joinstr/issues) for Sparrow
- [rust-joinstr/joinstr issues](https://github.com/rust-joinstr/joinstr/issues) for the Rust and Dart library
## Donations
joinstr is free software built by volunteers. You can support it at [donate.joinstr.xyz](https://donate.joinstr.xyz).
---
# Releases
| Client | Version | Released | Download | Notes |
|---|---|---|---|---|
| Electrum plugin | 0.4.2 | 2026-10-13 | [plugin/zip/v0.4.2](https://gitlab.com/invincible-privacy/joinstr/-/tree/main/plugin/zip/v0.4.2) | [Release notes](https://gitlab.com/invincible-privacy/joinstr/-/blob/main/doc/release-notes/plugin/release-notes-v0.4.2.md) |
| Android app | 0.3.0 | 2026-09-22 | [Releases](https://gitlab.com/invincible-privacy/joinstr-kmp/-/releases/v0.3.0) | [Release notes](https://gitlab.com/invincible-privacy/joinstr-kmp/-/releases/v0.3.0) |
| Sparrow | 0.2.0 | 2026-08-23 | [Releases](https://github.com/QcMrHyde/sparrow-joinstr/releases/tag/v0.2.0) | [Release notes](https://github.com/QcMrHyde/sparrow-joinstr/releases/tag/v0.2.0) |
| Umbrel app | 0.1.2 | | [Umbrel app store](https://apps.umbrel.com/app/joinstr) | In the app store |
| Web UI, daemon and CLI | `main` | | [invincible-privacy/joinstr](https://gitlab.com/invincible-privacy/joinstr) | No separate releases |
| Rust and Dart library | `master` | | [rust-joinstr/joinstr](https://github.com/rust-joinstr/joinstr) | No separate releases |
## Verifying downloads
The Electrum plugin zip, the Android APK and the Sparrow release manifest are signed by /dev/fd0.
```sh
gpg --keyserver hkps://keys.openpgp.org --recv-keys 9253062A4F92D63459085CA62D230520212A5901
```
| Client | Verify with |
|---|---|
| Electrum plugin | `gpg --verify joinstr.zip.asc joinstr.zip` |
| Android app | `gpg --verify Joinstr_v0.3.0_release.apk.asc Joinstr_v0.3.0_release.apk` and `sha256sum -c SHA256SUMS_v0.3.0` |
| Sparrow | `gpg --verify manifest.txt.floppy.asc manifest.txt` and `sha256sum --check --ignore-missing manifest.txt` |
## Upgrade notes
- **Electrum plugin 0.4.2.** It works in the Electrum binaries and talks to 0.4.1 peers. No action needed.
- **Electrum plugin 0.4.1.** Pools created by 0.4.0 cannot be joined from 0.4.1. Finish or abandon them before upgrading.
- **Android 0.2.0 and later.** The signing certificate changed in 0.2.0. Uninstall 0.1.x before installing a newer version.
---
# FAQ
## Can I use joinstr on mainnet?
Yes. The Electrum plugin, web UI, daemon, Android app and Sparrow all work on mainnet. Try a round on [Mutinynet](https://docs.joinstr.xyz/getting-started/mutinynet.md) or signet first to learn the flow.
## Do I need to run Bitcoin Core?
No. The Electrum plugin and Sparrow work with an Electrum server. For the best privacy, use your own server instead of a public one. The web UI, daemon and Android app need Bitcoin Core.
## Can I use joinstr on my phone?
Yes, on Android. See the [Android app](https://docs.joinstr.xyz/clients/android.md). It needs a Bitcoin Core node the phone can reach. There is no iOS release, although the app can be built for iOS from source.
## Which denominations can I use?
Any. The pool creator chooses the denomination. Your coin has to be worth the denomination plus 500 to 5000 sats, because there is no change output.
## What does a coinjoin cost?
Only the mining fee, shared by all peers, plus whatever your coin holds above the denomination. There is no coordinator fee. A paid relay may charge for writing events. See [Fees](https://docs.joinstr.xyz/how-it-works/fees.md).
## How many peers should a pool have?
At least 2. More peers give a larger anonymity set but take longer to fill. The web UI allows 2 to 10 peers.
## Which relay should I use?
Any nostr relay that accepts kind 2022 and kind 4 events. The default is `wss://nos.lol`. All peers in a pool use the relay the pool names, so pick one that is reliable. Paid relays reduce spam.
## Do I need Tor?
Tor is the default and strongly recommended. The Electrum plugin, web UI, daemon and Sparrow refuse to talk to a relay without it, unless the pool uses the VPN transport in a client that supports it. The Android and Umbrel apps run their own Tor.
## Is the old Riseup VPN option still there?
Yes, as an option in the Electrum plugin and the Android app. A VPN pool makes every peer use the same Riseup gateway. Tor is the default.
## What happens if a peer disappears in the middle of a round?
If it happens before they sign, the remaining signers can finish a smaller coinjoin through [ring signature recovery](https://docs.joinstr.xyz/how-it-works/index.md#recovery-when-peers-drop-out). If it happens after they sign, nothing changes, because every peer broadcasts the final transaction.
## Can a pool creator steal my coins?
No. Your client checks the full transaction before signing and before broadcasting. It refuses to sign if the round would cost you more than 15000 sats or if your signed input could be broadcast on its own. See [Security model](https://docs.joinstr.xyz/how-it-works/security.md).
## Is there a fee for market makers?
No. joinstr has no makers and no coordinator fees today.
## Where can I ask questions?
On the [forum](https://forum.joinstr.xyz). Bugs go to the issue tracker of the client you use, listed on [Community and support](https://docs.joinstr.xyz/community/community.md).
---
# For LLMs and agents
This page is for language models, coding agents and the people who use them. It explains how to load these docs into an agent and gives a compact brief with the facts an agent needs most often.
## Machine readable docs
| URL | Contents |
|---|---|
| [`/llms.txt`](https://docs.joinstr.xyz/llms.txt) | Index of every page with a one line summary, in the [llms.txt](https://llmstxt.org) format |
| [`/llms-full.txt`](https://docs.joinstr.xyz/llms-full.txt) | Every page as markdown in one file, links made absolute |
| `/.md` | The markdown source of any page, for example [`/clients/electrum.md`](https://docs.joinstr.xyz/clients/electrum.md) |
Every HTML page also links its markdown source with ` ` and a link at the bottom of the page.
To give an agent the whole documentation, point it at `https://docs.joinstr.xyz/llms-full.txt`. To let it pick pages, start from `https://docs.joinstr.xyz/llms.txt`.
## Brief
joinstr is a coinjoin implementation for bitcoin. Peers coordinate over a nostr relay with no central coordinator. A pool has one denomination, a fixed number of peers and one relay. Each peer contributes one input and receives one equal output. There is no change output. Inputs are signed with `SIGHASH_ALL|ANYONECANPAY` after all outputs are known, then every peer combines, checks and broadcasts the same transaction.
### Components
| Component | Source | Language | Backend | Latest |
|---|---|---|---|---|
| Electrum plugin (reference) | `gitlab.com/invincible-privacy/joinstr`, `plugin/` | Python | Electrum 4.6.0 or newer, any build | 0.4.2 |
| Web UI and Umbrel app | `gitlab.com/invincible-privacy/joinstr`, `gui/` | Python, Flask | Bitcoin Core RPC | Umbrel 0.1.2 |
| Daemon `joinstrd` and `joinstr-cli` | `gitlab.com/invincible-privacy/joinstr`, `cli/` | Python, FastAPI | Bitcoin Core RPC | `main` |
| Android app | `gitlab.com/invincible-privacy/joinstr-kmp` | Kotlin Multiplatform | Bitcoin Core RPC | 0.3.0 |
| Sparrow fork | `github.com/QcMrHyde/sparrow-joinstr` | Java | Sparrow server | 0.2.0 |
| Rust library and Dart bindings | `github.com/rust-joinstr/joinstr` | Rust, Dart | Electrum server | `master` |
### Constants
| Name | Value |
|---|---|
| Pool announcement | nostr kind `2022`, JSON content, no tags, signed by the pool key |
| Pool messages | nostr kind `4`, NIP-04 encrypted, tag `["p", pubkey]` |
| Message types | `new_pool`, `join_pool`, credentials (no `type`), `reject`, `output`, `input`, `reregister`, `resign` |
| Default relay | `wss://nos.lol` |
| Default Tor proxy | `127.0.0.1:9050` |
| Sighash for registration | `0x81` (`ALL\|ANYONECANPAY`) |
| Sighash for recovery | `0x01` (`ALL`) |
| Output order | BIP69, by amount then `scriptPubKey` bytes |
| Output amount | `round(denom × 1e8) − floor(fee_rate × 100 × peers) ÷ peers` |
| Allowed input | denomination + 500 to denomination + 5000 sats |
| Maximum cost per peer | 15000 sats |
| Fee bounds at finalization | 100 to 10000 sats per peer |
| Pool id | 10 hex characters followed by unix time |
| Poll budget | 360 polls every 5 seconds |
| Recovery phase timeout | 120 seconds each |
| Electrum GUI pool timeout | 600 seconds |
| Release signing key | `9253062A4F92D63459085CA62D230520212A5901` |
| Android APK certificate SHA-256 | `9F:D6:8F:FB:5E:B3:2F:D6:FC:2B:5D:FB:C9:7B:23:46:C7:54:C4:62:6F:9F:44:41:19:7B:D5:73:AB:16:18:E8` |
### Common tasks
Install and verify the Electrum plugin.
```sh
gpg --keyserver hkps://keys.openpgp.org --recv-keys 9253062A4F92D63459085CA62D230520212A5901
gpg --verify joinstr.zip.asc joinstr.zip
# then Electrum > Tools > Plugins > Add > joinstr.zip
```
Run a pool from the Electrum daemon.
```sh
electrum --offline setconfig plugins.joinstr.enabled true
electrum --offline setconfig proxy "socks5:127.0.0.1:9050::"
electrum daemon -d
electrum load_wallet
electrum joinstr_list_pools
electrum joinstr_create_pool 0.001 3
electrum joinstr_join_pool
```
Run the daemon and CLI against Bitcoin Core.
```sh
pip install -r cli/requirements.txt
cp cli/config.example.ini ~/.joinstr/config.ini # set [rpc], [nostr], [tor]
python cli/joinstrd.py # REST API on 127.0.0.1:8000, docs at /docs
python cli/joinstr-cli.py create --denomination 0.001 --peers 3
python cli/joinstr-cli.py join --pool-id
python cli/joinstr-cli.py input --txid --vout
```
Run the test suites of the main repository.
```sh
python -m pytest plugin/tests/ cli/tests/ gui/tests/
```
### Invariants
Code that builds or changes a joinstr client must keep these, or peers will reject its messages or lose coins.
1. Sign the announcement with the pool key it advertises.
2. Accept credentials only if their `private_key` derives the announced `public_key` and every term matches the announcement.
3. Send every pool message to the relay named in the pool, not a configured default.
4. Order outputs by BIP69 before signing. Sorting by address string is wrong.
5. Sign registration inputs with `0x81` and finalize them with an empty final `scriptSig` for native segwit.
6. Never sign a registration PSBT whose single input covers every output, since anyone could broadcast it alone.
7. Refuse a round that would cost more than 15000 sats or leave an output below dust.
8. Keep Python `json.dumps` spacing in message bodies, because some peers match `"type": ""` as text.
9. Route relay traffic through Tor with a new circuit per operation. Fail closed if Tor is down.
10. Use a fresh address for every output and never reuse an address across pools.
### Sources of truth
- The [protocol page](https://docs.joinstr.xyz/protocol/index.md) is the reference for the wire format. It follows `plugin/joinstr.py` in the main repository. NIP.md in the same repository is the original design and is older.
- [Releases](https://docs.joinstr.xyz/community/releases.md) lists the current versions.
- The [security policy](https://docs.joinstr.xyz/community/security-policy.md) explains how to report vulnerabilities. Do not file them as public issues.