Skip to content

tumbler.plan

tumbler.plan

Tumbler plan data model.

A Plan is an ordered list of Phase objects. Each phase is one of:

  • :class:TakerCoinjoinPhase - a single taker CoinJoin (optionally sweep).
  • :class:MakerSessionPhase - run a maker bot for a bounded time or until a target number of CoinJoins complete.

The plan and its phases form the single source of truth for a running tumble. Progress is persisted to a YAML file (see :mod:tumbler.persistence).

Attributes

MIN_DESTINATIONS = 3 module-attribute

Phase = Annotated[TakerCoinjoinPhase | MakerSessionPhase, Field(discriminator='kind')] module-attribute

Classes

MakerSessionPhase

Bases: _PhaseBase

Run a maker bot as part of the tumble.

The session ends when any configured bound is reached: duration_seconds elapses, target_cj_count CoinJoins have been served, or no CoinJoin has been served for idle_timeout_seconds (whichever comes first). At least one of duration_seconds or target_cj_count must be set; idle_timeout_seconds is an optional safety fallback so the phase does not hang forever when the maker is never chosen as counterparty.

Source code in tumbler/src/tumbler/plan.py
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
class MakerSessionPhase(_PhaseBase):
    """
    Run a maker bot as part of the tumble.

    The session ends when any configured bound is reached: ``duration_seconds``
    elapses, ``target_cj_count`` CoinJoins have been served, or no CoinJoin has
    been served for ``idle_timeout_seconds`` (whichever comes first). At least
    one of ``duration_seconds`` or ``target_cj_count`` must be set;
    ``idle_timeout_seconds`` is an optional safety fallback so the phase does
    not hang forever when the maker is never chosen as counterparty.
    """

    kind: Literal[PhaseKind.MAKER_SESSION] = PhaseKind.MAKER_SESSION
    duration_seconds: float | None = Field(default=None, gt=0.0)
    target_cj_count: int | None = Field(default=None, ge=1)
    idle_timeout_seconds: float | None = Field(default=None, gt=0.0)
    cj_served: int = Field(default=0, ge=0, description="CoinJoins served so far.")

    @model_validator(mode="after")
    def _validate_bound(self) -> MakerSessionPhase:
        if self.duration_seconds is None and self.target_cj_count is None:
            raise ValueError("MakerSessionPhase requires 'duration_seconds' or 'target_cj_count'")
        return self
Attributes
cj_served: int = Field(default=0, ge=0, description='CoinJoins served so far.') class-attribute instance-attribute
duration_seconds: float | None = Field(default=None, gt=0.0) class-attribute instance-attribute
idle_timeout_seconds: float | None = Field(default=None, gt=0.0) class-attribute instance-attribute
kind: Literal[PhaseKind.MAKER_SESSION] = PhaseKind.MAKER_SESSION class-attribute instance-attribute
target_cj_count: int | None = Field(default=None, ge=1) class-attribute instance-attribute

PhaseKind

Bases: StrEnum

Discriminator for the phase variants.

Source code in tumbler/src/tumbler/plan.py
24
25
26
27
28
class PhaseKind(StrEnum):
    """Discriminator for the phase variants."""

    TAKER_COINJOIN = "taker_coinjoin"
    MAKER_SESSION = "maker_session"
Attributes
MAKER_SESSION = 'maker_session' class-attribute instance-attribute
TAKER_COINJOIN = 'taker_coinjoin' class-attribute instance-attribute

PhaseStatus

Bases: StrEnum

Lifecycle of an individual phase.

Source code in tumbler/src/tumbler/plan.py
31
32
33
34
35
36
37
38
class PhaseStatus(StrEnum):
    """Lifecycle of an individual phase."""

    PENDING = "pending"
    RUNNING = "running"
    COMPLETED = "completed"
    FAILED = "failed"
    CANCELLED = "cancelled"
Attributes
CANCELLED = 'cancelled' class-attribute instance-attribute
COMPLETED = 'completed' class-attribute instance-attribute
FAILED = 'failed' class-attribute instance-attribute
PENDING = 'pending' class-attribute instance-attribute
RUNNING = 'running' class-attribute instance-attribute

Plan

Bases: BaseModel

A tumble plan with per-phase progress tracking.

Source code in tumbler/src/tumbler/plan.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
class Plan(BaseModel):
    """A tumble plan with per-phase progress tracking."""

    plan_id: str = Field(default_factory=lambda: uuid4().hex)
    wallet_name: str
    created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
    updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
    status: PlanStatus = PlanStatus.PENDING
    destinations: list[str] = Field(
        ...,
        min_length=1,
        description=(
            "External destination addresses. "
            f"The CLI enforces at least {MIN_DESTINATIONS} to avoid pairwise "
            "re-aggregation heuristics; library consumers may pass fewer."
        ),
    )
    parameters: PlanParameters = Field(default_factory=PlanParameters)
    phases: list[Phase] = Field(default_factory=list)
    current_phase: int = Field(
        default=0,
        ge=0,
        description="Index of the next phase to run (0 == plan not started).",
    )
    error: str | None = None

    @model_validator(mode="after")
    def _validate_phase_indices(self) -> Plan:
        for i, phase in enumerate(self.phases):
            if phase.index != i:
                raise ValueError(
                    f"phases[{i}].index must equal its list position (got {phase.index})"
                )
        if self.current_phase > len(self.phases):
            raise ValueError("current_phase exceeds number of phases")
        return self

    def current(self) -> Phase | None:
        """Return the phase at ``current_phase``, or ``None`` if the plan is done."""
        if self.current_phase >= len(self.phases):
            return None
        return self.phases[self.current_phase]

    def touch(self) -> None:
        """Update ``updated_at`` to now (UTC)."""
        self.updated_at = datetime.now(UTC)
