Module maker.cli

Maker bot CLI using Typer.

Configuration is loaded with the following priority (highest to lowest): 1. CLI arguments 2. Environment variables 3. Config file (~/.joinmarket-ng/config.toml) 4. Built-in defaults

Functions

def build_maker_config(settings: JoinMarketSettings,
mnemonic: str,
passphrase: str,
network: NetworkType | None = None,
bitcoin_network: NetworkType | None = None,
data_dir: Path | None = None,
backend_type: str | None = None,
rpc_url: str | None = None,
rpc_user: str | None = None,
rpc_password: str | None = None,
neutrino_url: str | None = None,
directory_servers: str | None = None,
tor_socks_host: str | None = None,
tor_socks_port: int | None = None,
tor_control_host: str | None = None,
tor_control_port: int | None = None,
tor_cookie_path: Path | None = None,
disable_tor_control: bool = False,
onion_serving_host: str | None = None,
onion_serving_port: int | None = None,
tor_target_host: str | None = None,
min_size: int | None = None,
cj_fee_relative: str | None = None,
cj_fee_absolute: int | None = None,
tx_fee_contribution: int | None = None,
merge_algorithm: str | None = None,
fidelity_bond_locktimes: list[int] | None = None,
fidelity_bond_index: int | None = None,
dual_offers: bool = False) ‑> MakerConfig
Expand source code
def build_maker_config(
    settings: JoinMarketSettings,
    mnemonic: str,
    passphrase: str,
    # CLI overrides (None means use settings value)
    network: NetworkType | None = None,
    bitcoin_network: NetworkType | None = None,
    data_dir: Path | None = None,
    backend_type: str | None = None,
    rpc_url: str | None = None,
    rpc_user: str | None = None,
    rpc_password: str | None = None,
    neutrino_url: str | None = None,
    directory_servers: str | None = None,
    tor_socks_host: str | None = None,
    tor_socks_port: int | None = None,
    tor_control_host: str | None = None,
    tor_control_port: int | None = None,
    tor_cookie_path: Path | None = None,
    disable_tor_control: bool = False,
    onion_serving_host: str | None = None,
    onion_serving_port: int | None = None,
    tor_target_host: str | None = None,
    min_size: int | None = None,
    cj_fee_relative: str | None = None,
    cj_fee_absolute: int | None = None,
    tx_fee_contribution: int | None = None,
    merge_algorithm: str | None = None,
    fidelity_bond_locktimes: list[int] | None = None,
    fidelity_bond_index: int | None = None,
    dual_offers: bool = False,
) -> MakerConfig:
    """
    Build MakerConfig from unified settings with CLI overrides.

    CLI arguments (when not None) override settings from config file and env vars.
    """
    # Resolve network settings
    effective_network = network if network is not None else settings.network_config.network
    effective_bitcoin_network = (
        bitcoin_network
        if bitcoin_network is not None
        else settings.network_config.bitcoin_network or effective_network
    )
    effective_data_dir = data_dir if data_dir is not None else settings.get_data_dir()

    # Resolve backend settings
    effective_backend_type = (
        backend_type if backend_type is not None else settings.bitcoin.backend_type
    )
    effective_rpc_url = rpc_url if rpc_url is not None else settings.bitcoin.rpc_url
    effective_rpc_user = rpc_user if rpc_user is not None else settings.bitcoin.rpc_user
    effective_rpc_password = (
        rpc_password
        if rpc_password is not None
        else settings.bitcoin.rpc_password.get_secret_value()
    )
    effective_neutrino_url = (
        neutrino_url if neutrino_url is not None else settings.bitcoin.neutrino_url
    )

    # Build backend config
    backend_config: dict[str, Any] = {}
    if effective_backend_type in ("scantxoutset", "descriptor_wallet"):
        backend_config = {
            "rpc_url": effective_rpc_url,
            "rpc_user": effective_rpc_user,
            "rpc_password": effective_rpc_password,
        }
    elif effective_backend_type == "neutrino":
        backend_config = {
            "neutrino_url": effective_neutrino_url,
            "network": (
                effective_bitcoin_network.value
                if hasattr(effective_bitcoin_network, "value")
                else str(effective_bitcoin_network)
            ),
        }

    # Resolve directory servers
    # If CLI provides directory servers, use those
    # Otherwise, if network was overridden via CLI, use defaults for that network
    # Otherwise, use settings (which may have custom servers or default for settings network)
    if directory_servers:
        dir_servers = [s.strip() for s in directory_servers.split(",")]
    elif settings.network_config.directory_servers:
        dir_servers = settings.network_config.directory_servers
    elif network is not None:
        # Network was overridden via CLI, get defaults for that network
        from jmcore.settings import DEFAULT_DIRECTORY_SERVERS

        dir_servers = DEFAULT_DIRECTORY_SERVERS.get(effective_network.value, [])
    else:
        dir_servers = settings.get_directory_servers()

    # Resolve Tor settings
    effective_socks_host = tor_socks_host if tor_socks_host is not None else settings.tor.socks_host
    effective_socks_port = tor_socks_port if tor_socks_port is not None else settings.tor.socks_port

    # Resolve Tor control settings
    if disable_tor_control:
        tor_control_cfg = TorControlConfig(enabled=False)
    else:
        # tor_control host defaults to tor.socks_host
        effective_control_host = (
            tor_control_host if tor_control_host is not None else settings.tor.control_host
        )
        effective_control_port = (
            tor_control_port if tor_control_port is not None else settings.tor.control_port
        )
        effective_cookie_path = None
        if tor_cookie_path is not None:
            effective_cookie_path = tor_cookie_path
        elif settings.tor.cookie_path:
            effective_cookie_path = Path(settings.tor.cookie_path)

        tor_control_cfg = TorControlConfig(
            enabled=settings.tor.control_enabled,
            host=effective_control_host,
            port=effective_control_port,
            cookie_path=effective_cookie_path,
            password=settings.tor.password if settings.tor.password else None,
        )

    # Resolve maker-specific settings
    effective_onion_host = (
        onion_serving_host if onion_serving_host is not None else settings.maker.onion_serving_host
    )
    effective_onion_port = (
        onion_serving_port if onion_serving_port is not None else settings.maker.onion_serving_port
    )
    effective_target_host = (
        tor_target_host if tor_target_host is not None else settings.tor.target_host
    )
    effective_min_size = min_size if min_size is not None else settings.maker.min_size
    effective_tx_fee = (
        tx_fee_contribution
        if tx_fee_contribution is not None
        else settings.maker.tx_fee_contribution
    )

    # Determine offer type and fee values
    # CLI explicit values take precedence
    offer_configs: list[OfferConfig] = []

    if dual_offers:
        # Create both relative and absolute offers
        # Use CLI values if provided, otherwise use settings
        rel_fee = cj_fee_relative if cj_fee_relative is not None else settings.maker.cj_fee_relative
        abs_fee = cj_fee_absolute if cj_fee_absolute is not None else settings.maker.cj_fee_absolute
        tx_fee = (
            tx_fee_contribution
            if tx_fee_contribution is not None
            else settings.maker.tx_fee_contribution
        )
        min_sz = min_size if min_size is not None else settings.maker.min_size

        offer_configs = [
            OfferConfig(
                offer_type=OfferType.SW0_RELATIVE,
                min_size=min_sz,
                cj_fee_relative=rel_fee,
                cj_fee_absolute=abs_fee,
                tx_fee_contribution=tx_fee,
            ),
            OfferConfig(
                offer_type=OfferType.SW0_ABSOLUTE,
                min_size=min_sz,
                cj_fee_relative=rel_fee,
                cj_fee_absolute=abs_fee,
                tx_fee_contribution=tx_fee,
            ),
        ]
        # Set dummy values for legacy fields (they won't be used)
        parsed_offer_type = OfferType.SW0_RELATIVE
        actual_cj_fee_relative = rel_fee
        actual_cj_fee_absolute = abs_fee
    elif cj_fee_relative is not None and cj_fee_absolute is not None:
        raise ValueError(
            "Cannot specify both --cj-fee-relative and --cj-fee-absolute. "
            "Use --dual-offers to create both offer types, or use only one fee option."
        )
    elif cj_fee_absolute is not None:
        # User explicitly set absolute fee via CLI
        parsed_offer_type = OfferType.SW0_ABSOLUTE
        actual_cj_fee_relative = settings.maker.cj_fee_relative
        actual_cj_fee_absolute = cj_fee_absolute
    elif cj_fee_relative is not None:
        # User explicitly set relative fee via CLI
        parsed_offer_type = OfferType.SW0_RELATIVE
        actual_cj_fee_relative = cj_fee_relative
        actual_cj_fee_absolute = settings.maker.cj_fee_absolute
    else:
        # Use settings values (from config file or defaults)
        # Parse offer_type from settings
        try:
            parsed_offer_type = OfferType(settings.maker.offer_type)
        except ValueError:
            raise ValueError(
                f"Invalid offer_type in config: {settings.maker.offer_type}. "
                "Must be one of: sw0reloffer, sw0absoffer, swreloffer, swabsoffer"
            )
        actual_cj_fee_relative = settings.maker.cj_fee_relative
        actual_cj_fee_absolute = settings.maker.cj_fee_absolute

    # Parse merge algorithm
    effective_merge_algorithm_str = (
        merge_algorithm if merge_algorithm is not None else settings.maker.merge_algorithm
    )
    try:
        parsed_merge_algorithm = MergeAlgorithm(effective_merge_algorithm_str.lower())
    except ValueError:
        raise ValueError(
            f"Invalid merge algorithm: {effective_merge_algorithm_str}. "
            "Must be one of: default, gradual, greedy, random"
        )

    # Log offer configuration for clarity
    if offer_configs:
        # Dual offers mode
        logger.info(f"Dual offers mode: creating {len(offer_configs)} offers")
        for i, oc in enumerate(offer_configs):
            fee_str = (
                f"rel={oc.cj_fee_relative}"
                if oc.offer_type in (OfferType.SW0_RELATIVE, OfferType.SWA_RELATIVE)
                else f"abs={oc.cj_fee_absolute} sats"
            )
            logger.info(f"  Offer {i}: type={oc.offer_type.value}, {fee_str}")
    else:
        # Single offer mode
        fee_str = (
            f"relative fee={actual_cj_fee_relative} ({float(actual_cj_fee_relative) * 100:.4f}%)"
            if parsed_offer_type in (OfferType.SW0_RELATIVE, OfferType.SWA_RELATIVE)
            else f"absolute fee={actual_cj_fee_absolute} sats"
        )
        logger.info(f"Offer config: type={parsed_offer_type.value}, {fee_str}")

    # Fidelity bond settings
    effective_locktimes = fidelity_bond_locktimes if fidelity_bond_locktimes else []
    effective_bond_index = fidelity_bond_index

    # Validate fidelity bond index requires locktimes
    if effective_bond_index is not None and not effective_locktimes:
        raise ValueError(
            "When using --fidelity-bond-index, you must also specify at least one "
            "--fidelity-bond-locktime"
        )

    return MakerConfig(
        mnemonic=SecretStr(mnemonic),
        passphrase=SecretStr(passphrase),
        network=effective_network,
        bitcoin_network=effective_bitcoin_network,
        data_dir=effective_data_dir,
        backend_type=effective_backend_type,
        backend_config=backend_config,
        directory_servers=dir_servers,
        socks_host=effective_socks_host,
        socks_port=effective_socks_port,
        mixdepth_count=settings.wallet.mixdepth_count,
        gap_limit=settings.wallet.gap_limit,
        dust_threshold=settings.wallet.dust_threshold,
        smart_scan=settings.wallet.smart_scan,
        background_full_rescan=settings.wallet.background_full_rescan,
        scan_lookback_blocks=settings.wallet.scan_lookback_blocks,
        tor_control=tor_control_cfg,
        onion_serving_host=effective_onion_host,
        onion_serving_port=effective_onion_port,
        tor_target_host=effective_target_host,
        min_size=effective_min_size,
        offer_type=parsed_offer_type,
        cj_fee_relative=actual_cj_fee_relative,
        cj_fee_absolute=actual_cj_fee_absolute,
        tx_fee_contribution=effective_tx_fee,
        min_confirmations=settings.maker.min_confirmations,
        session_timeout_sec=settings.maker.session_timeout_sec,
        pending_tx_timeout_min=settings.maker.pending_tx_timeout_min,
        rescan_interval_sec=settings.maker.rescan_interval_sec,
        message_rate_limit=settings.maker.message_rate_limit,
        message_burst_limit=settings.maker.message_burst_limit,
        fidelity_bond_locktimes=list(effective_locktimes),
        fidelity_bond_index=effective_bond_index,
        merge_algorithm=parsed_merge_algorithm,
        offer_configs=offer_configs,
    )

