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, config_overrides: dict[str, dict[str, str]] | None = None) -> 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.

config_overrides is the daemon's in-memory configset store; the fee policy JAM writes there ([POLICY] tx_fees etc.) is applied on top of the settings so a sat/vB rate chosen in the UI is honored (issue #566).

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
72
73
74
75
76
77
78
79
80
81
82
83
def build_coinjoin_taker_config(
    *,
    body: Any,
    mnemonic: Any,
    jm_settings: Any,
    taker_config_cls: Any,
    config_overrides: dict[str, dict[str, str]] | None = None,
) -> 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``.

    ``config_overrides`` is the daemon's in-memory ``configset`` store; the
    fee policy JAM writes there (``[POLICY] tx_fees`` etc.) is applied on top
    of the settings so a sat/vB rate chosen in the UI is honored (issue #566).
    """
    from jmwalletd.fee_policy import resolve_policy_fee_overrides
    from taker.config_builder import build_taker_config_kwargs

    fee_overrides = resolve_policy_fee_overrides(config_overrides)
    kwargs = build_taker_config_kwargs(
        jm_settings,
        mnemonic,
        "",
        amount=body.amount_sats,
        destination=body.destination,
        mixdepth=body.mixdepth,
        counterparties=int(body.counterparties),
        max_abs_fee=fee_overrides.max_cj_fee_abs,
        max_rel_fee=fee_overrides.max_cj_fee_rel,
        fee_rate=fee_overrides.fee_rate,
        block_target=fee_overrides.block_target,
        tx_fee_factor=fee_overrides.tx_fee_factor,
    )
    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
 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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
@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.fee_policy import resolve_policy_fee_overrides
        from jmwalletd.send import do_direct_send

        # Honor the fee policy JAM stores via configset ([POLICY] tx_fees):
        # a manual sat/vB rate or block target set in the UI applies to
        # direct sends too (issue #566).
        fee_overrides = resolve_policy_fee_overrides(state.config_overrides)
        settings = get_settings()
        fee_target_blocks = fee_overrides.block_target
        if fee_overrides.fee_rate is None and fee_target_blocks is None:
            fee_target_blocks = settings.wallet.default_fee_block_target
        tx_result = await do_direct_send(
            wallet_service=ws,
            mixdepth=body.mixdepth,
            amount_sats=body.amount_sats,
            destination=body.destination,
            fee_rate=fee_overrides.fee_rate,
            fee_target_blocks=fee_target_blocks,
            tx_fee_factor=(
                fee_overrides.tx_fee_factor
                if fee_overrides.tx_fee_factor is not None
                else settings.taker.tx_fee_factor
            ),
            max_fee_rate_sat_vb=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
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
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
@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:
                jm_settings = get_settings()
                bitcoin_network = (
                    jm_settings.network_config.bitcoin_network or jm_settings.network_config.network
                )
                backend = await get_backend(
                    state.data_dir,
                    force_new=True,
                    mnemonic=state.wallet_mnemonic,
                    network=bitcoin_network.value,
                )
                config = build_coinjoin_taker_config(
                    body=body,
                    mnemonic=state.wallet_mnemonic,
                    jm_settings=jm_settings,
                    taker_config_cls=TakerConfig,
                    config_overrides=state.config_overrides,
                )
                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
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
323
324
325
326
327
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
357
358
359
360
361
362
363
364
365
366
367
368
@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:
            maker: MakerBot | None = None
            backend: Any | None = 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(),
                    nick_auth_mode=jm_settings.network_config.nick_auth_mode,
                    nick_auth_directory_ids=jm_settings.network_config.nick_auth_directory_ids,
                    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:
                if maker is not None:
                    try:
                        await maker.stop()
                    except Exception:
                        logger.exception("Error stopping maker after its run ended")
                if backend is not None:
                    try:
                        await backend.close()
                    except Exception:
                        logger.exception("Error closing maker blockchain backend")
                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
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
@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
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
@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)