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. Only the last N chain mixdepths' sweeps target the user's destination addresses; every other sweep targets INTERNAL. The terminal chain mixdepth emits no fractional CoinJoins: an INTERNAL fraction 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 in get_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_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. The rewrite is skipped when no later pending taker phase spends from the mixdepth the INTERNAL deposit 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 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.

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 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.

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: 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.