cli: add headless command-line layer (bal_* commands for the daemon, cmdline entry point, manifest 'cmdline' support, offline controller tests)
This commit is contained in:
19
bal/cli/__init__.py
Normal file
19
bal/cli/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
bal.cli
|
||||
=======
|
||||
|
||||
Headless command-line layer of the Bitcoin After Life (BAL) Electrum plugin.
|
||||
|
||||
This sub-package implements the ``"cmdline"`` front-end: it exposes the
|
||||
plugin's functionality through Electrum ``bal_*`` commands while reusing only
|
||||
the GUI-free logic from ``bal.core``. Like ``bal.core``, it MUST never import
|
||||
PyQt or ``electrum.gui``.
|
||||
|
||||
* ``bal.cli.commands`` -> the ``@plugin_command`` transport layer
|
||||
* ``bal.cli.controller`` -> headless replica of the Qt flows (later phases)
|
||||
* ``bal.cli.plugin`` -> ``Plugin(BalPlugin)`` entry point for the daemon
|
||||
|
||||
Electrum discovers the plugin through ``manifest.json`` (``available_for``
|
||||
includes ``"cmdline"``) and loads the entry point from ``cmdline.py``, a thin
|
||||
zip-safe shim following the same pattern as ``qt.py``.
|
||||
"""
|
||||
407
bal/cli/commands.py
Normal file
407
bal/cli/commands.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""
|
||||
bal.cli.commands
|
||||
================
|
||||
|
||||
CLI commands (``bal_*``) for the Bitcoin After Life plugin.
|
||||
|
||||
This module is the *transport layer* of the command-line front-end: every
|
||||
function is a coroutine decorated with ``@plugin_command`` so Electrum exposes
|
||||
it as ``bal_<name>`` both on the command line and over JSON-RPC. The functions
|
||||
validate their arguments and delegate the real work to
|
||||
:mod:`bal.cli.controller` (a headless replica of the Qt flows); this module
|
||||
never imports Qt.
|
||||
|
||||
It must stay lightweight: Electrum imports it during the CLI pre-parse
|
||||
(``run_electrum`` calls ``Plugins(config, cmd_only=True)``) and on every
|
||||
GUI/daemon startup, before any wallet or network object exists. The heavy
|
||||
imports (``bal.core``, the controller) happen lazily inside each command.
|
||||
|
||||
Flags (see ``electrum.commands.plugin_command``):
|
||||
|
||||
* ``n`` -> requires a running daemon/network (always set for plugins);
|
||||
* ``w`` -> resolves and injects the wallet from the daemon;
|
||||
* ``p`` -> requires the wallet password (for signing).
|
||||
"""
|
||||
|
||||
from electrum.commands import plugin_command
|
||||
from electrum.util import UserFacingException
|
||||
|
||||
from .controller import BalController, _user_facing
|
||||
|
||||
plugin_name = "bal"
|
||||
|
||||
|
||||
def _controller(plugin, wallet):
|
||||
"""Build the headless controller, or fail with a clear message."""
|
||||
if plugin is None:
|
||||
raise UserFacingException("the bal plugin is not enabled in this daemon")
|
||||
if wallet is None:
|
||||
raise UserFacingException("wallet not loaded")
|
||||
return BalController(plugin, wallet)
|
||||
|
||||
|
||||
def _call(plugin, wallet, method, *args, **kwargs):
|
||||
controller = _controller(plugin, wallet)
|
||||
try:
|
||||
return getattr(controller, method)(*args, **kwargs)
|
||||
except Exception as e:
|
||||
raise _user_facing(e) from e
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Settings
|
||||
# --------------------------------------------------------------------------- #
|
||||
@plugin_command("n", plugin_name)
|
||||
async def settings_list(self, plugin=None):
|
||||
"""List all BAL plugin configuration options (key, name and value).
|
||||
|
||||
Returns a JSON object mapping every BAL configuration option (``bal_*``)
|
||||
to an object with ``value``, ``default`` and ``name``.
|
||||
"""
|
||||
return _call(plugin, None, "settings_list")
|
||||
|
||||
|
||||
@plugin_command("n", plugin_name)
|
||||
async def settings_get(self, key, plugin=None):
|
||||
"""Show the current value of one BAL configuration option.
|
||||
|
||||
arg:str:key:The configuration key (e.g. ``bal_tx_fees``).
|
||||
"""
|
||||
return _call(plugin, None, "settings_get", key)
|
||||
|
||||
|
||||
@plugin_command("n", plugin_name)
|
||||
async def settings_set(self, key, value, plugin=None):
|
||||
"""Set a BAL configuration option (booleans, integers, strings, JSON).
|
||||
|
||||
arg:str:key:The configuration key (e.g. ``bal_user_type``).
|
||||
arg:str:value:The new value; JSON for object-typed keys such as ``bal_will_settings``.
|
||||
"""
|
||||
return _call(plugin, None, "settings_set", key, value)
|
||||
|
||||
|
||||
@plugin_command("n", plugin_name)
|
||||
async def settings_reset(self, key, plugin=None):
|
||||
"""Reset a BAL configuration option to its default value.
|
||||
|
||||
arg:str:key:The configuration key (e.g. ``bal_tx_fees``).
|
||||
"""
|
||||
return _call(plugin, None, "settings_reset", key)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Heirs
|
||||
# --------------------------------------------------------------------------- #
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_list(self, wallet=None, plugin=None):
|
||||
"""List the heirs of the current wallet.
|
||||
|
||||
Returns a JSON object mapping heir names to their ``[address, amount,
|
||||
locktime]`` values.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_list")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_show(self, name, wallet=None, plugin=None):
|
||||
"""Show the details of a single heir.
|
||||
|
||||
arg:str:name:The heir name.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_show", name)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_add(self, name, address, amount, locktime=None, wallet=None, plugin=None):
|
||||
"""Add (or replace) an heir in the current wallet.
|
||||
|
||||
arg:str:name:The heir name.
|
||||
arg:str:address:The destination address (or ``OP_RETURN:<hex>`` for an OP_RETURN heir).
|
||||
arg:str:amount:The amount in satoshis or a percentage like ``50%%``.
|
||||
arg:str:locktime:The delivery locktime (absolute timestamp or ``30d``/``1y``); defaults to the will locktime.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_add", name, address, amount, locktime)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_update(
|
||||
self,
|
||||
name,
|
||||
address=None,
|
||||
amount=None,
|
||||
locktime=None,
|
||||
wallet=None,
|
||||
plugin=None,
|
||||
):
|
||||
"""Update an existing heir (only the given fields).
|
||||
|
||||
arg:str:name:The heir name.
|
||||
arg:str:address:The new destination address.
|
||||
arg:str:amount:The new amount in satoshis or a percentage.
|
||||
arg:str:locktime:The new delivery locktime.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_update", name, address, amount, locktime)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_delete(self, names, wallet=None, plugin=None):
|
||||
"""Delete one or more heirs.
|
||||
|
||||
arg:json:names:A JSON array of heir names (e.g. ``["Alice","Bob"]``).
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_delete", names)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_import(self, path, wallet=None, plugin=None):
|
||||
"""Import heirs from a JSON file (validated, merged).
|
||||
|
||||
arg:str:path:Path to the JSON file.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_import", path)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_export(self, path, wallet=None, plugin=None):
|
||||
"""Export the heirs to a JSON file.
|
||||
|
||||
arg:str:path:Destination file path.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_export", path)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Will-Executors
|
||||
# --------------------------------------------------------------------------- #
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_list(self, wallet=None, plugin=None):
|
||||
"""List the will-executors for the current network.
|
||||
|
||||
Returns a JSON object mapping executor URLs to their records (address,
|
||||
base_fee, status, info, selected, ...).
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_list")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_show(self, url, wallet=None, plugin=None):
|
||||
"""Show the details of a single will-executor.
|
||||
|
||||
arg:str:url:The will-executor URL.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_show", url)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_add(
|
||||
self,
|
||||
url,
|
||||
address="",
|
||||
base_fee=0,
|
||||
info=None,
|
||||
wallet=None,
|
||||
plugin=None,
|
||||
):
|
||||
"""Add a new will-executor (not selected by default).
|
||||
|
||||
arg:str:url:The will-executor base URL.
|
||||
arg:str:address:The executor fee address for this network.
|
||||
arg:int:base_fee:The executor base fee in satoshis.
|
||||
arg:str:info:A human-readable description.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_add", url, address, base_fee, info)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_update(
|
||||
self,
|
||||
url,
|
||||
address=None,
|
||||
base_fee=None,
|
||||
info=None,
|
||||
promo_code=None,
|
||||
rename_to=None,
|
||||
wallet=None,
|
||||
plugin=None,
|
||||
):
|
||||
"""Update an existing will-executor (only the given fields).
|
||||
|
||||
arg:str:url:The will-executor URL to update.
|
||||
arg:str:address:The new fee address.
|
||||
arg:int:base_fee:The new base fee in satoshis.
|
||||
arg:str:info:The new description.
|
||||
arg:str:promo_code:The new promo code.
|
||||
arg:str:rename_to:Optionally move the record to a new URL.
|
||||
"""
|
||||
return _call(
|
||||
plugin,
|
||||
wallet,
|
||||
"willexecutors_update",
|
||||
url,
|
||||
address,
|
||||
base_fee,
|
||||
info,
|
||||
promo_code,
|
||||
rename_to,
|
||||
)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_select(
|
||||
self, url, value=True, wallet=None, plugin=None
|
||||
):
|
||||
"""Select (or deselect) a will-executor.
|
||||
|
||||
arg:str:url:The will-executor URL.
|
||||
arg:bool:value:True to select, False to deselect.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_select", [url], value)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_delete(self, urls, wallet=None, plugin=None):
|
||||
"""Delete one or more will-executors.
|
||||
|
||||
arg:json:urls:A JSON array of executor URLs (e.g. ``["https://we.example.com"]``).
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_delete", urls)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_ping(self, urls=None, wallet=None, plugin=None):
|
||||
"""Ping the selected (or the given) will-executor servers.
|
||||
|
||||
Updates status/base_fee/address from each server and saves. Returns
|
||||
``{url: {status, ok}}``.
|
||||
|
||||
arg:json:urls:Optional JSON array of URLs to ping; defaults to the selected executors.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_ping", urls)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_download(self, wallet=None, plugin=None):
|
||||
"""Download the will-executor list from the welist server and merge it.
|
||||
|
||||
Returns the number of records downloaded and the new total.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_download")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_import(self, path, wallet=None, plugin=None):
|
||||
"""Import will-executors from a JSON file (``{url: record}``).
|
||||
|
||||
arg:str:path:Path to the JSON file.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_import", path)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_export(self, path, wallet=None, plugin=None):
|
||||
"""Export the will-executors to a JSON file.
|
||||
|
||||
arg:str:path:Destination file path.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_export", path)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Will
|
||||
# --------------------------------------------------------------------------- #
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_status(self, wallet=None, plugin=None):
|
||||
"""Show the current will: per-transaction status, locktime and executors.
|
||||
|
||||
Returns a JSON object with a per-txid detail list and global status counts.
|
||||
"""
|
||||
return _call(plugin, wallet, "will_status")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_check(self, wallet=None, plugin=None):
|
||||
"""Check the local coherence of the will (heirs, executors, fees, locktime).
|
||||
|
||||
Returns ``{"valid": true}`` when coherent, or raises a descriptive error.
|
||||
"""
|
||||
return _call(plugin, wallet, "will_check")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_prepare(self, wallet=None, plugin=None):
|
||||
"""Run the full prepare/inheritance flow (check, rebuild, persist).
|
||||
|
||||
Returns a JSON object with ``result`` (``coherent``, ``rebuilt``,
|
||||
``expired``, ``postponed``) and, when needed, the invalidation
|
||||
transaction to sign and broadcast.
|
||||
"""
|
||||
return _call(plugin, wallet, "prepare_will")
|
||||
|
||||
|
||||
@plugin_command("nwp", plugin_name)
|
||||
async def will_sign(self, txid=None, password=None, wallet=None, plugin=None):
|
||||
"""Sign the valid, not-yet-complete will transactions (or just one).
|
||||
|
||||
Updates the COMPLETE status and the signature counters and persists.
|
||||
|
||||
arg:str:txid:Optional transaction id to sign; signs all valid ones when omitted.
|
||||
"""
|
||||
txids = [txid] if txid is not None else None
|
||||
txs = _call(plugin, wallet, "sign_transactions", password, txids)
|
||||
return {wid: str(tx) for wid, tx in txs.items()}
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_broadcast(
|
||||
self, txid=None, force=False, wallet=None, plugin=None
|
||||
):
|
||||
"""Send the signed will transactions to their will-executors (in parallel).
|
||||
|
||||
Updates the PUSHED/PUSH_FAIL statuses and persists. Returns ``{url: status}``.
|
||||
|
||||
arg:str:txid:Optional transaction id to broadcast; all valid+signed ones when omitted.
|
||||
arg:bool:force:Force re-pushing transactions already marked as PUSHED.
|
||||
"""
|
||||
txids = [txid] if txid is not None else None
|
||||
return _call(plugin, wallet, "push_transactions_to_willexecutors", force, txids)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_export(self, path, wallet=None, plugin=None):
|
||||
"""Export the whole will to a JSON file.
|
||||
|
||||
arg:str:path:Destination file path.
|
||||
"""
|
||||
return _call(plugin, wallet, "export_will", path)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_import_merge(self, path, wallet=None, plugin=None):
|
||||
"""Merge a will file into the current will (PSBTs and statuses are merged).
|
||||
|
||||
arg:str:path:Path to the will JSON file.
|
||||
"""
|
||||
return _call(plugin, wallet, "merge_will_from_file", path)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_invalidate(self, wallet=None, plugin=None):
|
||||
"""Build the on-chain invalidation transaction for the current will.
|
||||
|
||||
Returns ``{txid, tx}`` (or nulls when there is nothing to invalidate); the
|
||||
transaction still needs to be signed and broadcast.
|
||||
"""
|
||||
return _call(plugin, wallet, "invalidate_will_command")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_check_executor(self, txid=None, wallet=None, plugin=None):
|
||||
"""Ask the will-executors whether they hold our pushed transactions.
|
||||
|
||||
Runs the searchtx check in parallel, applies the per-item status and
|
||||
persists. Returns ``{txid: {url, pushed, checked, check_fail}}``.
|
||||
|
||||
arg:str:txid:Optional transaction id to check; checks all pending ones when omitted.
|
||||
"""
|
||||
txids = [txid] if txid is not None else None
|
||||
return _call(plugin, wallet, "check_transactions", txids)
|
||||
1129
bal/cli/controller.py
Normal file
1129
bal/cli/controller.py
Normal file
File diff suppressed because it is too large
Load Diff
21
bal/cli/plugin.py
Normal file
21
bal/cli/plugin.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
bal.cli.plugin
|
||||
==============
|
||||
|
||||
The headless (command-line) entry point of the plugin.
|
||||
|
||||
:class:`Plugin` subclasses :class:`bal.core.plugin_base.BalPlugin` without
|
||||
adding any Qt hooks or per-window state. Electrum instantiates this class when
|
||||
the plugin runs with ``gui_name='cmdline'`` (the daemon loads
|
||||
``bal/cmdline.py``, which re-exports it), and it is the object injected as
|
||||
``plugin`` into every ``bal_*`` command by ``electrum.commands.plugin_command``.
|
||||
"""
|
||||
|
||||
from ..core.plugin_base import BalPlugin
|
||||
|
||||
|
||||
class Plugin(BalPlugin):
|
||||
"""Minimal ``BasePlugin`` subclass for the command-line front-end."""
|
||||
|
||||
def __init__(self, parent, config, name):
|
||||
BalPlugin.__init__(self, parent, config, name)
|
||||
Reference in New Issue
Block a user