Build MakerConfig from unified settings with CLI overrides.

CLI arguments (when not None) override settings from config file and env vars.

def config_init(data_dir: "Annotated[Path | None, typer.Option('--data-dir', '-d', envvar='JOINMARKET_DATA_DIR', help='Data directory for JoinMarket files')]" = None) ‑> None
Expand source code
@app.command()
def config_init(
    data_dir: Annotated[
        Path | None,
        typer.Option(
            "--data-dir",
            "-d",
            envvar="JOINMARKET_DATA_DIR",
            help="Data directory for JoinMarket files",
        ),
    ] = None,
) -> None:
    """Initialize the config file with default settings."""
    # Determine data directory
    from jmcore.paths import get_default_data_dir
    from jmcore.settings import reset_settings

    reset_settings()

    if data_dir is None:
        data_dir = get_default_data_dir()

    config_path = ensure_config_file(data_dir)
    typer.echo(f"Config file created at: {config_path}")
    typer.echo("\nAll settings are commented out by default.")
    typer.echo("Edit the file to customize your configuration.")
    typer.echo("\nPriority (highest to lowest):")
    typer.echo("  1. CLI arguments")
    typer.echo("  2. Environment variables")
    typer.echo("  3. Config file")
    typer.echo("  4. Built-in defaults")

