Skip to content

jmwalletd.routers.coinjoin

jmwalletd.routers.coinjoin

Maker and taker (coinjoin) endpoints.

Attributes

router = APIRouter() module-attribute

Classes

Functions:

build_coinjoin_taker_config(*, body: Any, mnemonic: Any, jm_settings: Any, taker_config_cls: Any) -> Any

Build a TakerConfig for a one-shot do_coinjoin request.

Delegates to :func:taker.config_builder.build_taker_config_kwargs (the same mapping the CLI taker and tumbler use) so a CoinJoin started through the daemon honors every [taker] policy setting (passed via config or TAKER__* env). This endpoint used to hand-maintain a mirror of that mapping, which silently drifted twice (issue #530, then the adaptive orderbook-wait knobs).

In particular minimum_makers is capped against the requested counterparties: a request for fewer makers than the policy minimum_makers (default 4) would otherwise select a valid N-maker CoinJoin and then reject it with Not enough makers selected: N.

Source code in jmwalletd/src/jmwalletd/routers/coinjoin.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def build_coinjoin_taker_config(
    *,
    body: Any,
    mnemonic: Any,
    jm_settings: Any,
    taker_config_cls: Any,
) -> Any:
    """Build a ``TakerConfig`` for a one-shot ``do_coinjoin`` request.

    Delegates to :func:`taker.config_builder.build_taker_config_kwargs` (the
    same mapping the CLI taker and tumbler use) so a CoinJoin started through
    the daemon honors every ``[taker]`` policy setting (passed via config or
    ``TAKER__*`` env). This endpoint used to hand-maintain a mirror of that
    mapping, which silently drifted twice (issue #530, then the adaptive
    orderbook-wait knobs).

    In particular ``minimum_makers`` is capped against the requested
    ``counterparties``: a request for fewer makers than the policy
    ``minimum_makers`` (default 4) would otherwise select a valid N-maker
    CoinJoin and then reject it with ``Not enough makers selected: N``.
    """
    from taker.config_builder import build_taker_config_kwargs

    kwargs = build_taker_config_kwargs(
        jm_settings,
        mnemonic,
        "",
        amount=body.amount_sats,
        destination=body.destination,
        mixdepth=body.mixdepth,
        counterparties=int(body.counterparties),
    )
    return taker_config_cls(**kwargs)

direct_send(walletname: str, body: DirectSendRequest, _auth: dict[str, Any] = Depends(require_auth), _wallet: None = Depends(require_wallet_match), state: DaemonState = Depends(get_daemon_state)) -> DirectSendResponse async

Send bitcoin directly (without coinjoin).

Source code in jmwalletd/src/jmwalletd/routers/coinjoin.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 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
@router.post("/wallet/{walletname}/taker/direct-send", operation_id="directsend")
async def direct_send(
    walletname: str,
    body: DirectSendRequest,
    _auth: dict[str, Any] = Depends(require_auth),
    _wallet: None = Depends(require_wallet_match),
    state: DaemonState = Depends(get_daemon_state),
) -> DirectSendResponse:
    """Send bitcoin directly (without coinjoin)."""
    if state.taker_running:
        raise ActionNotAllowed("A coinjoin is already in progress.")

    ws = state.wallet_service

    try:
        from jmwalletd.send import do_direct_send

        tx_result = await do_direct_send(
            wallet_service=ws,
            mixdepth=body.mixdepth,
            amount_sats=body.amount_sats,
            destination=body.destination,
            max_fee_rate_sat_vb=get_settings().wallet.max_fee_rate_sat_vb,
        )
    except ValueError as exc:
        raise InvalidRequestFormat(str(exc)) from exc
    except Exception as exc:
        logger.exception("Direct send failed")
        raise TransactionFailed(str(exc)) from exc

    # Build the txinfo response.
    txinfo = _build_txinfo(tx_result)

    # Notify WebSocket clients about the transaction immediately, and mark it
    # so the background transaction monitor does not emit a duplicate
    # first-seen notification for the same txid (it still reports confirmation).
    state.mark_tx_broadcast(txinfo.txid)
    state.broadcast_ws({"txid": txinfo.txid, "txdetails": txinfo.model_dump()})

    return DirectSendResponse(txinfo=txinfo)

do_coinjoin(walletname: str, body: DoCoinjoinRequest, _auth: dict[str, Any] = Depends(require_auth), _wallet: None = Depends(require_wallet_match), state: DaemonState = Depends(get_daemon_state)) -> JSONResponse async

Initiate a coinjoin transaction (asynchronous).

Source code in jmwalletd/src/jmwalletd/routers/coinjoin.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
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
188
189
190
191
192
193
194
195
196
@router.post("/wallet/{walletname}/taker/coinjoin", status_code=202, operation_id="docoinjoin")
async def do_coinjoin(
    walletname: str,
    body: DoCoinjoinRequest,
    _auth: dict[str, Any] = Depends(require_auth),
    _wallet: None = Depends(require_wallet_match),
    state: DaemonState = Depends(get_daemon_state),
) -> JSONResponse:
    """Initiate a coinjoin transaction (asynchronous)."""
    if state.coinjoin_state != CoinjoinState.NOT_RUNNING:
        raise ServiceAlreadyStarted("A coinjoin or maker service is already running.")
    if not state.wallet_mnemonic:
        raise NoWalletFound("Wallet mnemonic not available in daemon state.")

    try:
        from jmwalletd._backend import get_backend
        from taker.config import TakerConfig
        from taker.taker import Taker

        state.activate_coinjoin_state(CoinjoinState.TAKER_RUNNING)

        async def _run_coinjoin() -> None:
            taker: Any | None = None
            try:
                backend = await get_backend(
                    state.data_dir,
                    force_new=True,
                    mnemonic=state.wallet_mnemonic,
                    network=get_settings().network_config.network.value,
                )
                jm_settings = get_settings()
                config = build_coinjoin_taker_config(
                    body=body,
                    mnemonic=state.wallet_mnemonic,
                    jm_settings=jm_settings,
                    taker_config_cls=TakerConfig,
                )
                taker = Taker(
                    wallet=ws,
                    backend=backend,
                    config=config,
                )
                state._taker_ref = taker
                await taker.start()
                await taker.do_coinjoin(
                    amount=body.amount_sats,
                    destination=body.destination,
                    mixdepth=body.mixdepth,
                    counterparty_count=body.counterparties,
                )
            except Exception:
                logger.exception("Coinjoin failed")
            finally:
                # Always tear down the taker so its directory-client and
                # background tasks do not leak. Keep the shared wallet open
                # for any subsequent operation on the daemon.
                if taker is not None:
                    try:
                        await taker.stop(close_wallet=False)
                    except Exception:
                        logger.exception("Taker teardown failed")
                state.activate_coinjoin_state(CoinjoinState.NOT_RUNNING)
                state._taker_ref = None

        ws = state.wallet_service
        state._taker_task = asyncio.create_task(_run_coinjoin())

    except ImportError:
        state.activate_coinjoin_state(CoinjoinState.NOT_RUNNING)
        raise BackendNotReady("Taker module not available.") from None
    except Exception as exc:
        state.activate_coinjoin_state(CoinjoinState.NOT_RUNNING)
        raise BackendNotReady(str(exc)) from exc

    return JSONResponse(content={}, status_code=202)

start_maker(walletname: str, body: StartMakerRequest, _auth: dict[str, Any] = Depends(require_auth), _wallet: None = Depends(require_wallet_match), state: DaemonState = Depends(get_daemon_state)) -> JSONResponse async

Start the yield generator (maker) service.

Source code in jmwalletd/src/jmwalletd/routers/coinjoin.py
234
235
236
237
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
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
@router.post("/wallet/{walletname}/maker/start", status_code=202, operation_id="startmaker")
async def start_maker(
    walletname: str,
    body: StartMakerRequest,
    _auth: dict[str, Any] = Depends(require_auth),
    _wallet: None = Depends(require_wallet_match),
    state: DaemonState = Depends(get_daemon_state),
) -> JSONResponse:
    """Start the yield generator (maker) service."""
    if state.coinjoin_state != CoinjoinState.NOT_RUNNING:
        raise ServiceAlreadyStarted("A coinjoin or maker service is already running.")
    if not state.wallet_mnemonic:
        raise NoWalletFound("Wallet mnemonic not available in daemon state.")

    # Parse maker parameters.
    try:
        txfee = int(body.txfee)
        cjfee_a = int(body.cjfee_a)
        cjfee_r = str(body.cjfee_r)
        minsize = int(body.minsize)
    except ValueError as exc:
        raise InvalidRequestFormat(f"Invalid maker parameter: {exc}") from exc

    try:
        from jmwalletd._backend import get_backend
        from maker.bot import MakerBot
        from maker.config import MakerConfig

        state.activate_coinjoin_state(CoinjoinState.MAKER_RUNNING)

        async def _run_maker() -> None:
            try:
                ws = state.wallet_service
                backend = await get_backend(
                    state.data_dir,
                    force_new=True,
                    wallet_service=ws,
                )
                jm_settings = get_settings()
                config = MakerConfig(
                    mnemonic=state.wallet_mnemonic,
                    offer_type=body.ordertype,  # type: ignore[arg-type]
                    min_size=minsize,
                    cj_fee_relative=cjfee_r,
                    cj_fee_absolute=cjfee_a,
                    tx_fee_contribution=txfee,
                    network=jm_settings.network_config.network,
                    directory_servers=jm_settings.get_directory_servers(),
                    socks_host=jm_settings.tor.socks_host,
                    socks_port=jm_settings.tor.socks_port,
                    stream_isolation=jm_settings.tor.stream_isolation,
                    # Log maker history into the daemon's data dir so the
                    # yieldgen/report endpoint (which reads from state.data_dir)
                    # reflects this maker's earnings (#531).
                    data_dir=state.data_dir,
                )
                maker = MakerBot(
                    wallet=ws,
                    backend=backend,
                    config=config,
                )
                state._maker_ref = maker
                state.nickname = maker.nick
                write_nick_state(state.data_dir, "maker", maker.nick)

                await maker.start()
                # NOTE: maker.start() blocks until shutdown (it awaits
                # asyncio.gather on listen tasks).  The session endpoint
                # now reads current_offers directly from the maker ref,
                # so there is nothing to do here.
            except Exception:
                logger.exception("Maker failed")
            finally:
                state.activate_coinjoin_state(CoinjoinState.NOT_RUNNING)
                state.offer_list = None
                state.nickname = None
                state._maker_ref = None
                state._maker_task = None
                remove_nick_state(state.data_dir, "maker")

        state._maker_task = asyncio.create_task(_run_maker())
    except ImportError:
        state.activate_coinjoin_state(CoinjoinState.NOT_RUNNING)
        raise BackendNotReady("Maker module not available.") from None
    except Exception as exc:
        state.activate_coinjoin_state(CoinjoinState.NOT_RUNNING)
        raise BackendNotReady(str(exc)) from exc

    return JSONResponse(content={}, status_code=202)

stop_coinjoin(walletname: str, _auth: dict[str, Any] = Depends(require_auth), _wallet: None = Depends(require_wallet_match), state: DaemonState = Depends(get_daemon_state)) -> JSONResponse async

Stop a running coinjoin/tumbler.

Source code in jmwalletd/src/jmwalletd/routers/coinjoin.py
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
@router.get("/wallet/{walletname}/taker/stop", status_code=202, operation_id="stopcoinjoin")
async def stop_coinjoin(
    walletname: str,
    _auth: dict[str, Any] = Depends(require_auth),
    _wallet: None = Depends(require_wallet_match),
    state: DaemonState = Depends(get_daemon_state),
) -> JSONResponse:
    """Stop a running coinjoin/tumbler."""
    if not state.taker_running:
        raise ServiceNotStarted()

    # Signal the taker to stop if a reference is held.
    if state._taker_ref is not None:
        try:
            await state._taker_ref.stop()
        except Exception:
            logger.exception("Error stopping taker")

    if state._taker_task is not None and not state._taker_task.done():
        state._taker_task.cancel()
        with contextlib.suppress(asyncio.CancelledError, Exception):
            await state._taker_task

    state.activate_coinjoin_state(CoinjoinState.NOT_RUNNING)
    state._taker_ref = None
    state._taker_task = None
    return JSONResponse(content={}, status_code=202)

stop_maker(walletname: str, _auth: dict[str, Any] = Depends(require_auth), _wallet: None = Depends(require_wallet_match), state: DaemonState = Depends(get_daemon_state)) -> JSONResponse async

Stop the yield generator (maker) service.

Source code in jmwalletd/src/jmwalletd/routers/coinjoin.py
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
@router.get("/wallet/{walletname}/maker/stop", status_code=202, operation_id="stopmaker")
async def stop_maker(
    walletname: str,
    _auth: dict[str, Any] = Depends(require_auth),
    _wallet: None = Depends(require_wallet_match),
    state: DaemonState = Depends(get_daemon_state),
) -> JSONResponse:
    """Stop the yield generator (maker) service."""
    if not state.maker_running:
        raise ServiceNotStarted()

    # Signal the maker to stop if a reference is held.
    if state._maker_ref is not None:
        try:
            await state._maker_ref.stop()
        except Exception:
            logger.exception("Error stopping maker")

    if state._maker_task is not None and not state._maker_task.done():
        state._maker_task.cancel()
        with contextlib.suppress(asyncio.CancelledError, Exception):
            await state._maker_task

    state.activate_coinjoin_state(CoinjoinState.NOT_RUNNING)
    state.offer_list = None
    state.nickname = None
    state._maker_ref = None
    state._maker_task = None
    return JSONResponse(content={}, status_code=202)