Skip to content

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 a Plan from per-mixdepth balances, a destination list, and PlanParameters.
  • TumbleRunner — consumes a Plan and 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. Carries mixdepth, one of amount (sats; 0 means sweep) or amount_fraction (of the current mixdepth balance), destination (an external address or the literal "INTERNAL"), counterparty_count, and an optional rounding_sigfigs (significant-figure count for amount obfuscation).
  • maker_session — runs MakerBot for a bounded window or until a target number of CoinJoins have been served, whichever comes first. Carries maker_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_coinjoin sweep into the next mixdepth with destination=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 append mintxcount - 1 fractional taker CoinJoins, then a final taker sweep. The very last sweep in stage 2 targets the user's destination address; every other sweep targets INTERNAL.

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_count on 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 mixdepth and 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 below 0.96, guaranteeing a trailing sweep with non-dust value.
  • Per-phase counterparty fee bound matches max(cj_fee_abs, cj_fee_rel * amount) * counterparty_count so 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: configured if the user passed --fee-rate, estimated if the runner queried estimatesmartfee, fallback if 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.