Tumbler
The tumbler package builds and runs multi-phase CoinJoin plans that move
funds from a wallet's funded mixdepths to a list of destination addresses
while obscuring the link between origin and destination UTXOs.
It is consumed as a library and via a small CLI; nothing in tumbler
depends on jmwalletd. The HTTP daemon is one possible host, the CLI is
another, and a third-party caller can drive a Plan directly from Python.
Problem
A wallet whose origin is publicly visible — a KYC'd buy, a reused address, an observed UTXO graph — is hard to spend privately. A single CoinJoin already breaks naive co-spending heuristics, but two analytical signals survive a simple taker-only loop:
- Subset-sum recovery. A taker's input set must satisfy
sum(inputs) = cj_amount + change + cj_fees + tx_fee. With non-equal CoinJoin outputs that constraint is well below the Lagarias–Odlyzko density bound, so an observer can recover the taker's true input set from the joined input pool with off-the-shelf subset-sum solving. - Role fingerprint. A wallet that is always a taker has a different on-chain footprint than one that alternates roles: fees flow one way, UTXO ages drift one way, and the timing of activity is taker-driven. A long taker-only run is a worse mix than a shorter mixed-role one.
tumbler addresses both signals by chaining taker CoinJoins with
bounded maker sessions inside a single plan, and persists the plan as
human-readable YAML so a crashed daemon (or a curious operator) can
resume from a known state.
The user-facing privacy rationale, operational guidance, and fee overview live
in ../README-tumbler.md. This document focuses on the
data model and runtime behavior.
Architecture
tumbler exposes three public symbols:
Plan— a Pydantic model describing the full tumble: parameters, destinations, an ordered list of phases, and per-phase state.PlanBuilder— builds aPlanfrom per-mixdepth balances, a destination list, andPlanParameters.TumbleRunner— consumes aPlanand drives it to completion, persisting after every state transition.
caller (CLI, jmwalletd, library user)
|
v
TumbleRunner ---> taker.Taker.do_coinjoin (per CoinJoin)
| \--> maker.MakerBot.start/stop (per maker session)
v
jmwallet.WalletService
No other package imports anything else from tumbler. There is no
network code, no new crypto, and no protocol-level change to JoinMarket
CoinJoin.
Phases
A Plan is an ordered list of phases. The runner executes them one at a
time; a phase completes (or fails) before the next begins. Two phase
kinds are supported:
taker_coinjoin— a single CoinJoin as taker. Carriesmixdepth, one ofamount(sats;0means sweep) oramount_fraction(of the current mixdepth balance),destination(an external address or the literal"INTERNAL"),counterparty_count, and an optionalrounding_sigfigs(significant-figure count for amount obfuscation).maker_session— runsMakerBotfor a bounded window or until a target number of CoinJoins have been served, whichever comes first. Carriesmaker_session_seconds,maker_session_idle_timeout_seconds, and an offer template (ordertype, minsize, fees).
Phases are separated by an asyncio.sleep sampled from an exponential
distribution with mean time_lambda_seconds. The first phase of any
plan is a taker_coinjoin sweep with destination=INTERNAL: every
funded mixdepth's UTXOs go through a CoinJoin before any external
destination is touched.
Plan layout
A typical plan has two stages:
- Stage 1 — origin cleavage. For each non-empty mixdepth, in
descending order, append a
taker_coinjoinsweep into the next mixdepth withdestination=INTERNAL. This step alone is what makes the resulting wallet hard to link to the original UTXO set. - Stage 2 — role-mixed body. For each mixdepth on the way to the
external destination, optionally insert a
maker_session, then appendmintxcount - 1fractional taker CoinJoins, then a final taker sweep. The very last sweep in stage 2 targets the user's destination address; every other sweep targetsINTERNAL.
Maker sessions are inserted only when include_maker_sessions=True.
Without them the plan reduces to a pure taker chain similar in spirit to the
reference tumbler.
Subset-sum mitigation
A maker session sits between two taker phases. The session consumes UTXOs selected by other takers — subsets we did not control — and creates a maker-side CoinJoin output plus whatever maker-side change is implied by our inputs and the taker-chosen equal amount. We do not get to choose a change amount that "matches the taker"; only the equal CoinJoin amount is shared across participants. By the time the next taker phase fires, the wallet's UTXO graph has new points whose linkage back to the pre-maker set is mediated by taker-chosen subsets, which raises the cost of subset-sum recovery from "off-the-shelf solver" to "simulate or participate in the tumble."
This does not close the subset-sum vulnerability completely; closing it requires protocol-level changes (standard denominations, or taker-matches-maker-change). The mitigation composes cleanly with any future protocol fix.
Plan persistence (YAML)
A plan is stored at <data_dir>/schedules/<walletname>.yaml, one file
per wallet, overwritten in place on every state transition. There is no
database, no WAL, and no journal; the YAML file is the full source of
truth.
plan_id: 01HP5K9K2H3XR0QW0A1B2C3D4E
wallet_name: alice
created_at: '2026-04-22T12:34:56Z'
updated_at: '2026-04-22T12:41:03Z'
status: running
destinations:
- tb1qdest...aaa
- tb1qdest...bbb
- tb1qdest...ccc
parameters:
maker_count_min: 2
maker_count_max: 2
time_lambda_seconds: 3600.0
include_maker_sessions: true
mincjamount_sats: 100000
max_phase_retries: 3
seed: null
phases:
- index: 0
status: running
wait_seconds: 4372.841689800661
started_at: '2026-04-22T12:34:56Z'
finished_at: null
error: null
attempt_count: 0
kind: taker_coinjoin
mixdepth: 0
amount: 0
amount_fraction: null
counterparty_count: 2
destination: INTERNAL
txid: null
rounding_sigfigs: null
- index: 1
status: pending
wait_seconds: 2398.5842161882597
started_at: null
finished_at: null
error: null
attempt_count: 0
kind: maker_session
duration_seconds: 1200.0
target_cj_count: null
idle_timeout_seconds: null
cj_served: 0
- index: 2
status: pending
wait_seconds: 857.4411187260438
started_at: null
finished_at: null
error: null
attempt_count: 0
kind: taker_coinjoin
mixdepth: 1
amount: null
amount_fraction: 0.5097
counterparty_count: 2
destination: INTERNAL
txid: null
rounding_sigfigs: null
- index: 3
status: pending
wait_seconds: 0.0
started_at: null
finished_at: null
error: null
attempt_count: 0
kind: taker_coinjoin
mixdepth: 1
amount: 0
amount_fraction: null
counterparty_count: 2
destination: tb1qdest...aaa
txid: null
rounding_sigfigs: null
current_phase: 1
error: null
Phase status is one of pending | running | completed | failed |
cancelled. On startup the runner reloads the file: if current_phase
points to a running phase it resumes that phase, otherwise it
continues with the next pending one.
YAML is chosen over JSON for legibility — operators open this file with
an editor during support — and over TOML for clean nesting of phase
records with heterogeneous schemas per kind.
Failure handling and retries
Each taker phase has a retry budget given by PlanParameters.max_phase_retries
(default 3, range 0-20). When a taker phase fails the runner increments
attempt_count, persists, and rearms the same phase for retry.
Current retry behavior is intentionally narrow:
- The runner does not lower
counterparty_counton retry. Maker selection, replacement, and any reduction to available offers are handled inside the taker against the live orderbook. - If the phase originally targeted an external destination, the destination is
rewritten to
"INTERNAL". The retry still happens at the same mixdepth and a later phase is responsible for actually paying the external address. - A configurable retry delay is applied before the next attempt. Confirmation-
age style failures such as
No eligible UTXOs in mixdepthand PoDLE age failures are surfaced through taker failure text so the operator can tell the difference between UTXO-age problems and maker-availability problems.
When attempt_count reaches max_phase_retries the phase remains
failed and the whole plan transitions to failed. A failed maker
session is not retried; the runner proceeds to the next phase.
A crashed jmwalletd resumes from the persisted plan. Worst case a
single phase is attempted twice, which for a taker phase means a
duplicate CoinJoin (extra fee cost only) and for a maker phase means a
short double-maker window.
Library example
from pathlib import Path
from tumbler import PlanBuilder, TumbleRunner
from tumbler.builder import TumbleParameters
balances = {0: 200_000_000, 1: 0, 2: 0, 3: 0, 4: 0}
destinations = [
"bcrt1qdest0000000000000000000000000000000000aaa",
"bcrt1qdest0000000000000000000000000000000000bbb",
"bcrt1qdest0000000000000000000000000000000000ccc",
]
params = TumbleParameters(
destinations=destinations,
mixdepth_balances=balances,
maker_count_min=5,
maker_count_max=9,
time_lambda_seconds=21600.0,
include_maker_sessions=True,
maker_session_seconds=43200.0,
max_phase_retries=3,
seed=42,
)
plan = PlanBuilder(wallet_name="alice", params=params).build()
runner = TumbleRunner(plan=plan, data_dir=Path("/tmp/jm-tumbler"), ...)
await runner.run()
Privacy invariants
The plan builder is constrained to satisfy a fixed set of privacy/safety properties for every seed. These are pinned by the tumbler test suite over many seeds and balance scenarios:
- Every external destination receives at least one payout.
- Per-phase amount fractions are bounded in
[0.05, 1.0)and the per-mixdepth fractional sum stays below0.96, guaranteeing a trailing sweep with non-dust value. - Per-phase counterparty fee bound matches
max(cj_fee_abs, cj_fee_rel * amount) * counterparty_countso the upfront estimate cannot under-promise. - Worst-case total fee never exceeds the starting balance: the protocol cannot drain the wallet to fees.
- The estimator's reported balance equals the configured per-mixdepth balance map (no silent field drift).
- Phase indices are contiguous; counts match
taker + maker.
Amount rounding
By default a fraction of non-sweep taker CJs (rounding_chance=0.25)
have their resolved sat amount rounded to a random number of
significant figures, drawn from a weighted distribution
(rounding_sigfig_weights=(55, 15, 25, 65, 40) for 1-5 sigfigs).
This is a 1:1 port of the reference's do_round schedule entry and
prevents the wallet's sat-precise balance from leaking through to the
CoinJoin amount. Sweeps and explicit-amount phases never round.
The rounding is sampled at plan-build time and stored on
TakerCoinjoinPhase.rounding_sigfigs, so the persisted plan is
deterministic given the seed.
Maker exclusion across phases
Within a single tumble, the runner remembers which counterparty nicks
were used in the previous phase and passes them as exclude_nicks to
Taker.do_coinjoin for the next one. The exclusion window is one phase
deep (not cumulative): a longer window risks starving long plans of
counterparties when the orderbook is thin. This frustrates a coordinated
set of malicious makers from intersecting the same wallet across
consecutive phases of the same tumble.
The runner falls back to the legacy do_coinjoin signature on
TypeError so it stays compatible with reference takers and existing
test fakes that have not yet adopted the kwarg.
Maker policy in tumbler-driven sessions
Maker phases inside a tumbler plan run with a forced policy: absolute
fee cjfee_a = 0 and ordertype = sw0absoffer. This means the wallet
is offering free CoinJoin liquidity for the duration of the maker
session - which is exactly the role-mixing signal we want, since a
profit-motivated maker has a different on-chain footprint than a
mixing-motivated one. The cjfee_r field is left at the configured
value to keep relative-offer-only takers from rejecting our offer
outright (the reference taker implementation refuses cjfee_r=0).
The mutator lives in tumbler.maker_policy and is wired into both maker
factories. Tests in tumbler/tests/test_maker_policy.py pin the behavior.
Fee estimator
tumbler.estimator.estimate_plan_costs computes an upper bound on
total cost before the plan runs. The estimate covers:
- Counterparty fees per taker phase, taken as `max(abs, rel*amount)
- counterparty_count`, summed across all taker phases (sweeps included).
- Miner fees per taker phase, computed from the same coarse fee model the taker uses for its own operator prompts: ~68 vB per P2WPKH input, ~31 vB per P2WPKH output, plus ~11 vB fixed overhead, at the resolved sat/vB.
- Fee-rate source labelling:
configuredif the user passed--fee-rate,estimatedif the runner queriedestimatesmartfee,fallbackif neither was available (default 10 sat/vB).
The estimate is rendered by jm-tumbler plan so users see the
worst-case spend and can tune knobs before running. The same model is
used to assert the no-fund-loss invariant in tests.