Attributes
created_at: datetime = Field(default_factory=(lambda: datetime.now(UTC))) class-attribute instance-attribute
current_phase: int = Field(default=0, ge=0, description='Index of the next phase to run (0 == plan not started).') class-attribute instance-attribute
destinations: list[str] = Field(..., min_length=1, description=f'External destination addresses. The CLI enforces at least {MIN_DESTINATIONS} to avoid pairwise re-aggregation heuristics; library consumers may pass fewer.') class-attribute instance-attribute
error: str | None = None class-attribute instance-attribute
parameters: PlanParameters = Field(default_factory=PlanParameters) class-attribute instance-attribute
phases: list[Phase] = Field(default_factory=list) class-attribute instance-attribute
plan_id: str = Field(default_factory=(lambda: uuid4().hex)) class-attribute instance-attribute
status: PlanStatus = PlanStatus.PENDING class-attribute instance-attribute
updated_at: datetime = Field(default_factory=(lambda: datetime.now(UTC))) class-attribute instance-attribute
wallet_name: str instance-attribute
Methods:
current() -> Phase | None

Return the phase at current_phase, or None if the plan is done.

Source code in tumbler/src/tumbler/plan.py
227
228
229
230
231
def current(self) -> Phase | None:
    """Return the phase at ``current_phase``, or ``None`` if the plan is done."""
    if self.current_phase >= len(self.phases):
        return None
    return self.phases[self.current_phase]
touch() -> None

Update updated_at to now (UTC).

Source code in tumbler/src/tumbler/plan.py
233
234
235
def touch(self) -> None:
    """Update ``updated_at`` to now (UTC)."""
    self.updated_at = datetime.now(UTC)

PlanParameters

Bases: BaseModel

User-facing knobs captured for audit and resume. The builder records what it was told; the runner does not re-derive phases from these.

Source code in tumbler/src/tumbler/plan.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
class PlanParameters(BaseModel):
    """
    User-facing knobs captured for audit and resume. The builder records
    what it was told; the runner does not re-derive phases from these.
    """

    maker_count_min: int = Field(default=5, ge=1, le=20)
    maker_count_max: int = Field(default=9, ge=1, le=20)
    time_lambda_seconds: float = Field(default=6.0 * 60.0 * 60.0, gt=0.0)
    include_maker_sessions: bool = True
    mincjamount_sats: int = Field(default=100_000, ge=0)
    max_phase_retries: int = Field(
        default=3,
        ge=0,
        le=20,
        description="Maximum number of re-tries for a failed taker CoinJoin phase. "
        "Exhausting retries fails the entire plan.",
    )
    seed: int | None = None

    @model_validator(mode="after")
    def _validate_maker_count(self) -> PlanParameters:
        if self.maker_count_max < self.maker_count_min:
            raise ValueError("maker_count_max must be >= maker_count_min")
        return self
Attributes
include_maker_sessions: bool = True class-attribute instance-attribute
maker_count_max: int = Field(default=9, ge=1, le=20) class-attribute instance-attribute
maker_count_min: int = Field(default=5, ge=1, le=20) class-attribute instance-attribute
max_phase_retries: int = Field(default=3, ge=0, le=20, description='Maximum number of re-tries for a failed taker CoinJoin phase. Exhausting retries fails the entire plan.') class-attribute instance-attribute
mincjamount_sats: int = Field(default=100000, ge=0) class-attribute instance-attribute
seed: int | None = None class-attribute instance-attribute
time_lambda_seconds: float = Field(default=(6.0 * 60.0 * 60.0), gt=0.0) class-attribute instance-attribute

PlanStatus

Bases: StrEnum

Lifecycle of the overall plan.

Source code in tumbler/src/tumbler/plan.py
41
42
43
44
45
46
47
48
class PlanStatus(StrEnum):
    """Lifecycle of the overall plan."""

    PENDING = "pending"
    RUNNING = "running"
    COMPLETED = "completed"
    FAILED = "failed"
    CANCELLED = "cancelled"
Attributes
CANCELLED = 'cancelled' class-attribute instance-attribute
COMPLETED = 'completed' class-attribute instance-attribute
FAILED = 'failed' class-attribute instance-attribute
PENDING = 'pending' class-attribute instance-attribute
RUNNING = 'running' class-attribute instance-attribute

TakerCoinjoinPhase

Bases: _PhaseBase

A single taker CoinJoin.

