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. Only the last N chain mixdepths' sweeps target the user's destination addresses; every other sweep targetsINTERNAL. The terminal chain mixdepth emits no fractional CoinJoins: anINTERNALfraction from it would land in a mixdepth outside the chain that no later phase sweeps, stranding funds in the wallet (the reference tumbler applies the same cleanup inget_tumble_schedule). Its external sweep is therefore its only spend, guaranteeing the plan fully empties the wallet minus fees.
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 | skipped. 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. skipped is
terminal like completed: a resume never re-runs a skipped phase.
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. The rewrite is skipped when no later pending taker phase spends from the mixdepth theINTERNALdeposit would land in (notably the plan's final external sweep): diverting there would strand the funds in the wallet and leave the destination unpaid. - 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.
One class of failure bypasses the retry budget entirely: a taker phase
that fails with No eligible UTXOs in mixdepth while the mixdepth's
spendable (unfrozen, non-bond) balance is zero is marked skipped and
the plan advances immediately. Retrying cannot help — there is nothing
to mix — and this situation arises legitimately when the operator
freezes funds after the plan was built, or when the stage-2 chain visits
a mixdepth the actual coin flow never reached. Unconfirmed funds still
count as spendable, so confirmation-age failures keep the ordinary retry
behavior. Plan-time balances already exclude frozen UTXOs and fidelity
bonds, so skipped phases are the runtime safety net, not the norm.
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.
Legacy schedule projection (JAM)
While a plan is running, GET /api/v1/session exposes it to
authenticated clients through the legacy schedule field so JAM can
render its scheduler progress view. jmwalletd.legacy_schedule
projects the live plan into the reference's 7-element entries
([mixdepth, amount, counterparties, destination, wait_minutes,
rounding, flag]), where the flag is 0 before broadcast, the txid
while waiting for confirmations, and 1 once the plan advances past
the phase. Maker sessions have no legacy equivalent; their expected
duration and inter-phase wait are folded into the preceding taker
entry's wait time so JAM's ETA stays meaningful. The schedule is
computed from the in-memory runner on every request (never cached), and
is null whenever no tumble is running, matching the reference, which
also returns no schedule for single-shot taker CoinJoins.
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.