Initialize the config file with default settings.

def create_wallet_service(config: MakerConfig) ‑> WalletService
Expand source code
def create_wallet_service(config: MakerConfig) -> WalletService:
    backend_type = config.backend_type.lower()
    # Use bitcoin_network for address generation (bcrt1 vs tb1 vs bc1)
    bitcoin_network = config.bitcoin_network or config.network

    from jmwallet.backends.bitcoin_core import BitcoinCoreBackend
    from jmwallet.backends.descriptor_wallet import (
        DescriptorWalletBackend,
        generate_wallet_name,
        get_mnemonic_fingerprint,
    )
    from jmwallet.backends.neutrino import NeutrinoBackend

    backend: BitcoinCoreBackend | DescriptorWalletBackend | NeutrinoBackend
    if backend_type == "descriptor_wallet":
        backend_cfg = config.backend_config
        fingerprint = get_mnemonic_fingerprint(
            config.mnemonic.get_secret_value(), config.passphrase.get_secret_value() or ""
        )
        # Convert NetworkType enum to string value
        network_str = (
            bitcoin_network.value if hasattr(bitcoin_network, "value") else str(bitcoin_network)
        )
        wallet_name = generate_wallet_name(fingerprint, network_str)
        backend = DescriptorWalletBackend(
            rpc_url=backend_cfg.get("rpc_url", "http://127.0.0.1:8332"),
            rpc_user=backend_cfg.get("rpc_user", ""),
            rpc_password=backend_cfg.get("rpc_password", ""),
            wallet_name=wallet_name,
        )
    elif backend_type == "scantxoutset":
        backend_cfg = config.backend_config
        backend = BitcoinCoreBackend(
            rpc_url=backend_cfg.get("rpc_url", "http://127.0.0.1:8332"),
            rpc_user=backend_cfg.get("rpc_user", ""),
            rpc_password=backend_cfg.get("rpc_password", ""),
        )
    elif backend_type == "neutrino":
        backend_cfg = config.backend_config
        backend = NeutrinoBackend(
            neutrino_url=backend_cfg.get("neutrino_url", "http://127.0.0.1:8334"),
            network=bitcoin_network.value,
            connect_peers=backend_cfg.get("connect_peers", []),
            data_dir=backend_cfg.get("data_dir", "/data/neutrino"),
        )
    else:
        raise typer.BadParameter(f"Unsupported backend: {backend_type}")

    wallet = WalletService(
        mnemonic=config.mnemonic.get_secret_value(),
        backend=backend,
        network=bitcoin_network.value,
        mixdepth_count=config.mixdepth_count,
        gap_limit=config.gap_limit,
        passphrase=config.passphrase.get_secret_value(),
    )
    return wallet
