Scripts, systemd units, nginx config and the landing page for the coinjoin.nl WabiSabi coordinator. Runs on a RaspiBlitz box (aarch64). No secrets in this repo — RPC creds, SSL keys and wallet files live outside it.
scripts/ Python/bash tooling
systemd/ service + timer that drive the scripts
nginx/ site + mempool reverse-proxy config (cert paths only, no keys)
web/ the static landing page
The landing page renders the latest successful coinjoin round (a txflow animation + an esplora-style viewer iframe) and a latest-rounds table with per-round fee stats — refreshed every 5 minutes by a systemd timer.
wasabi-coinjoin-latest.timer (every 5 min)
└─> wasabi-coinjoin-latest.service (oneshot, 3 steps)
1. coinjoin-latest-txid.sh greps the coordinator log for the last
"Successfully broadcast" txid → writes
web/latest-coinjoin.json, and (only when
the txid changed) renders web/latest.html
via txflow.py against the self-hosted
mempool (http://localhost:4080).
2. coinjoin-stats.py sync stores new rounds in the SQLite fee DB
(per-tx numbers from bitcoind JSON-RPC).
3. coinjoin-stats.py latest 10 > web/latest-stats.txt (table source)
web/index.html fetches latest-coinjoin.json, latest.html and
latest-stats.txt client-side and renders them.
| File | Purpose |
|---|---|
txflow.py |
Animate a Bitcoin tx flow from mempool.space or a self-hosted mempool. txflow.py <txid> --mempool http://localhost:4080 --export out.html. |
coinjoin-stats.py |
SQLite log of successful coinjoins for fee stats (stdlib only). Subcommands: sync, stats, latest [N], lowest. Paths come from env vars (COINJOIN_COORD_CONFIG, COINJOIN_COORD_LOG, COINJOIN_DB). The copy installed at /usr/local/bin/ hardcodes box paths (/var/lib/coinjoin-stats/coinjoin.db, /home/wasabi/.walletwasabi/coordinator/); this env-driven copy is the publishable one. |
coinjoin-latest-txid.sh |
Publishes the latest broadcast txid as JSON and renders the txflow animation. |
coinjoin-history-backfill.py |
One-time historical backfill of the fee DB via electrs (blockchain.scripthash.get_history per coordinator scrap address). |
coinjoin-history-wait.sh |
Waiter that polls electrs and runs the backfill once it is indexed (used by a transient systemd-run unit). |
sudo cp scripts/coinjoin-stats.py scripts/coinjoin-latest-txid.sh \
scripts/coinjoin-history-*.{py,sh} /usr/local/bin/
sudo cp scripts/txflow.py /home/admin/txflow.py
sudo cp systemd/wasabi-coinjoin-latest.* /etc/systemd/system/
sudo cp nginx/coinjoin.nl.conf /etc/nginx/sites-available/
sudo cp nginx/mempool.conf /etc/nginx/snippets/
sudo cp web/index.html /var/www/coinjoin/ && sudo chown www-data:www-data /var/www/coinjoin/index.html
sudo systemctl daemon-reload && sudo systemctl enable --now wasabi-coinjoin-latest.timer
sudo nginx -t && sudo systemctl reload nginxThe
coinjoin-stats.pyinstalled at/usr/local/bin/is the hardcoded-path variant. The copy inscripts/here is env-driven; set theCOINJOIN_*env vars (or edit the paths) before relying on it directly.
The round viewer is a fork of Copexit/am-i-exposed and is built separately —
see viewer-mods.md.
Status of hardware wallets as unattended WabiSabi coinjoin remote signers for Wasabi Wallet (ownership proofs + round signing under an on-device policy):
| Trezor | Coldcard | Passport Prime | Krux | Ledger | SeedSigner (SabiSigner fork) | BitBox02 Nova | |
|---|---|---|---|---|---|---|---|
| Wasabi branch | feature/trezor-coinjoin |
feature/coldcard-coinjoin |
feature/passport-coinjoin |
feature/krux-coinjoin |
none — feasibility study only | none needed — device side in SabiSigner (app, branch feat/ownership-proof) + SabiSigner-os (image); host side is kruxd.py sabi |
none — feasibility study only |
| Wasabi client side | ✅ TrezorKeyChain + bridge | ✅ ColdcardKeyChain + raw USB HID (no bridge daemon) — hardware-verified on a real Mk4 | ✅ PassportKeyChain (transport moving USB HID → QuantumLink) | ✅ KruxKeyChain + kruxd bridge on :21326 (serial COM8 / simulator TCP); importkruxwallet RPC, per-wallet round/fee policy |
❌ none (USB channel + client libs exist, no KeyChain) | ✅ via bridge, no C# — kruxd.py sabi speaks the SabiSigner encrypted HID session and presents the Krux HTTP API, so Wasabi's KruxBackend/KruxKeyChain drive it unchanged; sabi-wallet.py writes the wallet file (CoinJoinVendor: 3) HWI cannot. Untested against a live round yet |
❌ none. HWI already enumerates BitBox02_BTCOnly/BitBox02_Multi so ordinary PSBT signing works, but no CoinJoinVendor, no VendorOf entry, no IKeyChain |
| Firmware requirement | none — stock firmware has coinjoin support | custom build, published here for testers — 5.6.0 + PR #685 (branch). Mk4 only | custom coinjoin logic (KeyOS branch) — needs 2 QuantumLink messages added upstream | custom feat/slip-19-coinjoin branch — builds via WSL docker, flashes to WonderMV over USB (-B dan, COM8) |
fork of app-bitcoin needed: AUTHORIZE_COINJOIN + GET_OWNERSHIP_PROOF APDUs; swap mode already skips per-tx confirmation against a pre-approved policy — precedent to reuse |
fork done: SabiSigner puts a USB peripheral back (dwc2 + f_hid only, host stack CONFIG_USB off on every board, gadget bound only while on the USB screen) and adds five requests: get_version, get_xpub, sign_psbt, authorize_coinjoin, sign_coinjoin — no seed export exists for the layer to reach (threat model). Still on pid.codes test PID 0x0001; needs an allocated PID before a public release |
full fork needed — firmware is open source, so buildable without vendor cooperation, but three separate additions: foreign-input support in the tx protocol, SLIP-19/SLIP-21, and a session policy engine. Nova and BitBox02 share the codebase, so a fork covers both |
| Ownership proofs (SLIP-19) | ✅ device-native | ✅ segwit + taproot, verified on a real Mk4 and accepted by Wasabi's own verifier. Ownership id is the real SLIP-21 derivation, pinned to the published vectors | ✅ segwit + taproot (spec vector + BIP-86 vector) | ✅ on-device create_proof P2WPKH + P2TR (SLIP-21 ownership key); vectors pass Wasabi's verifier |
❌ not implemented (SIGN_MESSAGE can't produce SLIP-19 sighash format) |
✅ on-device get_ownership_proof, P2WPKH + P2TR (BIP-86 tweaked Schnorr), SLIP-21 ownership id, verified against the SLIP-19 vectors; issued only inside a live authorization and only under the authorized account (84'/86' sibling pair) |
❌ not implemented. BTCSignMessageRequest is legacy Bitcoin Signed Message double-SHA256, ECDSA only, with a mandatory on-device confirm — can't produce the SLIP-19 sighash, and no Schnorr path for taproot proofs |
| Unattended round signing | ✅ on-device authorization, SLIP-25 account | ✅ HSM policy, verified on a real Mk4. Five device-side rules bound one transaction and the sequence together: self-transfer floor, sats leaving, feerate per own vByte, round count and rate | ✅ session policy: fee cap, self-spend only, round budget, expiry | ✅ "CoinJoin USB" session: one on-device approval (fingerprint + policy summary), then unattended proofs + signing over framed UART link; self-spend floor, fee cap, SIGHASH rules, round budget (max_rounds) enforced |
❌ none; NVRAM policy storage feasible (MuSig2 sessions + BIP-388 HMAC precedents) | ✅ device side: authorize_coinjoin is one on-device approval (coordinator, account, round count, per-round + total fee budget), then sign_coinjoin and get_ownership_proof run without a button press: re-derives ownership of every input/output, ≥4 foreign inputs, owned scopes under the approved 84'/86' account pair, non_witness_utxo required on non-taproot inputs (miner-fee attack), round + fee budget decremented. RAM-only, dies with the session |
❌ none. Every signature is a live confirmation; no session concept. Persistent policy storage is plausible (registered script configs already live in NVRAM, 25 named entries) but no evaluator exists |
| Real device tested | ✅ | ✅ mainnet — retail Mk4 signing full WabiSabi rounds unattended, confirmed on-chain, after regtest rounds first. Every policy rule exercised from both sides on the device. Mk4 only: Q disables HSM, Mk3 too old | ❌ retail locked to vendor-signed firmware; building as official KeyOS SDK app — awaiting Foundation dev unit | ✅ WonderMV signed live WabiSabi coinjoin rounds on regtest (2026-07-13) — device validated PSBTs against on-device policy, signed own inputs over USB, txns broadcast + confirmed; 1134 unit tests pass | ❌ — Nano S Plus only sideloadable target (Nano X blocks sideloading, Nano S EOL/unsupported); Speculos emulator for dev | ❌ image built and published (sabisigner_os.0.1.0-rc1.pi0.img); no live WabiSabi round recorded yet |
❌ |
| Script types | taproot (SLIP-25) | segwit (taproot proofs verified, signing follow-up) | segwit v0 signing; proofs segwit + taproot | segwit + taproot (P2WPKH/P2TR only, enforced in psbt validation) | n/a | segwit + taproot on device (taproot via witness_utxo, everything else needs full non_witness_utxo); untested against Wasabi |
P2WPKH, P2WPKH-P2SH, P2TR supported for ordinary signing; irrelevant until proofs exist |
| Readiness | closest to production | testable today — firmware published, mainnet rounds confirmed, PR #685 open with Coinkite. Open: signing speed caps round size, Linux/macOS HID transport unwritten, taproot signing untested | collaborating with Foundation — logic done + tested; SDK app over QuantumLink pending dev unit + protocol proposal (status) | working end-to-end on hardware (bring-up guide) | feasibility researched — upstream unlikely (silent signing gated to Ledger swap partners), sideload-only fork | device + bridge built, first live round pending — image flashed and booting on a Pi Zero; ownership proofs and the kruxd translation pass loopback tests against a real UsbSession. Open: a live regtest round (taproot-only inputs first: Wasabi's Krux PSBT carries no non_witness_utxo, which the device demands for segwit inputs), pid.codes PID allocation |
concept stage — the tx protocol itself can't express a coinjoin (BTCSignInputRequest: all inputs must be ours; fee is computed over all inputs). Open firmware makes a fork possible, unlike Ledger, but it is a larger job than the Coldcard's was |
A signed Mk4 build is published in firmware/ with its SHA-256 and a GPG signature, so
this can be tried without building anything. It is Coinkite's 5.6.0 plus the ownership proofs and
HSM policy rules from PR #685, which is open and
not yet reviewed by Coinkite — so it is unofficial firmware, the device says so at every boot, and it
belongs on a wallet you can afford to be wrong about.
What works today: a retail Mk4 signs live WabiSabi rounds unattended, on mainnet, under a policy it displays and you approve once. What limits it today is speed — roughly 2.7 ms per PSBT byte, so a small round signs comfortably while a large one can take longer than a coordinator's signing phase allows. Small rounds, or a coordinator with a longer signing phase, work now; large rounds need firmware work that belongs upstream rather than in this branch.
A debug-built Coldcard (DEBUG_BUILD=1, i.e. is_devmode) exposes three channels that each
amount to arbitrary code execution from the host, with no physical interaction:
| Channel | Enabled by | What a host can do |
|---|---|---|
USB HID EVAL / EXEC / XKEY |
is_devmode, dispatched in usb.py |
Run any Python on the device, including reading the seed |
| Serial REPL | ckcc.vcp_enabled(True) at boot (main.py) |
Same, over the CDC port the device presents |
dev_helper keypad injection |
started when is_devmode (main.py) |
Drive the UI remotely; T re-enables the REPL |
This matters more here than it does for ordinary desk use, because unattended coinjoin signing is designed around leaving the device connected to a host for hours while nobody is watching.
Upstream dispatched the USB test commands before the HSM whitelist, so HSM mode did not stop
them: with hsm_active set, EXEC still executed. Demonstrated on the simulator and fixed on
feature/slip19-coinjoin —
they are now refused on real hardware while HSM is active, and the simulator keeps them so the
test suite can still drive HSM (regression test: testing/test_devmode_hsm.py).
That fix closes the HID channel. It does not close the serial REPL or keypad injection, which are enabled at boot and have no HSM concept. So the rule stands: use a debug build only on a test unit, never on a wallet holding funds. HSM mode is not a substitute for a non-debug build.
Every device above plugs into the same core. Wasabi's coinjoin flow never learns how a
particular device talks — it only records which vendor signs a wallet
(KeyManager.CoinJoinVendor), maps a connected model to that vendor in one place
(HardwareCoinJoin.VendorOf), and dispatches once when authorizing a batch of rounds. Signing
itself goes through IKeyChain, whose entire contract is two calls: produce a SLIP-19 ownership
proof for an input, and sign our own inputs of a coinjoin. A device is touched at exactly two
points in a round — input registration and transaction signing — and not at all in between.
The consent model is deliberately left to each device rather than flattened into a common abstraction, because the guarantees genuinely differ: a Trezor counts down preauthorized rounds bound to its SLIP-25 account, a Coldcard enforces a self-transfer floor and a feerate cap in its HSM policy, Krux holds a CoinJoin USB session with its own fee cap and round budget. Each expresses the user's limits in its own terms; the client enforces whatever the device cannot (round budget, fee-rate cap on round selection) and refuses to widen anything the device already decided.
SLIP-25 account behaviour — destination selection, account splitting, taproot-only coin choice —
keys off the account shape rather than the brand, so a future vendor that adopts SLIP-25
inherits it and one signing from the default accounts does not. Adding a vendor is four small
edits: an enum value, a model-to-vendor entry, a case in AuthorizeHardwareCoinJoinAsync, and an
IKeyChain implementation over whatever transport the device speaks.