Skip to content

jmwalletd.send

jmwalletd.send

Direct-send helper for jmwalletd.

Builds, signs, broadcasts, and records direct (non-coinjoin) transactions in history.csv following the two-phase history persistence pattern.

Attributes

Classes

Functions:

do_direct_send(*, wallet_service: WalletService, mixdepth: int, amount_sats: int, destination: str, fee_rate: float | None = None, fee_target_blocks: int | None = None, tx_fee_factor: float = 0.0, max_fee_rate_sat_vb: float = DEFAULT_MAX_FEE_RATE_SAT_VB) -> DirectSendResult async

Build, sign, broadcast, and record a direct (non-coinjoin) transaction.

fee_rate (sat/vB) takes priority; otherwise fee_target_blocks drives backend estimation (None keeps the spend module's default target). tx_fee_factor applies the reference fee-rate randomization, and max_fee_rate_sat_vb applies the operator's configured hard cap.

Follows the same two-phase history-write pattern as the CLI send command:

  1. A role="send" row is written to history.csv with failure_reason="awaiting broadcast" before the broadcast call. This permanently marks destination and change addresses as used, even if broadcast fails — the signed bytes are already outside the wallet's control.
  2. After broadcast resolves (success or failure) the row is patched in-place via :func:~jmwallet.history.update_send_awaiting_broadcast.

Persistence failures are logged as warnings and never block the broadcast.

Source code in jmwalletd/src/jmwalletd/send.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 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
 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
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
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
async def do_direct_send(
    *,
    wallet_service: WalletService,
    mixdepth: int,
    amount_sats: int,
    destination: str,
    fee_rate: float | None = None,
    fee_target_blocks: int | None = None,
    tx_fee_factor: float = 0.0,
    max_fee_rate_sat_vb: float = DEFAULT_MAX_FEE_RATE_SAT_VB,
) -> DirectSendResult:
    """Build, sign, broadcast, and record a direct (non-coinjoin) transaction.

    ``fee_rate`` (sat/vB) takes priority; otherwise ``fee_target_blocks``
    drives backend estimation (``None`` keeps the spend module's default
    target). ``tx_fee_factor`` applies the reference fee-rate randomization,
    and ``max_fee_rate_sat_vb`` applies the operator's configured hard cap.

    Follows the same two-phase history-write pattern as the CLI send command:

    1. A ``role="send"`` row is written to ``history.csv`` with
       ``failure_reason="awaiting broadcast"`` **before** the broadcast call.
       This permanently marks destination and change addresses as used,
       even if broadcast fails — the signed bytes are already outside
       the wallet's control.
    2. After broadcast resolves (success or failure) the row is patched
       in-place via :func:`~jmwallet.history.update_send_awaiting_broadcast`.

    Persistence failures are logged as warnings and never block the broadcast.
    """
    from jmcore.paths import get_default_data_dir
    from jmwalletd._backend import get_backend

    data_dir: Path = wallet_service.data_dir or get_default_data_dir()
    backend = await get_backend(data_dir, wallet_service=wallet_service)

    # Preserve fidelity bond UTXOs on every backend. Plain sync omits branch 2
    # on address-scanning backends and can drop the bond immediately before an
    # expired-bond sweep.
    await wallet_service.sync_with_registered_bonds()

    logger.info(
        "Direct send: {} sats from mixdepth {} to {} (max fee rate {:.2f} sat/vB)",
        amount_sats or "sweep",
        mixdepth,
        destination,
        max_fee_rate_sat_vb,
    )

    extra_kwargs: dict[str, int] = {}
    if fee_target_blocks is not None:
        extra_kwargs["fee_target_blocks"] = fee_target_blocks

    # --- Phase 1: build + sign (no broadcast yet) ---
    prepared = await prepare_direct_send(
        wallet=wallet_service,
        backend=backend,
        mixdepth=mixdepth,
        amount_sats=amount_sats,
        destination=destination,
        fee_rate=fee_rate,
        tx_fee_factor=tx_fee_factor,
        max_fee_rate_sat_vb=max_fee_rate_sat_vb,
        **extra_kwargs,
    )

    # --- Phase 1b: persist pending history row BEFORE broadcast ---
    network: str = getattr(wallet_service, "network", "mainnet")
    wallet_fingerprint: str = getattr(wallet_service, "wallet_fingerprint", "") or getattr(
        wallet_service, "fingerprint", ""
    )
    send_entry = create_send_history_entry(
        destination=destination,
        change_address=prepared.change_address,
        amount=prepared.send_amount,
        mining_fee=prepared.fee,
        source_mixdepth=mixdepth,
        selected_utxos=prepared.selected_utxos,
        txid="",
        success=False,
        failure_reason="awaiting broadcast",
        network=network,
        wallet_fingerprint=wallet_fingerprint,
        source_addresses=prepared.source_addresses,
    )
    history_persisted = False
    try:
        append_history_entry(send_entry, data_dir=data_dir)
        history_persisted = True
    except Exception as exc:
        logger.warning("Failed to persist pre-broadcast send history entry: {}", exc)

    # --- Phase 2: broadcast ---
    txid = ""
    broadcast_exc: Exception | None = None
    try:
        logger.info(
            "Broadcasting direct-send transaction ({} bytes)",
            len(bytes.fromhex(prepared.tx_hex)),
        )
        txid = (await backend.broadcast_transaction(prepared.tx_hex)) or prepared.txid
        logger.info("Broadcast OK: {}", txid)
    except Exception as exc:
        broadcast_exc = exc
        logger.error("Broadcast failed: {}", exc)

    # --- Phase 3: finalize history row (success or failure) ---
    if history_persisted:
        try:
            updated = update_send_awaiting_broadcast(
                send_entry,
                txid=txid,
                success=broadcast_exc is None,
                failure_reason=""
                if broadcast_exc is None
                else f"broadcast failed: {broadcast_exc}",
                data_dir=data_dir,
            )
            if not updated:
                logger.warning("Could not find pre-broadcast send history entry to finalize")
        except Exception as exc:
            logger.warning("Failed to finalize send history entry: {}", exc)

    if broadcast_exc is not None:
        raise broadcast_exc

    return DirectSendResult(
        txid=txid,
        tx_hex=prepared.tx_hex,
        fee=prepared.fee,
        fee_rate=prepared.fee_rate,
        send_amount=prepared.send_amount,
        change_amount=prepared.change_amount,
        num_inputs=prepared.num_inputs,
        num_outputs=prepared.num_outputs,
        inputs=prepared.inputs,
        outputs=prepared.outputs,
    )