Skip to content

jmcore.confirmation

jmcore.confirmation

User confirmation prompts for fund-moving operations.

Functions

confirm_transaction(operation: str, amount: int, destination: str | None = None, fee: int | None = None, mining_fee: int | None = None, additional_info: dict[str, Any] | None = None, skip_confirmation: bool = False, stage: str = '') -> bool

Prompt user to confirm a transaction that moves funds.

Args: operation: Type of operation (e.g., "send", "coinjoin") amount: Amount in satoshis (0 for sweep) destination: Destination address (optional) fee: Total fee in satoshis (optional, for CoinJoin this is maker fees + mining fee) mining_fee: Mining/transaction fee in satoshis (optional) additional_info: Additional details to show (e.g., maker fees, counterparties) skip_confirmation: If True, skip prompt (from --yes flag) stage: Prompt stage identifier. Pass "initial" for the maker-selection estimate and "broadcast" for the final pre-broadcast confirmation. Shown in the section header so users can tell the two prompts apart.

Returns: True if user confirms, False otherwise

Raises: RuntimeError: If in non-interactive mode without skip_confirmation

Source code in jmcore/src/jmcore/confirmation.py
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
229
230
231
232
233
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
def confirm_transaction(
    operation: str,
    amount: int,
    destination: str | None = None,
    fee: int | None = None,
    mining_fee: int | None = None,
    additional_info: dict[str, Any] | None = None,
    skip_confirmation: bool = False,
    stage: str = "",
) -> bool:
    """
    Prompt user to confirm a transaction that moves funds.

    Args:
        operation: Type of operation (e.g., "send", "coinjoin")
        amount: Amount in satoshis (0 for sweep)
        destination: Destination address (optional)
        fee: Total fee in satoshis (optional, for CoinJoin this is maker fees + mining fee)
        mining_fee: Mining/transaction fee in satoshis (optional)
        additional_info: Additional details to show (e.g., maker fees, counterparties)
        skip_confirmation: If True, skip prompt (from --yes flag)
        stage: Prompt stage identifier.  Pass "initial" for the maker-selection
            estimate and "broadcast" for the final pre-broadcast confirmation.
            Shown in the section header so users can tell the two prompts apart.

    Returns:
        True if user confirms, False otherwise

    Raises:
        RuntimeError: If in non-interactive mode without skip_confirmation
    """
    # Skip if confirmation disabled
    if skip_confirmation:
        return True

    # Error if non-interactive without --yes
    if not is_interactive_mode():
        raise RuntimeError(
            "Cannot prompt for confirmation in non-interactive mode. "
            "Use --yes flag or set NO_INTERACTIVE=1 to skip confirmation."
        )

    # Use different display for coinjoin vs regular transactions
    if operation.lower() == "coinjoin":
        _display_coinjoin_send_confirmation(
            amount=amount,
            destination=destination,
            mining_fee=mining_fee,
            additional_info=additional_info,
            stage=stage,
        )
    else:
        _display_standard_send_confirmation(
            operation=operation,
            amount=amount,
            destination=destination,
            fee=fee,
            mining_fee=mining_fee,
            additional_info=additional_info,
        )

    # Prompt for confirmation - flush stdout and clear any buffered stdin
    try:
        sys.stdout.flush()
        # Drain any pending input to ensure we get fresh user input
        # (important when running in asyncio context with logging)
        try:
            import termios

            # Flush input buffer to discard any stale data
            termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH)
        except ImportError:
            # Not Unix
            pass
        except (OSError, ValueError):
            # Not a TTY or no terminal settings available
            pass

        response = input("\nProceed with this transaction? [y/N]: ").strip().lower()
        return response in ("y", "yes")
    except (KeyboardInterrupt, EOFError):
        print("\n\nTransaction cancelled by user.")
        return False

format_maker_summary(makers: list[dict[str, Any]], fee_rate: float | None = None) -> dict[str, Any]

Format maker information for confirmation display.

Args: makers: List of selected maker dicts with 'nick', 'fee', 'bond_value', 'location', etc. fee_rate: Fee rate in sat/vB (optional)

Returns: Dict with formatted maker info for confirmation display

Source code in jmcore/src/jmcore/confirmation.py
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
def format_maker_summary(
    makers: list[dict[str, Any]], fee_rate: float | None = None
) -> dict[str, Any]:
    """
    Format maker information for confirmation display.

    Args:
        makers: List of selected maker dicts with 'nick', 'fee', 'bond_value', 'location', etc.
        fee_rate: Fee rate in sat/vB (optional)

    Returns:
        Dict with formatted maker info for confirmation display
    """
    total_maker_fee = sum(m.get("fee", 0) for m in makers)

    # Find max widths for alignment
    max_fee_width = max((len(f"{m.get('fee', 0):,}") for m in makers), default=1)
    max_bond_width = max((len(f"{m.get('bond_value', 0):,}") for m in makers), default=1)

    maker_details = []
    for m in makers:
        nick = m.get("nick", "unknown")
        fee = m.get("fee", 0)
        bond_value = m.get("bond_value", 0)
        location = m.get("location")

        # Right-align fee and bond values
        fee_str = f"{fee:>{max_fee_width},}"
        bond_str = f" [bond: {bond_value:>{max_bond_width},}]" if bond_value > 0 else " [no bond]"

        # Add location info if available
        if location and location != "NOT-SERVING-ONION":
            # Truncate onion address for readability (show first 16 chars)
            if ":" in location:
                onion, port = location.rsplit(":", 1)
                if onion.endswith(".onion") and len(onion) > 20:
                    location_str = f" @ {onion[:16]}...:{port}"
                else:
                    location_str = f" @ {location}"
            else:
                location_str = f" @ {location[:20]}..."
            maker_details.append(f"{nick}: {fee_str} sats{bond_str}{location_str}")
        else:
            maker_details.append(f"{nick}: {fee_str} sats{bond_str}")

    result: dict[str, Any] = {
        "Total Maker Fee": total_maker_fee,
        "Makers": maker_details,
    }

    if fee_rate is not None:
        result["Fee Rate"] = fee_rate

    return result

is_interactive_mode() -> bool

Check if we're running in interactive mode.

Returns False if NO_INTERACTIVE env var is set or if not attached to a TTY.

Source code in jmcore/src/jmcore/confirmation.py
12
13
14
15
16
17
18
19
20
def is_interactive_mode() -> bool:
    """
    Check if we're running in interactive mode.

    Returns False if NO_INTERACTIVE env var is set or if not attached to a TTY.
    """
    if os.environ.get("NO_INTERACTIVE"):
        return False
    return sys.stdin.isatty() and sys.stdout.isatty()