Source code in tumbler/src/tumbler/plan.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
class TakerCoinjoinPhase(_PhaseBase):
    """A single taker CoinJoin."""

    kind: Literal[PhaseKind.TAKER_COINJOIN] = PhaseKind.TAKER_COINJOIN
    mixdepth: int = Field(..., ge=0, le=9)
    # Exactly one of amount / amount_fraction must be set (validated below).
    amount: int | None = Field(default=None, ge=0)
    amount_fraction: float | None = Field(default=None, ge=0.0, le=1.0)
    counterparty_count: int = Field(..., ge=1, le=20)
    destination: str = Field(
        ...,
        description="A bitcoin address, or the sentinel 'INTERNAL' to pick the "
        "next mixdepth's internal address at execution time.",
    )
    txid: str | None = Field(
        default=None, description="Broadcast txid, set once the CoinJoin confirms."
    )
    rounding_sigfigs: int | None = Field(
        default=None,
        ge=1,
        le=8,
        description=(
            "If set, round the resolved sat amount to this many significant "
            "figures before dispatching to the taker. Mirrors the reference "
            "implementation's ``rounding`` schedule entry: a sub-BTC amount "
            "like 0.13256 BTC rounded to 2 sigfigs becomes 0.13 BTC, which "
            "obfuscates the relationship between the wallet balance and the "
            "CoinJoin amount. Sweeps (amount==0 / amount_fraction==0) ignore "
            "this field."
        ),
    )

    @model_validator(mode="after")
    def _validate_amount(self) -> TakerCoinjoinPhase:
        if self.amount is None and self.amount_fraction is None:
            raise ValueError("TakerCoinjoinPhase requires 'amount' or 'amount_fraction'")
        if self.amount is not None and self.amount_fraction is not None:
            raise ValueError("TakerCoinjoinPhase must not set both 'amount' and 'amount_fraction'")
        return self

    @property
    def is_sweep(self) -> bool:
        """A sweep empties the mixdepth: amount==0 or amount_fraction==0."""
        return (self.amount == 0) or (self.amount_fraction == 0.0)
Attributes
amount: int | None = Field(default=None, ge=0) class-attribute instance-attribute
amount_fraction: float | None = Field(default=None, ge=0.0, le=1.0) class-attribute instance-attribute
counterparty_count: int = Field(..., ge=1, le=20) class-attribute instance-attribute
destination: str = Field(..., description="A bitcoin address, or the sentinel 'INTERNAL' to pick the next mixdepth's internal address at execution time.") class-attribute instance-attribute
is_sweep: bool property

A sweep empties the mixdepth: amount==0 or amount_fraction==0.

kind: Literal[PhaseKind.TAKER_COINJOIN] = PhaseKind.TAKER_COINJOIN class-attribute instance-attribute
mixdepth: int = Field(..., ge=0, le=9) class-attribute instance-attribute
rounding_sigfigs: int | None = Field(default=None, ge=1, le=8, description="If set, round the resolved sat amount to this many significant figures before dispatching to the taker. Mirrors the reference implementation's ``rounding`` schedule entry: a sub-BTC amount like 0.13256 BTC rounded to 2 sigfigs becomes 0.13 BTC, which obfuscates the relationship between the wallet balance and the CoinJoin amount. Sweeps (amount==0 / amount_fraction==0) ignore this field.") class-attribute instance-attribute
txid: str | None = Field(default=None, description='Broadcast txid, set once the CoinJoin confirms.') class-attribute instance-attribute

Functions:

round_to_significant_figures(value: int, sigfigs: int) -> int

Round value to sigfigs significant figures in base 10.

Mirrors round_to_significant_figures in the reference jmclient.taker: the smallest power of ten greater than value is used as the scale, then the value is rounded to sigfigs sigfigs around it. Examples (sigfigs=2)::

13_256_421 -> 13_000_000
9_876      -> 9_900
1_000_000  -> 1_000_000
0          -> 0

Raises ValueError if value is negative or sigfigs is not in [1, 8] (matching the model bounds).

Source code in tumbler/src/tumbler/plan.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
def round_to_significant_figures(value: int, sigfigs: int) -> int:
    """Round ``value`` to ``sigfigs`` significant figures in base 10.

    Mirrors ``round_to_significant_figures`` in the reference
    ``jmclient.taker``: the smallest power of ten greater than ``value`` is
    used as the scale, then the value is rounded to ``sigfigs`` sigfigs
    around it. Examples (``sigfigs=2``)::

        13_256_421 -> 13_000_000
        9_876      -> 9_900
        1_000_000  -> 1_000_000
        0          -> 0

    Raises ``ValueError`` if ``value`` is negative or ``sigfigs`` is not in
    ``[1, 8]`` (matching the model bounds).
    """
    if value < 0:
        raise ValueError("round_to_significant_figures requires a non-negative value")
    if not 1 <= sigfigs <= 8:
        raise ValueError("sigfigs must be in [1, 8]")
    if value == 0:
        return 0
    for p in range(-10, 20):
        power10 = 10**p
        if power10 > value:
            sf_power10 = 10**sigfigs
            return int(round(value / power10 * sf_power10) * power10 / sf_power10)
    raise RuntimeError("round_to_significant_figures: value out of range")