def generate_address(mnemonic: "Annotated[str | None, typer.Option(help='BIP39 mnemonic', envvar='MNEMONIC')]" = None,
mnemonic_file: "Annotated[Path | None, typer.Option('--mnemonic-file', '-f', help='Path to mnemonic file')]" = None,
password: "Annotated[str | None, typer.Option('--password', '-p', help='Password for encrypted mnemonic file')]" = None,
bip39_passphrase: "Annotated[str | None, typer.Option('--bip39-passphrase', envvar='BIP39_PASSPHRASE', help='BIP39 passphrase (13th/25th word)')]" = None,
prompt_bip39_passphrase: "Annotated[bool, typer.Option('--prompt-bip39-passphrase', help='Prompt for BIP39 passphrase interactively')]" = False,
network: "Annotated[NetworkType | None, typer.Option(case_sensitive=False, help='Protocol network')]" = None,
bitcoin_network: "Annotated[NetworkType | None, typer.Option(case_sensitive=False, help='Bitcoin network for address generation (defaults to --network)')]" = None,
backend_type: "Annotated[str | None, typer.Option(help='Backend type')]" = None,
log_level: "Annotated[str | None, typer.Option('--log-level', '-l', help='Log level')]" = None) ‑> None
Expand source code
@app.command()
def generate_address(
    mnemonic: Annotated[str | None, typer.Option(help="BIP39 mnemonic", envvar="MNEMONIC")] = None,
    mnemonic_file: Annotated[
        Path | None, typer.Option("--mnemonic-file", "-f", help="Path to mnemonic file")
    ] = None,
    password: Annotated[
        str | None, typer.Option("--password", "-p", help="Password for encrypted mnemonic file")
    ] = None,
    bip39_passphrase: Annotated[
        str | None,
        typer.Option(
            "--bip39-passphrase",
            envvar="BIP39_PASSPHRASE",
            help="BIP39 passphrase (13th/25th word)",
        ),
    ] = None,
    prompt_bip39_passphrase: Annotated[
        bool,
        typer.Option(
            "--prompt-bip39-passphrase",
            help="Prompt for BIP39 passphrase interactively",
        ),
    ] = False,
    network: Annotated[
        NetworkType | None,
        typer.Option(case_sensitive=False, help="Protocol network"),
    ] = None,
    bitcoin_network: Annotated[
        NetworkType | None,
        typer.Option(
            case_sensitive=False,
            help="Bitcoin network for address generation (defaults to --network)",
        ),
    ] = None,
    backend_type: Annotated[str | None, typer.Option(help="Backend type")] = None,
    log_level: Annotated[
        str | None,
        typer.Option("--log-level", "-l", help="Log level"),
    ] = None,
) -> None:
    """Generate a new receive address."""
    # Load settings (log_level=None means use settings.logging.level)
    settings = setup_cli(log_level)

    # Load mnemonic using unified resolver
    try:
        resolved = resolve_mnemonic(
            settings,
            mnemonic=mnemonic,
            mnemonic_file=mnemonic_file,
            password=password,
            bip39_passphrase=bip39_passphrase,
            prompt_bip39_passphrase=prompt_bip39_passphrase,
        )
        resolved_mnemonic = resolved.mnemonic if resolved else ""
        resolved_passphrase = resolved.bip39_passphrase if resolved else ""
    except (ValueError, FileNotFoundError) as e:
        logger.error(str(e))
        raise typer.Exit(1)

    # Build config with CLI overrides
    try:
        config = build_maker_config(
            settings=settings,
            mnemonic=resolved_mnemonic,
            passphrase=resolved_passphrase,
            network=network,
            bitcoin_network=bitcoin_network,
            backend_type=backend_type,
        )
    except ValueError as e:
        logger.error(str(e))
        raise typer.Exit(1)

    wallet = create_wallet_service(config)
    address = wallet.get_receive_address(0, 0)
    typer.echo(address)

Generate a new receive address.

def main() ‑> None
Expand source code
def main() -> None:  # pragma: no cover
    app()
def run_async(coro: Any) ‑> Any
Expand source code
def run_async(coro: Any) -> Any:
    return asyncio.run(coro)
def start(mnemonic: "Annotated[str | None, typer.Option(help='BIP39 mnemonic phrase', envvar='MNEMONIC')]" = None,
mnemonic_file: "Annotated[Path | None, typer.Option('--mnemonic-file', '-f', help='Path to mnemonic file')]" = None,
password: "Annotated[str | None, typer.Option('--password', '-p', help='Password for encrypted mnemonic file')]" = None,
bip39_passphrase: "Annotated[str | None, typer.Option('--bip39-passphrase', envvar='BIP39_PASSPHRASE', help='BIP39 passphrase (13th/25th word)')]" = None,
prompt_bip39_passphrase: "Annotated[bool, typer.Option('--prompt-bip39-passphrase', help='Prompt for BIP39 passphrase interactively')]" = False,
data_dir: "Annotated[Path | None, typer.Option('--data-dir', '-d', envvar='JOINMARKET_DATA_DIR', help='Data directory for JoinMarket files. Defaults to ~/.joinmarket-ng')]" = None,
network: "Annotated[NetworkType | None, typer.Option(case_sensitive=False, help='Protocol network (mainnet, testnet, signet, regtest)')]" = None,
bitcoin_network: "Annotated[NetworkType | None, typer.Option(case_sensitive=False, help='Bitcoin network for address generation (defaults to --network)')]" = None,
backend_type: "Annotated[str | None, typer.Option(help='Backend type: scantxoutset | descriptor_wallet | neutrino')]" = None,
rpc_url: "Annotated[str | None, typer.Option(envvar='BITCOIN_RPC_URL', help='Bitcoin full node RPC URL')]" = None,
rpc_user: "Annotated[str | None, typer.Option(envvar='BITCOIN_RPC_USER', help='Bitcoin full node RPC username')]" = None,
rpc_password: "Annotated[str | None, typer.Option(envvar='BITCOIN_RPC_PASSWORD', help='Bitcoin full node RPC password')]" = None,
neutrino_url: "Annotated[str | None, typer.Option(envvar='NEUTRINO_URL', help='Neutrino REST API URL')]" = None,
min_size: "Annotated[int | None, typer.Option(help='Minimum CoinJoin size in sats')]" = None,
cj_fee_relative: "Annotated[str | None, typer.Option(help='Relative coinjoin fee (e.g., 0.001 = 0.1%)', envvar='CJ_FEE_RELATIVE')]" = None,
cj_fee_absolute: "Annotated[int | None, typer.Option(help='Absolute coinjoin fee in sats. Mutually exclusive with --cj-fee-relative.', envvar='CJ_FEE_ABSOLUTE')]" = None,
tx_fee_contribution: "Annotated[int | None, typer.Option(help='Tx fee contribution in sats')]" = None,
directory_servers: "Annotated[str | None, typer.Option('--directory', '-D', envvar='DIRECTORY_SERVERS', help='Directory servers (comma-separated host:port)')]" = None,
tor_socks_host: "Annotated[str | None, typer.Option(help='Tor SOCKS proxy host (overrides TOR__SOCKS_HOST)')]" = None,
tor_socks_port: "Annotated[int | None, typer.Option(help='Tor SOCKS proxy port (overrides TOR__SOCKS_PORT)')]" = None,
tor_control_host: "Annotated[str | None, typer.Option(help='Tor control port host (overrides TOR__CONTROL_HOST)')]" = None,
tor_control_port: "Annotated[int | None, typer.Option(help='Tor control port (overrides TOR__CONTROL_PORT)')]" = None,
tor_cookie_path: "Annotated[Path | None, typer.Option(help='Path to Tor cookie auth file (overrides TOR__COOKIE_PATH)')]" = None,
disable_tor_control: "Annotated[bool, typer.Option('--disable-tor-control', help='Disable Tor control port integration')]" = False,
onion_serving_host: "Annotated[str | None, typer.Option(help='Bind address for incoming connections (overrides MAKER__ONION_SERVING_HOST)')]" = None,
onion_serving_port: "Annotated[int | None, typer.Option(help='Port for incoming .onion connections (overrides MAKER__ONION_SERVING_PORT)')]" = None,
tor_target_host: "Annotated[str | None, typer.Option(help='Target hostname for Tor hidden service (overrides TOR__TARGET_HOST)')]" = None,
fidelity_bond_locktimes: "Annotated[list[int], typer.Option('--fidelity-bond-locktime', '-L', help='Fidelity bond locktimes to scan for')]" = [],
fidelity_bond_index: "Annotated[int | None, typer.Option('--fidelity-bond-index', '-I', envvar='FIDELITY_BOND_INDEX', help='Fidelity bond derivation index')]" = None,
fidelity_bond: "Annotated[str | None, typer.Option('--fidelity-bond', '-B', help='Specific fidelity bond to use (format: txid:vout)')]" = None,
merge_algorithm: "Annotated[str | None, typer.Option('--merge-algorithm', '-M', envvar='MERGE_ALGORITHM', help='UTXO selection strategy: default, gradual, greedy, random')]" = None,
dual_offers: "Annotated[bool, typer.Option('--dual-offers', help='Create both relative and absolute fee offers simultaneously. Each offer gets a unique ID (0 for relative, 1 for absolute). Use with --cj-fee-relative and --cj-fee-absolute to set fees for each.')]" = False,
log_level: "Annotated[str | None, typer.Option('--log-level', '-l', help='Log level')]" = None) ‑> None
Expand source code
@app.command()
def start(
    mnemonic: Annotated[
        str | None, typer.Option(help="BIP39 mnemonic phrase", envvar="MNEMONIC")
    ] = None,
    mnemonic_file: Annotated[
        Path | None, typer.Option("--mnemonic-file", "-f", help="Path to mnemonic file")
    ] = None,
    password: Annotated[
        str | None, typer.Option("--password", "-p", help="Password for encrypted mnemonic file")
    ] = None,
    bip39_passphrase: Annotated[
        str | None,
        typer.Option(
            "--bip39-passphrase",
            envvar="BIP39_PASSPHRASE",
            help="BIP39 passphrase (13th/25th word)",
        ),
    ] = None,
    prompt_bip39_passphrase: Annotated[
        bool,
        typer.Option(
            "--prompt-bip39-passphrase",
            help="Prompt for BIP39 passphrase interactively",
        ),
    ] = False,
    data_dir: Annotated[
        Path | None,
        typer.Option(
            "--data-dir",
            "-d",
            envvar="JOINMARKET_DATA_DIR",
            help="Data directory for JoinMarket files. Defaults to ~/.joinmarket-ng",
        ),
    ] = None,
    network: Annotated[
        NetworkType | None,
        typer.Option(
            case_sensitive=False,
            help="Protocol network (mainnet, testnet, signet, regtest)",
        ),
    ] = None,
    bitcoin_network: Annotated[
        NetworkType | None,
        typer.Option(
            case_sensitive=False,
            help="Bitcoin network for address generation (defaults to --network)",
        ),
    ] = None,
    backend_type: Annotated[
        str | None,
        typer.Option(help="Backend type: scantxoutset | descriptor_wallet | neutrino"),
    ] = None,
    rpc_url: Annotated[
        str | None, typer.Option(envvar="BITCOIN_RPC_URL", help="Bitcoin full node RPC URL")
    ] = None,
    rpc_user: Annotated[
        str | None, typer.Option(envvar="BITCOIN_RPC_USER", help="Bitcoin full node RPC username")
    ] = None,
    rpc_password: Annotated[
        str | None,
        typer.Option(envvar="BITCOIN_RPC_PASSWORD", help="Bitcoin full node RPC password"),
    ] = None,
    neutrino_url: Annotated[
        str | None, typer.Option(envvar="NEUTRINO_URL", help="Neutrino REST API URL")
    ] = None,
    min_size: Annotated[int | None, typer.Option(help="Minimum CoinJoin size in sats")] = None,
    cj_fee_relative: Annotated[
        str | None,
        typer.Option(
            help="Relative coinjoin fee (e.g., 0.001 = 0.1%)",
            envvar="CJ_FEE_RELATIVE",
        ),
    ] = None,
    cj_fee_absolute: Annotated[
        int | None,
        typer.Option(
            help="Absolute coinjoin fee in sats. Mutually exclusive with --cj-fee-relative.",
            envvar="CJ_FEE_ABSOLUTE",
        ),
    ] = None,
    tx_fee_contribution: Annotated[
        int | None, typer.Option(help="Tx fee contribution in sats")
    ] = None,
    directory_servers: Annotated[
        str | None,
        typer.Option(
            "--directory",
            "-D",
            envvar="DIRECTORY_SERVERS",
            help="Directory servers (comma-separated host:port)",
        ),
    ] = None,
    tor_socks_host: Annotated[
        str | None, typer.Option(help="Tor SOCKS proxy host (overrides TOR__SOCKS_HOST)")
    ] = None,
    tor_socks_port: Annotated[
        int | None, typer.Option(help="Tor SOCKS proxy port (overrides TOR__SOCKS_PORT)")
    ] = None,
    tor_control_host: Annotated[
        str | None,
        typer.Option(
            help="Tor control port host (overrides TOR__CONTROL_HOST)",
        ),
    ] = None,
    tor_control_port: Annotated[
        int | None, typer.Option(help="Tor control port (overrides TOR__CONTROL_PORT)")
    ] = None,
    tor_cookie_path: Annotated[
        Path | None,
        typer.Option(
            help="Path to Tor cookie auth file (overrides TOR__COOKIE_PATH)",
        ),
    ] = None,
    disable_tor_control: Annotated[
        bool,
        typer.Option(
            "--disable-tor-control",
            help="Disable Tor control port integration",
        ),
    ] = False,
    onion_serving_host: Annotated[
        str | None,
        typer.Option(
            help="Bind address for incoming connections (overrides MAKER__ONION_SERVING_HOST)",
        ),
    ] = None,
    onion_serving_port: Annotated[
        int | None,
        typer.Option(
            help="Port for incoming .onion connections (overrides MAKER__ONION_SERVING_PORT)",
        ),
    ] = None,
    tor_target_host: Annotated[
        str | None,
        typer.Option(
            help="Target hostname for Tor hidden service (overrides TOR__TARGET_HOST)",
        ),
    ] = None,
    fidelity_bond_locktimes: Annotated[
        list[int],
        typer.Option("--fidelity-bond-locktime", "-L", help="Fidelity bond locktimes to scan for"),
    ] = [],  # noqa: B006
    fidelity_bond_index: Annotated[
        int | None,
        typer.Option(
            "--fidelity-bond-index",
            "-I",
            envvar="FIDELITY_BOND_INDEX",
            help="Fidelity bond derivation index",
        ),
    ] = None,
    fidelity_bond: Annotated[
        str | None,
        typer.Option(
            "--fidelity-bond",
            "-B",
            help="Specific fidelity bond to use (format: txid:vout)",
        ),
    ] = None,
    merge_algorithm: Annotated[
        str | None,
        typer.Option(
            "--merge-algorithm",
            "-M",
            envvar="MERGE_ALGORITHM",
            help="UTXO selection strategy: default, gradual, greedy, random",
        ),
    ] = None,
    dual_offers: Annotated[
        bool,
        typer.Option(
            "--dual-offers",
            help=(
                "Create both relative and absolute fee offers simultaneously. "
                "Each offer gets a unique ID (0 for relative, 1 for absolute). "
                "Use with --cj-fee-relative and --cj-fee-absolute to set fees for each."
            ),
        ),
    ] = False,
    log_level: Annotated[
        str | None,
        typer.Option("--log-level", "-l", help="Log level"),
    ] = None,
) -> None:
    """
    Start the maker bot.

    Configuration is loaded from ~/.joinmarket-ng/config.toml (or $JOINMARKET_DATA_DIR/config.toml),
    environment variables, and CLI arguments. CLI arguments have the highest priority.
    """
    # Load settings (log_level=None means use settings.logging.level)
    settings = setup_cli(log_level)

    # Ensure config file exists (creates template if not)
    ensure_config_file(settings.get_data_dir())

    # Load mnemonic using unified resolver
    try:
        resolved = resolve_mnemonic(
            settings,
            mnemonic=mnemonic,
            mnemonic_file=mnemonic_file,
            password=password,
            bip39_passphrase=bip39_passphrase,
            prompt_bip39_passphrase=prompt_bip39_passphrase,
        )
        resolved_mnemonic = resolved.mnemonic if resolved else ""
        resolved_passphrase = resolved.bip39_passphrase if resolved else ""
    except (ValueError, FileNotFoundError) as e:
        logger.error(str(e))
        raise typer.Exit(1)

    # Build MakerConfig with CLI overrides
    try:
        config = build_maker_config(
            settings=settings,
            mnemonic=resolved_mnemonic,
            passphrase=resolved_passphrase,
            network=network,
            bitcoin_network=bitcoin_network,
            data_dir=data_dir,
            backend_type=backend_type,
            rpc_url=rpc_url,
            rpc_user=rpc_user,
            rpc_password=rpc_password,
            neutrino_url=neutrino_url,
            directory_servers=directory_servers,
            tor_socks_host=tor_socks_host,
            tor_socks_port=tor_socks_port,
            tor_control_host=tor_control_host,
            tor_control_port=tor_control_port,
            tor_cookie_path=tor_cookie_path,
            disable_tor_control=disable_tor_control,
            onion_serving_host=onion_serving_host,
            onion_serving_port=onion_serving_port,
            tor_target_host=tor_target_host,
            min_size=min_size,
            cj_fee_relative=cj_fee_relative,
            cj_fee_absolute=cj_fee_absolute,
            tx_fee_contribution=tx_fee_contribution,
            merge_algorithm=merge_algorithm,
            fidelity_bond_locktimes=fidelity_bond_locktimes if fidelity_bond_locktimes else None,
            fidelity_bond_index=fidelity_bond_index,
            dual_offers=dual_offers,
        )
    except ValueError as e:
        logger.error(str(e))
        raise typer.Exit(1)

    # Log configuration source
    logger.info(f"Using network: {config.network.value}")
    logger.info(f"Using backend: {config.backend_type}")
    logger.info(f"Tor SOCKS: {config.socks_host}:{config.socks_port}")
    logger.info(f"Directory servers: {len(config.directory_servers)} configured")

    wallet = create_wallet_service(config)
    bot = MakerBot(wallet, wallet.backend, config)

    # Store the specific fidelity bond selection if provided
    if fidelity_bond:
        try:
            parts = fidelity_bond.split(":")
            if len(parts) != 2:
                raise ValueError("Invalid format")
            config.selected_fidelity_bond = (parts[0], int(parts[1]))
            logger.info(f"Using specified fidelity bond: {fidelity_bond}")
        except (ValueError, IndexError):
            logger.error(f"Invalid fidelity bond format: {fidelity_bond}. Use txid:vout")
            raise typer.Exit(1)

    async def run_bot() -> None:
        try:
            # Write nick state file for external tracking and cross-component protection
            nick = bot.nick
            data_dir = config.data_dir
            write_nick_state(data_dir, "maker", nick)
            logger.info(f"Nick state written to {data_dir}/state/maker.nick")

            # Send startup notification immediately (including nick)
            notifier = get_notifier(settings, component_name="Maker")
            await notifier.notify_startup(
                component="Maker",
                network=config.network.value,
                nick=nick,
            )
            await bot.start()
            while True:
                await asyncio.sleep(1)
        except asyncio.CancelledError:
            pass
        finally:
            # Clean up nick state file on shutdown
            remove_nick_state(config.data_dir, "maker")
            await bot.stop()

    try:
        run_async(run_bot())
    except KeyboardInterrupt:
        logger.info("Shutting down maker bot...")
        run_async(bot.stop())

Start the maker bot.

Configuration is loaded from ~/.joinmarket-ng/config.toml (or $JOINMARKET_DATA_DIR/config.toml), environment variables, and CLI arguments. CLI arguments have the highest priority.