#!/usr/bin/env python3
# Copyright (c) 2026 The Sequentia developers
# Distributed under the MIT software license.
"""Pignus command line: propose loans, and check the ones you are offered.

The command that matters is `verify`. Everything Pignus claims about being
non-custodial reduces to one check -- rebuild the vault address from the terms
you agreed and confirm it is the address being funded -- and this is where a
person can run it themselves, without trusting a wallet, a book or a
counterparty's description of the deal.

    pignus-cli selftest
    pignus-cli quote     --market GOLD/USDX --collateral-ref 3000 --debt-ref 1
    pignus-cli propose   --collateral-asset <id> --debt-asset <id> ... > loan.json
    pignus-cli show      --terms loan.json --price 30000000
    pignus-cli address   --terms loan.json
    pignus-cli verify    --terms loan.json --txid <funding> --vout 0
    pignus-cli status    --terms loan.json --txid <funding> --vout 0
    pignus-cli check-attestation --oracle-x <hex> --attestation att.json

With a node wallet, everything the page does:

    pignus-cli offer-fund --market GOLD/USDX --principal 100 --lots 3 \\
        --interest 3 --open-ltv 50 --liq-ltv 75 --term-days 30 --rpc-wallet me
    pignus-cli offer-take --offer <id> --rpc-wallet me
    pignus-cli offer-publish --body offer-<txid>.json
    pignus-cli loan-export --loan <id> --out loan.json
    pignus-cli repay | liquidate | default | recover --loan <id> --rpc-wallet me
    pignus-cli repay --terms loan.json --txid <vault> --rpc-wallet me
    pignus-cli offer-withdraw --offer <id> --rpc-wallet me
    pignus-cli explain --loan <id>

Native BTC collateral (Tier B), a two-party handshake carrying a ticket JSON:

    btc-keygen, btc-propose, btc-prepare, btc-release, btc-originate,
    btc-disburse, btc-claim-principal, btc-upgrade, btc-repay, btc-claim,
    btc-reclaim, and the endings btc-abort, btc-refund, btc-refund-principal,
    btc-seize-sighash, btc-seize, btc-timeout; btc-check says whose move it is.
    Through a relay: btc-offer-publish, btc-offer-take, btc-offer-resign,
    btc-offer-withdraw, btc-respond, btc-responder-status,
    btc-responder-clear.

    `btc-adaptor` is an alias of `btc-release`. What it produces is a plain
    BIP340 release signature the lender publishes, not an adaptor signature.

Pledged collateral at an OpenAMP issuer (Tier C), where a signature rather than
a key travels between the parties:

    pledge-sign, pledge-list, pledge-create, pledge-release, pledge-seize

A repurchase against an OpenDAMP asset (Tier D, not a loan):

    repo-propose, repo-show, repo-verify, repo-fund, repo-settle, repo-forfeit

`pignus-cli <command> --help` describes each. Exit status: 0 done, 1 the command
could not run, 2 REFUSED -- a check failed and nothing was built or broadcast,
3 the covenant is unavailable, 4 the command ran and reported a state that is
not settled and not safe to act on. That last one is not only for the check
commands: `repo-verify` uses it for `bond-only` and `funded-unburied`, which
look like success and are not; `pledge-seize` uses it when an issuer accepted
the request and has not delivered the collateral; and `btc-responder-status`
uses it when a take needs a person. In every case the command did what it was
asked -- what is not true is that the thing is finished.
"""

from typing import NoReturn
import argparse
import inspect
import json
import os
import sys
import time
from decimal import Decimal

sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), ".."))

from pignus import __version__ as PIGNUS_VERSION  # noqa: E402
from pignus import atoms, compat, oracle as O    # noqa: E402
from pignus import atoms as _atoms, units      # noqa: E402
from pignus.btc_collateral import MIN_UPGRADE_FEE, MIN_ANCHOR_DEPTH   # noqa: E402
from pignus import locktime_open as _pkg_locktime_open   # noqa: E402
from pignus.node import Node                    # noqa: E402
from pignus.openamp import PledgeError          # noqa: E402
from pignus.terms import LoanTerms, feed_id     # noqa: E402
from pignus.watcher import VaultWatcher         # noqa: E402


def refuse(message) -> NoReturn:
    """A check failed, so nothing was built. Exit status 2, always, so that a
    script can tell a refusal from a crash.

    Every deliberate refusal in this file goes through here rather than raising
    SystemExit with a message of its own, because that form exits 1 -- the
    status this tool documents as "could not run". A caller that branches on the
    difference, and the responder and the drills both do, was told a failed
    check and a crashed command were the same event.

    This never returns, which is what lets a check read as a guard: the code
    after a refuse() call runs only when the check passed.
    """
    print(f"REFUSED: {message}", file=sys.stderr)
    raise SystemExit(2)


def _funding_for(args, node, need, exclude=(), fee_asset=None):
    """Explicit coins covering `need`, or None when a dry run would have had to
    move money to get them.

    Preparing explicit coins is itself a broadcast -- a node wallet's change is
    blinded, and a covenant cannot spend a blinded input -- so `--dry-run` must
    not reach it. A dry run that pays a preparing fee has moved money.

    `fee_asset` is the asset this command is already paying its fee in, which
    is the one the preparing sends should use too: the choice a person made on
    the command line, not a default picked behind them. `--prep-fee-asset`
    overrides it for the wallet that holds its spending money in one asset and
    pays its fees in another.
    """
    from pignus.vault import select_funding

    def said(asset, wants, txids):
        short = ", ".join(f"{n} of {a[:8]}…" for a, n in wants.items())
        print(f"  prepared {len(txids)} explicit coin(s) ({short}); the "
              f"preparing fee was paid in {asset[:8]}…: " + ", ".join(txids),
              file=sys.stderr)

    prep_asset = _prep_asset(node, args, fee_asset)
    try:
        return select_funding(node, need, exclude=exclude,
                              prepare=not args.dry_run,
                              prep_fee_asset=prep_asset, on_prepare=said)
    except ValueError as e:
        if not args.dry_run:
            raise
        print(f"dry-run: nothing composed. Preparing explicit coins would have "
              f"broadcast a payment: {e}", file=sys.stderr)
        return None


def _prep_asset(node, args, fee_asset):
    """Which asset the PREPARING sends pay their own fee in.

    `--prep-fee-asset` when it is given, and otherwise the asset this command
    is already paying in. One place, because the offer commands each select
    their own funding and every one of them used to ignore the flag -- a flag
    accepted and silently dropped is worse than one that does not exist.
    """
    prep = getattr(args, "prep_fee_asset", None)
    return resolve_asset(node, prep) if prep else fee_asset


def terms_doc(path):
    """The terms object in a file, and the record around it if there is one.

    `offer-take` prints {"terms": {...}, "txid", "vout", ...}; a plain terms
    document is the object itself. Accepting both means the output of one
    command is the input of the next without hand editing.

    A path that is not there, or a file that is not the document it should be,
    is answered with a sentence rather than a traceback. Every command here
    takes `--terms`, so a mistyped path is the commonest wrong thing an
    operator can hand this tool, and a Python traceback tells them their
    software is broken when what happened is that they mistyped a path.
    """
    try:
        with open(path) as f:
            d = json.load(f)
    except OSError as e:
        refuse(f"cannot read the terms file {path}: {e.strerror}")
    except ValueError as e:
        refuse(f"{path} is not valid JSON ({e}). A terms file is what "
               f"`propose`, `offer-fund` and `offer-take` write.")
    if not isinstance(d, dict):
        refuse(f"{path} holds a {type(d).__name__}, not a terms document")
    try:
        if ("collateral_asset" not in d
                and isinstance(d.get("terms"), (dict, str))):
            return LoanTerms.from_json(d["terms"]), d
        return LoanTerms.from_json(d), d
    except (KeyError, TypeError, ValueError) as e:
        refuse(f"{path} is not a loan's terms: {e}")


def read_terms(path):
    return terms_doc(path)[0]


def node_from(args):
    return Node(url=args.rpc, user=args.rpc_user, password=args.rpc_password,
                wallet=args.rpc_wallet, cookie_path=args.rpc_cookie)


def add_node_args(p):
    p.add_argument("--rpc", default=os.environ.get(
        "PIGNUS_RPC_URL", "http://127.0.0.1:18776"),
        help="the Sequentia node's JSON-RPC url (18776 on the default chain, "
             "7040 on elementsregtest), or PIGNUS_RPC_URL")
    p.add_argument("--rpc-user", default=os.environ.get("PIGNUS_RPC_USER"),
                   help="RPC user, or PIGNUS_RPC_USER")
    p.add_argument("--rpc-password", default=os.environ.get("PIGNUS_RPC_PASSWORD"),
                   help="RPC password, or PIGNUS_RPC_PASSWORD; prefer "
                        "--rpc-cookie or the environment, because a command "
                        "line is readable by every process on the machine")
    p.add_argument("--rpc-wallet", default=os.environ.get("PIGNUS_RPC_WALLET"),
                   help="the node wallet to spend and sign with")
    p.add_argument("--rpc-cookie", default=os.environ.get("PIGNUS_RPC_COOKIE"),
                   help="path to the node's .cookie file, used when --rpc-user "
                        "is not given; or PIGNUS_RPC_COOKIE")


def add_fee_args(p, fee_default="the asset already being spent"):
    """The three fee options every composing command shares.

    Fees are payable in any asset the network accepts, so there is nothing to
    fall back to and nothing is assumed: a command either pays in the asset it
    is already spending or in one the operator names.
    """
    p.add_argument("--fee-asset", help="label or 64-hex id; default: "
                   f"{fee_default}, else anything held that the node "
                   "publishes a rate for")
    p.add_argument("--fee-amount", type=int, default=None,
                   help="atoms; default: priced from the node's exchange rates "
                        "at 20x the relay floor. 0 is a real answer -- it "
                        "means this transaction's fee is paid by another "
                        "input -- and is honoured rather than replaced")
    p.add_argument("--prep-fee-asset", help="label or 64-hex id for the fee on "
                   "the sends that give the wallet explicit coins (default: "
                   "--fee-asset). A covenant cannot spend a blinded input, and "
                   "a node wallet's change is blinded")


# --------------------------------------------------------------- commands

def cmd_selftest(args):
    n = compat.verify_builder()
    print(f"covenant builder matches all {n} golden vectors "
          "(vault, offer, repurchase, hashlock)")
    sec = O.generate_key()
    x = O.xonly_pubkey(sec)
    att = O.sign(sec, "GOLD/USDX", 300 * 100_000, 100_000)
    assert O.verify(x, att), "self-signed attestation failed to verify"
    print("oracle sign/verify round-trip ok (48-byte BIP340 message)")
    return 0


def split_market(market):
    """(collateral ticker, debt ticker) from a TICKER/TICKER market name."""
    parts = [p.strip().upper() for p in market.split("/")]
    if len(parts) != 2 or not all(parts):
        refuse(f"--market must be TICKER/TICKER, got {market!r}")
    return parts[0], parts[1]


def cmd_quote(args):
    c, d = split_market(args.market)
    price = O.quote_price(args.collateral_ref, args.debt_ref,
                          args.collateral_precision, args.debt_precision,
                          args.price_scale)
    back = O.unquote_price(price, args.collateral_precision,
                           args.debt_precision, args.price_scale)
    open_ltv = args.open_ltv / 100
    if not 0 < open_ltv <= 1:
        refuse("--open-ltv is a percentage above 0 and at most 100")
    print(json.dumps({
        "market": f"{c}/{d}",
        "feed_id": feed_id(args.market).hex(),
        "price": price,
        "price_scale": args.price_scale,
        "reads_as": f"1 {c} = {back:,.8f} {d}",
        "opened_at_ltv": f"{args.open_ltv:g}%",
        # The strike a loan opened at --open-ltv implies for each liquidation
        # ratio. `offer-fund` measures the ratio against the DEBT, which is the
        # principal plus the whole term's interest -- so a strike computed from
        # the principal alone sits below the one actually baked in, and quotes
        # a liquidation price a borrower would never see.
        "interest_pct": args.interest,
        # Within a rounding step of what `offer-fund` bakes: both sides ceil,
        # at different points. It is a quote, not the number that goes in.
        "strike_at_liq_ltv": {
            f"{liq}%": math.ceil(price * args.open_ltv
                                 * (1 + args.interest / 100) / liq)
            for liq in (60, 70, 75, 80)},
    }, indent=2))
    return 0


def cmd_propose(args):
    oracle_x, oracles, threshold = resolve_oracle_set(
        args.oracle_x, args.oracles, args.oracle_threshold)
    t = LoanTerms(
        collateral_asset=args.collateral_asset, debt_asset=args.debt_asset,
        collateral_amount=args.collateral_amount, principal=args.principal,
        debt=args.debt, borrower_x=args.borrower_x, lender_x=args.lender_x,
        market=args.market, oracle_x=oracle_x,
        oracles=oracles, oracle_threshold=threshold, strike=args.strike,
        not_before=args.not_before, maturity=args.maturity,
        recover_after=args.recover_after, bonus_num=args.bonus_num,
        bonus_den=args.bonus_den, price_scale=args.price_scale,
        max_price=args.max_price, memo=args.memo or "")
    warnings = t.sanity_check()
    for w in warnings:
        print(f"warning: {w}", file=sys.stderr)
    print(t.to_json())
    return 0


def match_vault(t, funded, single_leaf=None):
    """Which of the two vault layouts these terms compile to is being funded.

    A loan drawn from a funded offer sits at the offer's single-leaf vault, not
    at the four-leaf tree `propose` describes, so a check that knew only the
    four-leaf address would refuse every offer-born loan -- which is every loan
    the book makes. `single_leaf` None accepts either and says which; True or
    False demands that one. Returns "four-leaf" or "single-leaf", and raises the
    ValueError `verify_funding` raises -- which names both addresses and the one
    being funded -- when the output is not the vault asked for.
    """
    return ("single-leaf" if t.verify_funding(funded, single_leaf=single_leaf)
            else "four-leaf")


def cmd_show(args):
    t = read_terms(args.terms)
    print(t.describe(args.price))
    print(f"  four-leaf vault  (scriptPubKey) {t.script_pubkey().hex()}")
    print(f"  offer-born vault (scriptPubKey) {offer_vault_address(t).hex()}")
    return 0


def cmd_address(args):
    print(read_terms(args.terms).script_pubkey().hex())
    return 0


def _funded_spk(args):
    """The scriptPubKey being checked, read without a transaction index.

    `gettxout` answers from the utxo set, which is what a node without
    `-txindex` -- every committee node -- can do for a mined funding.
    """
    node = node_from(args)
    got = node.gettxout(args.txid, args.vout, True)
    if got is not None:
        return got["scriptPubKey"]["hex"]
    if args.blockhash:
        tx = node.getrawtransaction(args.txid, True, args.blockhash)
        return tx["vout"][args.vout]["scriptPubKey"]["hex"]
    refuse(f"{args.txid}:{args.vout} is not an unspent output: it is spent, or "
           "this node has not seen it. Pass --blockhash <block> to read it out "
           "of the block it is in, or --spk <hex> to check the terms against a "
           "scriptPubKey you already have.")


def cmd_verify(args):
    """The check. Prints what it compared, so a person can see it was done."""
    t, doc = terms_doc(args.terms)
    if args.spk:
        funded = args.spk
    else:
        if args.txid is None:
            args.txid = doc.get("txid")
            args.vout = int(doc.get("vout") or 0)
        if args.txid is None:
            refuse("verify needs --spk or --txid, or a terms file "
                   "that carries the funding outpoint")
        funded = _funded_spk(args)
    want = True if args.single_leaf else (False if args.four_leaf else None)
    try:
        shape = match_vault(t, funded, want)
    except ValueError as e:
        refuse(str(e))
    label = ("four-leaf vault" if shape == "four-leaf"
             else "single-leaf offer-born vault")
    print(f"OK: the funded output is the {label} these terms compile to")
    # Both names, because they are different and a reader needs the right one.
    # `terms id` is the hash of the address these terms compile to, which is
    # what a book files them under; `loan id` is that hashed together with the
    # funding outpoint, because one terms document can be funded more than
    # once, and it is what every book record and every URL uses. Looking one up
    # under the other finds nothing.
    print(f"  terms id   {t.loan_id()}")
    print(f"  loan id    {_book_loan_key(t, args.txid, args.vout)}")
    print(f"  scriptPubKey {funded}")
    print("  the scriptPubKey matches, and these terms compile with the NUMS")
    print("  internal key, so the funded output has no key path: an output on")
    print("  any other internal key would not have matched.")
    if shape == "four-leaf":
        print("  The four leaves are the only ways this collateral can move.")
    else:
        print("  The one leaf's selector (REPAY, LIQUIDATE, DEFAULT, RECOVER)")
        print("  is the only way this collateral can move.")
    for w in t.sanity_check():
        print(f"  ! {w}")
    return 0


def _status_once(args, t, doc):
    """One reconciliation of a loan, as `status` prints it."""
    node = node_from(args)
    single = bool(args.single_leaf or doc.get("single_leaf"))
    got = node.gettxout(args.txid, args.vout, True)
    if got is not None:
        # While the vault is unspent the chain settles which layout it is; once
        # it is spent, --single-leaf is the only way to say, because the two are
        # spent with different witnesses and the exit is read from the leaf.
        try:
            single = match_vault(t, got["scriptPubKey"]["hex"]) == "single-leaf"
        except ValueError as e:
            refuse(str(e))
    w = VaultWatcher(node, min_depth=args.min_depth)
    v = w.track(_book_loan_key(t, args.txid, args.vout), t, args.txid,
                args.vout, single_leaf=single)
    w.poll()
    out = v.to_dict()
    if args.price is not None:
        out["price"] = args.price
        out["health"] = round(t.health(args.price), 4)
        out["ltv"] = round(t.ltv(args.price), 4)
        out["liquidatable"] = t.is_liquidatable(args.price)
        out["seizure_if_liquidated"] = t.seizure_at(args.price)
        out["surplus_if_liquidated"] = t.surplus_at(args.price)
    out["height"] = node.getblockcount()
    # In the maturity's OWN units. Every other reader here uses the package
    # predicate; comparing a height to a Unix-time maturity says a loan matures
    # nine thousand years from now, so `past_maturity` would read false for
    # ever on a time-locked one -- and that field is what tells somebody the
    # DEFAULT exit has opened.
    out["past_maturity"] = _pkg_locktime_open(
        t.maturity, out["height"], median_time(node, out["height"]))
    # The BOOK's view, when there is one to ask: it holds the price by the
    # loan's own oracle keys, so health and liquidatable come without
    # anybody typing --price, and it remembers when the loan crossed. This
    # is what makes the command something a cron job can run.
    if getattr(args, "book", None):
        key = _book_loan_key(t, args.txid, args.vout)
        try:
            view = book_get(args.book, f"/v1/loan/{key}")
        except Exception as e:                          # noqa: BLE001
            out["book_error"] = f"{type(e).__name__}: {e}"
        else:
            for k in ("state", "health", "ltv", "price", "liquidatable",
                      "liquidatable_since", "recover_open",
                      "closed_confirmations", "seizure_if_liquidated",
                      "surplus_if_liquidated"):
                if k in view:
                    out[k] = view[k]
            if "past_maturity" in view:
                out["past_maturity"] = view["past_maturity"]
    # What needs a person, said as a status. 4 is "ran, and reported a state
    # that is not settled and not safe to act on": a live loan that can be
    # liquidated, called, or swept is exactly that, for both its parties.
    live = str(out.get("state", "")).upper() == "LIVE"
    needs = live and bool(out.get("liquidatable") or out.get("past_maturity")
                          or out.get("recover_open"))
    out["needs_attention"] = needs
    return out, (4 if needs else 0)


def cmd_status(args):
    t, doc = terms_doc(args.terms)
    if args.txid is None:
        args.txid = doc.get("txid")
        args.vout = int(doc.get("vout") or 0)
    if args.txid is None:
        refuse("status needs --txid, or a terms file that carries one")
    if not args.watch:
        out, rc = _status_once(args, t, doc)
        print(json.dumps(out, indent=2))
        return rc
    # --watch: one line per pass, and a change of anything that matters is a
    # line of its own, so a log of this is a history of the loan. Stopped by
    # the operator; the exit status is the last pass's.
    last, rc = None, 0
    try:
        while True:
            out, rc = _status_once(args, t, doc)
            key = (out.get("state"), out.get("liquidatable"),
                   out.get("past_maturity"), out.get("recover_open"))
            line = (f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} "
                    f"{out.get('state')} health={out.get('health', '?')} "
                    f"liquidatable={out.get('liquidatable', '?')} "
                    f"matured={out.get('past_maturity')} "
                    f"recover_open={out.get('recover_open')} "
                    f"height={out.get('height')}"
                    + (" NEEDS ATTENTION" if rc == 4 else ""))
            if key != last or args.verbose:
                print(line, flush=True)
                last = key
            time.sleep(max(5.0, float(args.interval)))
    except KeyboardInterrupt:
        return rc


def _wallet_programs(node):
    """Every payout program this wallet can be paid at, from its own coins and
    its payout address -- the same set the page calls "mine"."""
    progs = set()
    try:
        _ver, prog, _spk = wallet_payout(node)
        progs.add(prog)
    except Exception:                                   # noqa: BLE001
        pass
    for u in node.listunspent():
        spk = str(u.get("scriptPubKey", ""))
        if spk.startswith("0014") and len(spk) == 44:
            progs.add(spk[4:])
        elif spk.startswith("5120") and len(spk) == 68:
            progs.add(spk[4:])
    return progs


def cmd_loans(args):
    """List the loans a book knows, or only this wallet's.

    The page can show "mine" because it has a wallet beside it; the command
    line had no listing at all, so a lender or a borrower with a node and a
    cron job had nothing to point it at. `--mine` matches each loan's payout
    programs against this wallet's coins and payout address, which is what
    the page does too.
    """
    q = f"/v1/loans?state={args.state}" if args.state else "/v1/loans"
    if args.market:
        q += ("&" if "?" in q else "?") + f"market={args.market}"
    rows = book_get(args.book, q)["loans"]
    mine = _wallet_programs(node_from(args)) if args.mine else None
    out = []
    for l in rows:
        try:
            t = json.loads(l["terms"])
        except (TypeError, ValueError):
            continue
        role = []
        if mine is not None:
            if t.get("borrower_prog") in mine:
                role.append("borrower")
            if t.get("lender_prog") in mine:
                role.append("lender")
            if not role:
                continue
        out.append({"loan_id": l.get("loan_id"), "market": l.get("market"),
                    "state": l.get("state"), "health": l.get("health"),
                    "liquidatable": l.get("liquidatable"),
                    "liquidatable_since": l.get("liquidatable_since"),
                    "past_maturity": l.get("past_maturity"),
                    "recover_open": l.get("recover_open"),
                    "maturity": t.get("maturity"),
                    "outpoint": f"{l.get('txid')}:{l.get('vout')}",
                    "role": role or None})
    print(json.dumps({"loans": out, "count": len(out)}, indent=2))
    needs = any(r["state"] == "LIVE" and (r["liquidatable"] or r["past_maturity"]
                                          or r["recover_open"]) for r in out)
    return 4 if (needs and args.mine) else 0


def cmd_offers(args):
    """List a book's offers, or only this wallet's."""
    rows = book_get(args.book, f"/v1/offers?status={args.status}")["offers"]
    mine = _wallet_programs(node_from(args)) if args.mine else None
    out = []
    for o in rows:
        if mine is not None and o.get("lender_prog") not in mine:
            continue
        out.append({k: o.get(k) for k in
                    ("offer_id", "market", "status", "expired", "lots",
                     "lots_left", "principal", "funded_value",
                     "expiry_locktime", "outpoint")})
    print(json.dumps({"offers": out, "count": len(out)}, indent=2))
    tidy = any(o.get("expired") and (o.get("lots_left") or 0) and
               o.get("status") == "open" for o in out)
    return 4 if (tidy and args.mine) else 0


def cmd_check_attestation(args):
    with open(args.attestation) as f:
        d = json.load(f)
    # A row from a book's /v1/attestations carries the key that signed it; a
    # row from an oracle's log does not, so --oracle-x is what names it there.
    oracle_x = args.oracle_x or d.get("oracle_x")
    if not oracle_x:
        refuse("give --oracle-x: this attestation does not name the "
               "key it was signed with")
    att = O.Attestation.from_dict({k: v for k, v in d.items() if k != "oracle_x"})
    ok = O.verify(oracle_x, att)
    # The SCALE, printed and optionally checked. It is not inside the
    # signature -- it is baked into the vault's leaf instead -- so a price
    # quoted at one scale and read at another is a hundredfold error the
    # covenant cannot notice. This command is where a person is told to go and
    # confirm it, and it could not, because it never printed the number.
    scale = getattr(att, "price_scale", None)
    scale_ok = None
    if args.price_scale is not None:
        scale_ok = scale is not None and int(scale) == int(args.price_scale)
    print(json.dumps({
        "valid": ok,
        "oracle_x": oracle_x,
        "market": att.market,
        "feed_id": att.feed_id,
        "feed_matches_market": feed_id(att.market).hex() == att.feed_id,
        "price": att.price,
        "price_scale": scale,
        "price_scale_matches": scale_ok,
        "timestamp": att.timestamp,
        "message": att.message().hex(),
    }, indent=2))
    if scale_ok is False:
        print(f"the attestation quotes at price scale {scale} and --price-scale "
              f"says {args.price_scale}. The scale is NOT signed: it is baked "
              "into the vault's leaf, so the same number means a different "
              "price at each. This price cannot close that loan.",
              file=sys.stderr)
        return 2
    return 0 if ok else 2


# --- Tier C: pledged collateral on an OpenAMP asset --------------------------

def _party_key(spec, what):
    """A party's own private key, from a FILE or from stdin ("-"), or None.

    Never from the command line: a hex secret in argv is readable by every
    process on the machine and is kept in the shell's history. Every failure
    says whose key was wanted, because the party running the command is often
    not the party whose signature is missing, and "TypeError: expected str"
    tells them nothing about which of the two to go and fetch.
    """
    if not spec:
        return None
    try:
        raw = (sys.stdin.read() if spec == "-" else open(spec).read()).strip()
    except OSError as e:
        refuse(f"cannot read {what} from {spec}: {e.strerror}")
    try:
        sec = bytes.fromhex(raw)
    except ValueError:
        refuse(f"{spec} does not hold {what} as hex")
    try:
        return O.check_key(sec, f"{what} in {spec}")
    except ValueError as e:
        refuse(str(e))


def _pledge_sig(spec, action, pledge_id, extra, signer_x=None):
    """Another party's countersignature, checked before it is sent anywhere.

    `spec` is the hex signature, or the path of the JSON `pledge-sign` printed
    -- which carries the key that signed it, so the check below needs nothing
    else. A signature the issuer would reject is one the caller can still fix,
    and finding that out here says why.
    """
    from pignus import openamp as OA
    named = "repayment" if action == "release" else "reason"
    sig = spec
    if os.path.exists(spec):
        with open(spec) as f:
            d = json.load(f)
        if d.get("action") != action or d.get("pledge") != pledge_id:
            refuse(
                f"that signature authorises {d.get('action')} on pledge "
                f"{d.get('pledge')}, not {action} on {pledge_id}")
        if (d.get("extra") or "") != (extra or ""):
            refuse(f"that signature covers a different {named}: "
                   f"{d.get('extra')!r}")
        sig = d.get("signature")
        signer_x = signer_x or d.get("signed_by")
    if signer_x and not OA.verify_pledge_sig(signer_x, sig, action, pledge_id,
                                             extra):
        refuse(f"that signature does not verify for this pledge and "
               f"{named}")
    return sig


def cmd_pledge_sign(args):
    """Produce the authorisation the issuer's server demands.

    Printed rather than sent, deliberately: the party whose key this is may not
    be the party running the command, and a signature is the thing that travels
    between them. `pignus-cli pledge-release --lender-sig` and `pignus-cli
    pledge-seize --lender-sig/--holder-sig` send one.
    """
    from pignus import openamp as OA
    sec = _party_key(args.key, "your own private key")
    sig = OA.sign_pledge(sec, args.action, args.pledge, args.extra)
    print(json.dumps({
        "pledge": args.pledge, "action": args.action, "extra": args.extra,
        "signed_by": OA.party_key(sec),
        "message": OA.pledge_message(args.action, args.pledge, args.extra).hex(),
        "signature": sig,
    }, indent=2))
    _say_tier_c()


def _pledge_client(args):
    from pignus import openamp as OA
    tok = args.token
    if not tok:
        refuse(
            "an issuer token is required (--token, or PIGNUS_ISSUER_TOKEN in the "
            "environment). It authenticates the caller; it does NOT authorise a "
            "release or a seizure, which need the parties' own signatures.")
    try:
        return OA.PledgeClient(args.issuer, tok,
                               allow_insecure=bool(args.allow_insecure_issuer))
    except ValueError as e:
        refuse(str(e))


def _say_tier_c(pledge=None):
    """The one sentence that says what a Tier C lender's security actually is.

    Printed by every pledge command, on stderr so it never lands in a JSON
    pipeline. A Tier C pledge is not a covenant: the collateral is locked by an
    issuer's policy server, and the lender's security is that issuer's promise.
    Presenting that quietly beside a Tier A loan, which no one can undo, would
    be a lie of omission -- and the moment it matters most is the moment
    somebody is acting, not the moment they are browsing.
    """
    from pignus import openamp as OA                     # noqa: PLC0415
    if pledge:
        print(OA.describe(pledge), file=sys.stderr)
        return
    print("Tier C: issuer-permissioned. The collateral is locked by the "
          "issuer's policy server, not by a covenant, so the lender's security "
          "is that issuer's promise.", file=sys.stderr)


def cmd_pledge_list(args):
    """Tier C pledges at an issuer, all of them or one by id.

    `--pledge` narrows to one, which is what a lender is told to run after a
    seizure the issuer accepted but has not delivered: the state it moved to is
    the thing that decides whether the collateral is theirs yet.
    """
    from pignus import openamp as OA
    client = _pledge_client(args)
    if getattr(args, "pledge", None):
        one = client.get(args.pledge)
        if one is None:
            refuse(f"this issuer has no pledge {args.pledge}")
        out = [one]
    else:
        out = client.list(asset=args.asset, holder=args.holder,
                          lender=args.lender, state=args.state)
    if args.json:
        print(json.dumps(out, indent=2))
        return
    for p in out:
        print(OA.describe(p))
    if not out:
        print("no pledges match")


def cmd_pledge_create(args):
    # openamp.py refuses a pledge of nothing, and one where the two parties are
    # the same, with a sentence saying why. A traceback would bury it.
    try:
        p = _pledge_client(args).create(
            args.asset, args.borrower, args.lender, args.atoms, args.maturity,
            debt_asset=args.debt_asset, debt_atoms=args.debt_atoms,
            note=args.note)
    except ValueError as e:
        refuse(str(e))
    print(json.dumps(p, indent=2))
    _say_tier_c(p)


def cmd_pledge_release(args):
    if not (args.key or args.lender_sig or args.force):
        refuse("a release is the lender's word that the debt is "
               "settled: give --key <file>, or --lender-sig from "
               "`pledge-sign`, or --force with a --reason")
    sec = _party_key(args.key, "the LENDER's private key")
    signed = {}
    if args.lender_sig:
        signed["lender_sig"] = _pledge_sig(args.lender_sig, "release",
                                           args.pledge, args.repaid_txid,
                                           args.lender_x)
    try:
        out = _pledge_client(args).release(
            args.pledge, lender_sec=sec, repaid_txid=args.repaid_txid,
            force=args.force, reason=args.reason, **signed)
    except ValueError as e:
        refuse(str(e))
    print(json.dumps(out, indent=2))
    p = out.get("pledge") if isinstance(out, dict) else None
    _say_tier_c(p if isinstance(p, dict) and p.get("id") else None)
    if isinstance(out, dict) and out.get("idempotent"):
        print("this pledge was already released earlier; nothing changed",
              file=sys.stderr)


def cmd_pledge_seize(args):
    """Deliver defaulted collateral to the lender.

    Before maturity the borrower must countersign, and what travels here is
    that signature -- produced on the borrower's own machine with `pledge-sign
    --action seize` -- never their key. A lender holding the borrower's key
    could spend every enclave output that key controls, which is not what
    surrendering one pledge means.
    """
    if not (args.key or args.lender_sig):
        refuse("a seizure needs the lender's authorisation: --key "
               "<file>, or --lender-sig from `pledge-sign`")
    lender = _party_key(args.key, "the LENDER's private key")
    signed = {}
    if args.holder_sig:
        signed["holder_sig"] = _pledge_sig(args.holder_sig, "seize", args.pledge,
                                           args.reason, args.holder_x)
    if args.lender_sig:
        signed["lender_sig"] = _pledge_sig(args.lender_sig, "seize", args.pledge,
                                           args.reason, args.lender_x)
    try:
        out = _pledge_client(args).seize(args.pledge, args.reason, lender,
                                         **signed)
    except ValueError as e:
        refuse(str(e))
    print(json.dumps(out, indent=2))
    _say_tier_c(out.get("pledge") if isinstance(out, dict)
                and isinstance(out.get("pledge"), dict) else None)
    # Say what actually happened, from the SHAPE of the answer. The issuer
    # answers a demo-asset seizure with the delivering `txid`; an
    # external-issuer asset answers with sighashes `to_sign` and where to
    # complete them, and holds the asset until the issuer signs. Reporting
    # the second as delivered told a lender the collateral was theirs while
    # the pending sweep quietly expired.
    if isinstance(out, dict) and out.get("to_sign"):
        n = len(out.get("to_sign") or [])
        print(f"the issuer accepted the request and holds the collateral: "
              f"{n} sighash(es) must be signed by the issuer's key and posted "
              f"to {out.get('complete_at') or 'the completion endpoint'} "
              f"before it moves. The pledge is still open; nothing has been "
              f"delivered.", file=sys.stderr)
        return 4
    if isinstance(out, dict) and out.get("txid"):
        print(f"the collateral has been delivered to the lender in "
              f"{out['txid']}", file=sys.stderr)
        return 0
    print("the issuer answered, but with neither a delivering transaction nor "
          "sighashes to complete; check the pledge before treating the "
          "collateral as delivered", file=sys.stderr)
    return 4


# --- Tier D: a repurchase against an OpenDAMP asset --------------------------

def cmd_repo_propose(args):
    from pignus.repurchase import RepurchaseTerms
    t = RepurchaseTerms(
        collateral_asset=args.collateral_asset,
        collateral_amount=args.collateral_amount,
        debt_asset=args.debt_asset, principal=args.principal, debt=args.debt,
        collateral_value=args.collateral_value,
        borrower_cu=args.borrower_cu, borrower_prog=args.borrower_prog,
        lender_prog=args.lender_prog, borrower_ver=args.borrower_ver,
        lender_ver=args.lender_ver, forfeit_after=args.forfeit_after,
    ).sanity_check()
    doc = json.loads(t.to_json())
    doc["bond"] = t.bond()
    doc["address_program"] = t.script_pubkey().hex()
    doc["product"] = "repurchase"
    doc["confirmation"] = t.describe()
    out = json.dumps(doc, indent=2)
    if args.out:
        with open(args.out, "w") as f:
            f.write(out + "\n")
        print(f"wrote {args.out}")
    else:
        print(out)
    # Said every time, because it decides whether to sell under it at all.
    print("NOTE: the buyback is signed by the lender with their OpenDAMP key, "
          "against the issuer's current policy snapshot (opendamp "
          "transfer-cosign). A lender who cannot produce that signature "
          "cannot return the asset, and the bond is all a settlement that "
          "never comes leaves the holder with. Sell only under a lender who "
          "can.", file=sys.stderr)


def cmd_repo_show(args):
    from pignus.repurchase import settlement_shape, LOCKTIME_THRESHOLD
    t = _repo_terms(args)
    print(t.describe())
    print()
    print(f"bond            {t.bond()} atoms of {t.debt_asset}")
    print(f"vault program   {t.script_pubkey().hex()}")
    # In its OWN units, because `sanity_check` accepts either: below
    # LOCKTIME_THRESHOLD it is a block height, at or above it a Unix time.
    # Printing "height" for a timestamp is how somebody reads 1,790,000,000 as
    # a block a million years away and concludes the bond is unreachable.
    _ff = int(t.forfeit_after)
    if _ff < LOCKTIME_THRESHOLD:
        print(f"forfeit after   height {_ff}")
    else:
        import datetime as _dt
        when = _dt.datetime.fromtimestamp(_ff, _dt.timezone.utc)
        print(f"forfeit after   Unix time {_ff} "
              f"({when.strftime('%Y-%m-%d %H:%M UTC')}), which a node compares "
              f"to the chain's median time, not to a height")
    print()
    shape = settlement_shape(True)
    print("settlement (exactly OpenDAMP's limits, no spare slot in either "
          "direction):")
    for i in shape["inputs"]:
        print("  in   " + i)
    for o in shape["outputs"]:
        print("  out  " + o)


def cmd_repo_verify(args):
    """Both halves, or neither is worth anything.

    The bond is what the borrower gets back if the lender never returns the
    asset; the collateral leg is the lender actually receiving that asset. A
    check that passed on the bond alone would call a repurchase good while
    nobody had looked at the half it secures, so this reports a `state` rather
    than an `ok`, and `bond-only` is what a bond on its own earns.
    """
    from pignus import repurchase as R
    t = _repo_terms(args)
    node = node_from(args)
    if bool(args.leg_txid) != bool(args.lender_cu):
        refuse("--leg-txid and --lender-cu go together: leg one is "
               "checked by finding the collateral asset at the "
               "lender's own C_U address, which needs both")
    # A SPENT bond is not a failed check, it is a finished repurchase -- the
    # settlement or the forfeit took it. `verify_funding` cannot tell the two
    # apart (both look like "no such unspent output"), so the outpoint is asked
    # about first. Without this the `settled` state, which the documentation
    # lists, was reachable from no command at all.
    spent = False
    if args.vout is not None:
        try:
            spent = node.gettxout(args.txid, int(args.vout), True) is None
        except Exception:                               # noqa: BLE001
            spent = False
    bond = None
    unfunded_why = None
    if not spent:
        try:
            bond = t.verify_funding(node, args.txid,
                                    None if args.vout is None else args.vout)
        except R.NotFunded:
            # A bond nobody funded is a STATE, and the one a borrower who
            # transferred the asset before the lender posted security most
            # needs to hear. Refusing here reported it as a bad terms document
            # and made `not-funded` and `leg-one-only` -- both documented --
            # reachable from no command at all.
            #
            # A scan that finds nothing cannot tell "never funded" from
            # "settled or forfeited": a spent bond also pays nothing now. Only
            # a named output can, so a scan says so rather than reporting a
            # finished repurchase as one the lender never secured.
            bond = None
            if args.vout is None:
                unfunded_why = ("no unspent output pays the bond address: "
                                "never funded, or already settled or "
                                "forfeited. Pass --vout to name the bond's "
                                "output and tell the two apart")
        except ValueError as e:
            refuse(str(e))
    elif args.vout is not None:
        # A named output that is spent: still make sure it WAS the bond, or a
        # lender's spent change output reads as a settled repurchase while
        # the bond sits unspent at the next index.
        try:
            live = t.verify_funding(node, args.txid)
        except R.NotFunded:
            live = None
        except ValueError as e:
            # An unspent output DOES pay the bond address, wrongly: that is
            # not a finished repurchase, whatever the named output says.
            refuse(f"the output at {args.txid}:{args.vout} is spent, but "
                   f"another output of that transaction pays the bond "
                   f"address and is not the bond: {e}")
        if live is not None:
            refuse(f"the output at {args.txid}:{args.vout} is spent, but the "
                   f"bond these terms compile to is still unspent at "
                   f"{args.txid}:{live['n']}; that was not the bond's output")
    leg = None
    if args.leg_txid:
        # C_U(Y) is a taproot output: the OpenDAMP verifier only ever pays the
        # restricted asset to one, so the program given is a v1 program.
        cu_spk = payout_spk(1, args.lender_cu).hex()
        try:
            leg = R.verify_leg_one(node, args.leg_txid, cu_spk,
                                   t.collateral_asset, t.collateral_amount,
                                   args.min_confirmations, vout=args.leg_vout)
        except ValueError as e:
            refuse(str(e))
    height = int(node.getblockcount())
    # The median time as well as the height, because `forfeit_after` may be
    # written in either unit and only the chain can say whether a time-valued
    # one has passed.
    state = R.repurchase_state(t, height, bond=bond, leg_one=leg,
                               bond_spent=spent,
                               min_confirmations=args.min_confirmations,
                               now=median_time(node, height))
    print(json.dumps({
        "product": "repurchase",
        "state": state,
        "bond": t.bond(),
        "bond_txid": args.txid,
        "bond_vout": (args.vout if bond is None else bond["n"]),
        "bond_asset": None if bond is None else bond.get("asset"),
        "bond_confirmations": (0 if bond is None
                               else int(bond.get("confirmations") or 0)),
        "leg_one": None if leg is None else {
            "txid": args.leg_txid,
            "vout": leg["n"],
            "asset": leg.get("asset"),
            "confirmations": int(leg.get("confirmations") or 0),
        },
        "min_confirmations": args.min_confirmations,
    }, indent=2))
    if state == "not-funded" and unfunded_why:
        print(unfunded_why, file=sys.stderr)
    if state == "settled":
        print("settled: the bond vault has been spent, which is what a "
              "settlement or a forfeit does. Read the spending transaction to "
              "see which.", file=sys.stderr)
    elif leg is None:
        print("bond-only: nothing here has checked that the collateral reached "
              "the lender. Pass --leg-txid and --lender-cu to check leg one.",
              file=sys.stderr)
    elif state == "funded-unburied":
        print(f"both halves are on chain but not yet {args.min_confirmations} "
              "deep; until they are, either can still be reorged away",
              file=sys.stderr)
    print(t.describe(), file=sys.stderr)
    # A script has to be able to tell a repurchase that was fully checked from
    # one where half of it was never looked at. Both printed the same status,
    # so an unattended caller could not: `bond-only` means the collateral leg
    # is unexamined, and `funded-unburied` means either half can still be
    # reorged away. Neither is a repurchase anybody should act on.
    return 0 if state in ("live", "forfeitable", "settled") else 4


def _repo_terms(args):
    from pignus.repurchase import RepurchaseTerms
    with open(args.terms) as f:
        return RepurchaseTerms.from_json(f.read())


def cmd_repo_fund(args):
    """Pay the bond into the repurchase vault -- the coin every other repo
    command acts on. Derives the address from the terms; nobody has to trust a
    supplied one."""
    node = node_from(args)
    t = _repo_terms(args)
    spk = t.script_pubkey()
    addr = node.deriveaddresses(
        node.getdescriptorinfo(f"raw({spk.hex()})")["descriptor"])[0]
    # The bond set aside first: a wallet funded with exactly the bond picked
    # the bond asset for its own fee and failed at the send with a message
    # naming nothing.
    fee_asset, fee, rate = choose_fee(node, args, "fund", prefer=[t.debt_asset],
                                      committed={t.debt_asset: t.bond()})
    if args.dry_run:
        print(json.dumps({"dry_run": True, "address": addr,
                          "scriptPubKey": spk.hex(), "bond": t.bond(),
                          "asset": t.debt_asset, "fee_asset": fee_asset,
                          "fee": fee}, indent=2))
        return 0
    txid = node.sendtoaddress(address=addr, amount=units(t.bond()),
                              assetlabel=t.debt_asset,
                              fee_asset_label=fee_asset)
    # The wallet orders its own outputs, so the bond is not necessarily first;
    # the same check repo-verify makes finds it, and confirms the bond landed
    # explicit, in the right asset and at exactly the right amount.
    try:
        vout = int(t.verify_funding(node, txid)["n"])
    except ValueError as e:
        # NOT `refuse`. That means "nothing was built, nothing was sent", and
        # status 2 is what a script reads it by -- but the money has already
        # gone out one line above. Saying "REFUSED" here would tell an operator
        # their bond is still in their wallet when it is on the chain.
        print(f"the bond was BROADCAST at {txid}, and it is not the bond these "
              f"terms make: {e}\n"
              f"The money is on chain. Nothing further has been signed. Read "
              f"the transaction, and use `pignus-cli repo-verify --txid {txid}` "
              f"to see what actually landed before doing anything else.",
              file=sys.stderr)
        raise SystemExit(1) from None
    print(f"funded the bond ({t.bond()} atoms of {t.debt_asset[:12]}...) at "
          f"{txid}:{vout}  (fee in {fee_asset[:8]}…)", file=sys.stderr)
    print(json.dumps({"txid": txid, "vout": vout}))
    return 0


def _repo_spender(args, node, t):
    from pignus.repurchase import RepurchaseSpender, NotFunded
    from pignus.vault import Outpoint
    # The bond is FOUND, not assumed at vout 0. `repo-fund` and `repo-verify`
    # scan for the output that pays the bond address; a spender that defaulted
    # to 0 read the lender's own spent change as "that bond vault is already
    # spent", and told a borrower whose bond sits at vout 1 that their
    # counterparty had cheated.
    if args.vout is None:
        try:
            o = t.verify_funding(node, args.txid)
        except NotFunded:
            refuse("no unspent output of that transaction pays the bond "
                   "address these terms compile to: the bond was never "
                   "funded there, or it has already been settled or "
                   "forfeited. Pass --vout to name the output and tell the "
                   "two apart.")
        except ValueError as e:
            refuse(str(e))
        args.vout = int(o["n"])
    got = node.gettxout(args.txid, args.vout, True)
    if got is None:
        refuse(f"there is no unspent output at {args.txid}:{args.vout}: the "
               f"bond there has already been settled or forfeited, or that "
               f"was never the bond's output (leave --vout off to find it)")
    if got["scriptPubKey"]["hex"] != t.script_pubkey().hex():
        refuse("the coin is not the vault these terms compile to")
    if "value" not in got or "asset" not in got:
        refuse("the bond output is blinded; a repurchase bond must be explicit, "
               "because the covenant compares its value")
    if got["asset"] != t.debt_asset:
        refuse(f"the coin at that address holds {got['asset'][:12]}..., not the "
               f"bond asset {t.debt_asset[:12]}...")
    bond = atoms(got["value"])
    if bond != t.bond():
        refuse(f"the vault holds {bond} atoms but these terms make the bond "
               f"exactly {t.bond()}; the repurchase you were shown is not the "
               "one being funded")
    coin = Outpoint(args.txid, args.vout, bond, t.debt_asset)
    return RepurchaseSpender, coin


def cmd_repo_forfeit(args):
    """FORFEIT: after the deadline, sweep the bond back to the borrower.
    Permissionless -- it can only ever pay the borrower."""
    from pignus.vault import wallet_payout
    node = node_from(args)
    t = _repo_terms(args)
    RepurchaseSpender, coin = _repo_spender(args, node, t)
    # In the deadline's OWN units. `sanity_check` accepts a Unix-time
    # `forfeit_after`, and FORFEIT is the borrower's only remedy when a lender
    # never sells the asset back: comparing a timestamp to a block height
    # refuses it for ever and strands their equity in the vault.
    locktime_open(node, t.forfeit_after, "FORFEIT")
    fee_asset, fee, rate = choose_fee(node, args, "recover",
                                      prefer=[t.debt_asset])
    _ver, _prog, change = wallet_payout(node)
    funding = _funding_for(args, node, {fee_asset: fee},
                           exclude=[(coin.txid, coin.vout)], fee_asset=fee_asset)
    if funding is None:
        return 0
    # The NODE's threshold for this asset, not the composer's fallback: an
    # asset the node prices differently has its dust somewhere else, and change
    # folded at the wrong figure is either a dust output nobody can spend or a
    # gift to the block producer.
    sp = RepurchaseSpender(node, t, fee_asset, fee,
                           dust_fold=dust_fold_for(rate))
    raw = sp.forfeit(coin, funding, change)
    if args.dry_run:
        print(raw); return 0
    txid = node.sendrawtransaction(raw)
    print(f"forfeit swept the bond to the borrower: {txid}", file=sys.stderr)
    print(json.dumps({"txid": txid, "exit": "forfeit"}))
    return 0


def _coin_from_chain(node, spec, what):
    """One coin of a settlement, read off the chain rather than off a document.

    Every amount and asset here comes from `gettxout`. A settlement moves four
    coins at once and the verifier compares what it sees, so a number somebody
    typed into a file is a number nobody checked.
    """
    txid, _, vout = str(spec).rpartition(":")
    if not txid or not vout.isdigit():
        refuse(f"{what} takes txid:vout, got {spec!r}")
    got = node.gettxout(txid, int(vout), True)
    if got is None:
        refuse(f"{what} {spec} is not an unspent output: it is spent, or this "
               "node has not seen it")
    if "value" not in got or "asset" not in got:
        refuse(f"{what} {spec} is blinded; every coin in a settlement must be "
               "explicit, because the covenant and OpenDAMP's verifier both "
               "compare values they have to be able to read")
    coin = Outpoint(txid, int(vout), atoms(got["value"]), got["asset"])
    # Kept beside it, because a caller sometimes has to prove the script it was
    # given IS this coin's. `Outpoint` is the shape the covenant code takes and
    # it carries no script, so this rides alongside rather than inside it.
    coin_spk[(txid, int(vout))] = got["scriptPubKey"]["hex"]
    return coin


# What each coin `_coin_from_chain` read actually pays to. One dictionary
# rather than a wider Outpoint: every covenant path takes the dataclass as it
# is, and widening it would touch code that has golden vectors behind it.
coin_spk = {}


def _input_has_witness(raw, index):
    """Whether input `index` of this transaction carries a witness stack."""
    from pignus.vault import _tf
    m, _ = _tf()
    tx = m.tx_from_hex(raw)
    wit = tx.wit.vtxinwit
    return index < len(wit) and bool(wit[index].scriptWitness.stack)


def _check_settlement_signed(raw):
    """Every signature but RETURN's is on, or the missing one is named.

    Three parties sign a settlement and none of them can see the others'
    work in a node's refusal, which is a script error naming nothing.
    """
    from pignus.repurchase import SETTLEMENT_DEBT_INDEX
    who = {0: ("the OpenDAMP verifier", "the lender, with `opendamp "
               "transfer-cosign --snapshot <policy snapshot> --transaction "
               "<the skeleton> --sender-privkey <their OpenDAMP key>`"),
           2: ("the lender's C_U", "the lender, with the same "
               "`opendamp transfer-cosign` that signs input 0"),
           SETTLEMENT_DEBT_INDEX: ("the borrower's debt coin", "the borrower's "
               "wallet, which `repo-settle --skeleton` does when that wallet "
               "composes, and `signrawtransactionwithwallet` does otherwise")}
    for idx, (what, signer) in who.items():
        if not _input_has_witness(raw, idx):
            refuse(f"input {idx} ({what}) carries no witness. It is signed by "
                   f"{signer}. RETURN goes on last, so nothing is attached "
                   "until every other signature is on.")


def _check_settlement_vault(raw, terms, args):
    """The transaction being witnessed really spends the vault named.

    `repo-settle --attach` names the bond with --txid, and with --vout when
    the caller has it. A witness put on the wrong transaction is refused by
    the node with a script error that names nothing, and a person then looks
    for a fault in the covenant rather than in the arguments they typed.
    """
    from pignus.repurchase import SETTLEMENT_VAULT_INDEX
    from pignus.vault import _tf
    m, _ = _tf()
    try:
        tx = m.tx_from_hex(raw)
    except Exception as e:                              # noqa: BLE001
        refuse(f"--attach is not a transaction this can read: {e}")
    if SETTLEMENT_VAULT_INDEX >= len(tx.vin):
        refuse(f"that transaction has {len(tx.vin)} input(s); a settlement's "
               f"bond vault sits at input {SETTLEMENT_VAULT_INDEX}")
    vin = tx.vin[SETTLEMENT_VAULT_INDEX]
    got = (f"{vin.prevout.hash:064x}", int(vin.prevout.n))
    if args.vout is None:
        # Left to be found, as every other repurchase command finds it: the
        # bond is whichever output of --txid the skeleton spends at input 1,
        # and the checks below then judge that outpoint.
        if got[0] != str(args.txid).lower():
            refuse(f"input {SETTLEMENT_VAULT_INDEX} of that transaction spends "
                   f"{got[0]}:{got[1]}, and --txid names {str(args.txid).lower()}. "
                   "This witness belongs to a different settlement.")
        args.vout = got[1]
    want = (str(args.txid).lower(), int(args.vout))
    if got != want:
        refuse(f"input {SETTLEMENT_VAULT_INDEX} of that transaction spends "
               f"{got[0]}:{got[1]}, and --txid/--vout name {want[0]}:{want[1]}. "
               "This witness belongs to a different settlement.")


def cmd_repo_settle(args):
    """SETTLE: the asset home to the borrower's C_U, the bond to the lender.

    Composed unsigned and never broadcast here, because three of the four inputs
    belong to somebody else: input 0 is the OpenDAMP verifier's own output, 2 is
    the lender's C_U holding the asset and 3 is the borrower's debt coin. The
    covenant's witness goes on LAST, with --attach, because a wallet rewrites
    the witness structure when it signs.

    The vault can only sit at input 1: an OpenDAMP transfer must spend the
    verifier at input 0, and the covenant credits at output 2k and returns at
    2k+1 for a vault at input k, so any other index puts its outputs where the
    borrower's change and the fee have to be.
    """
    from pignus import repurchase as R
    from pignus.vault import wallet_payout
    t = _repo_terms(args)

    if args.inspect:
        # The LENDER's look before signing. No node: the document carries the
        # four spent outputs, and the terms say where everything must go.
        with open(args.inspect) as f:
            text = f.read().strip()
        try:
            doc = json.loads(text)
            raw, prevouts = str(doc["tx"]), list(doc["prevouts"])
        except (ValueError, KeyError, TypeError):
            refuse("--inspect takes the document repo-settle --skeleton wrote: "
                   "`tx` and `prevouts`")
        _check_settlement_vault(raw, t, args)
        problems, summary = R.inspect_settlement(raw, prevouts, t,
                                                 lender_cu=args.lender_cu)
        if problems:
            for pr in problems:
                print(f"  NOT what the terms say: {pr}", file=sys.stderr)
            print("do not sign this", file=sys.stderr)
        else:
            print(f"this settlement pays the lender {summary['debt_to_lender']} "
                  f"atoms of {t.debt_asset[:12]}... (the debt) and releases the "
                  f"{summary['bond_to_lender']}-atom bond to them; "
                  f"{summary['asset_to_borrower']} atoms of "
                  f"{t.collateral_asset[:12]}... go home to C_U(borrower); fee "
                  f"{summary['fee']}, change {summary['change']}, locktime "
                  f"{summary['locktime']}. Every output is where the terms say.",
                  file=sys.stderr)
        print(json.dumps({"ok": not problems, "problems": problems, **summary}))
        return 0 if not problems else 4

    node = node_from(args)

    if args.attach:
        raw = args.attach
        if os.path.exists(raw):
            with open(raw) as f:
                raw = f.read().strip()
        elif "/" in raw or raw.endswith(".json"):
            refuse(f"--attach names {raw}, and there is no such file")
        # The document --skeleton wrote and `opendamp transfer-cosign` wrote
        # back, or the bare transaction.
        if raw.startswith("{"):
            try:
                raw = str(json.loads(raw)["tx"])
            except (ValueError, KeyError, TypeError):
                refuse("--attach is a document with no `tx` field. The "
                       "skeleton repo-settle writes carries one, and so does "
                       "what opendamp transfer-cosign writes back")
        # The fee is already in the transaction being witnessed; this spender
        # COMPOSES NOTHING -- it is constructed only for the leaf and the
        # control block, so it makes no change output and needs no dust
        # threshold.
        sp = R.RepurchaseSpender(node, t, t.debt_asset, 0)
        # ...and the input being witnessed has to be THIS vault. --txid/--vout
        # were required and then ignored, so a witness could be attached to
        # somebody else's settlement, where it fails at the node with a script
        # error that names nothing.
        _check_settlement_vault(raw, t, args)
        # Every other party's signature is on before RETURN goes on last. A
        # node refuses a transaction missing one with a script error that
        # names nothing; this names the input and who signs it.
        _check_settlement_signed(raw)
        signed = sp.attach_return_witness(raw)
        if args.broadcast:
            txid = node.sendrawtransaction(signed)
            print(f"settled: asset delivered to the borrower, bond released to "
                  f"the lender: {txid}", file=sys.stderr)
            print(json.dumps({"txid": txid, "exit": "return"}))
            return 0
        print(signed)
        print("the RETURN witness is on; broadcast it with sendrawtransaction, "
              "or re-run with --broadcast", file=sys.stderr)
        return 0

    missing = [n for n, v in (("--verifier", args.verifier),
                              ("--verifier-spk", args.verifier_spk),
                              ("--cu-lender", args.cu_lender),
                              ("--debt-utxo", args.debt_utxo)) if not v]
    if missing:
        refuse(
            "a settlement spends four named coins, in one order and no other: "
            + ", ".join(missing) + " missing. The verifier output and its own "
            "script come from the OpenDAMP transfer tool; --cu-lender is the "
            "lender's C_U coin holding the asset and --debt-utxo the "
            "borrower's coin paying the debt")

    _RepurchaseSpender, vault = _repo_spender(args, node, t)
    fee_asset, fee, rate = choose_fee(node, args, "repay",
                                      prefer=[t.debt_asset],
                                      committed={t.debt_asset: int(t.debt)})
    if fee_asset != t.debt_asset:
        refuse(f"a settlement pays its fee in the debt asset "
               f"{t.debt_asset[:12]}..., not {fee_asset[:12]}...: all four "
               "inputs OpenDAMP allows are spoken for, so there is no input a "
               "fee in another asset could be paid from")
    verifier = _coin_from_chain(node, args.verifier, "--verifier")
    cu_lender = _coin_from_chain(node, args.cu_lender, "--cu-lender")
    debt_input = _coin_from_chain(node, args.debt_utxo, "--debt-utxo")
    # Output 0 returns the verifier's coin to the address it came from, so the
    # script given has to BE that address. Mistype it and the settlement is
    # otherwise perfectly valid: it simply sends the issuer's verifier asset
    # somewhere nothing can ever spend it from, and no other check would say so.
    on_chain = coin_spk.get((verifier.txid, verifier.vout))
    if on_chain and on_chain != str(args.verifier_spk).lower():
        refuse(f"--verifier-spk is {str(args.verifier_spk)[:20]}... and the "
               f"verifier coin actually pays {on_chain[:20]}.... Output 0 "
               "returns that coin to itself, so a different script would send "
               "the issuer's verifier asset somewhere nothing can spend it "
               "from.")
    if args.change_spk:
        change_spk = bytes.fromhex(args.change_spk)
    else:
        # Output 4 is the BORROWER's change of the debt asset, so it defaults to
        # this wallet only because the borrower is who composes a settlement.
        _ver, _prog, change_spk = wallet_payout(node)
    sp = R.RepurchaseSpender(node, t, fee_asset, fee,
                             dust_fold=dust_fold_for(rate))
    try:
        raw = sp.compose_settlement(vault, verifier,
                                    bytes.fromhex(args.verifier_spk),
                                    cu_lender, debt_input, change_spk,
                                    locktime=int(args.locktime or 0))
    except ValueError as e:
        refuse(str(e))
    print(f"composed a settlement: {t.collateral_amount} atoms of "
          f"{t.collateral_asset[:12]}... home to the borrower's C_U, {t.debt} "
          f"to the lender, the {t.bond()}-atom bond released, fee {fee} atoms "
          f"of {fee_asset[:8]}…", file=sys.stderr)
    # The borrower's own coin is signed HERE, by the wallet that composes,
    # before anybody else touches the transaction: a wallet rewrites the
    # witness structure when it signs, so it goes first. The OpenDAMP tool
    # leaves every witness it finds in place, and RETURN goes on last.
    try:
        hexed = str(node.signrawtransactionwithwallet(raw).get("hex") or raw)
    except Exception as e:                                  # noqa: BLE001
        print(f"  this wallet could not sign the debt coin: {e}", file=sys.stderr)
        hexed = raw
    debt_signed = _input_has_witness(hexed, R.SETTLEMENT_DEBT_INDEX)
    # What a signer that did not build this needs beside it: the four outputs
    # it spends, in input order, every one read off the chain above.
    prevouts = []
    for coin in (verifier, vault, cu_lender, debt_input):
        spk = coin_spk.get((coin.txid, coin.vout))
        if coin is vault:
            spk = t.script_pubkey().hex()
        prevouts.append({"asset": coin.asset, "value": int(coin.amount),
                         "script_pubkey": spk})
    doc = {"tx": hexed, "prevouts": prevouts,
           "vault_index": R.SETTLEMENT_VAULT_INDEX,
           "debt_input_signed": debt_signed}
    if debt_signed:
        print("  the borrower's debt coin (input 3) is signed", file=sys.stderr)
    else:
        print("  the borrower's debt coin (input 3) is NOT signed: this wallet "
              "does not hold it. The borrower signs the document's `tx` with "
              "signrawtransactionwithwallet and puts the result back in it, "
              "BEFORE the lender signs.", file=sys.stderr)
    print("  next, the lender signs the OpenDAMP inputs (0 and 2), which "
          "needs their OpenDAMP key and the issuer's current policy "
          "snapshot:\n"
          "    opendamp transfer-cosign --snapshot <policy snapshot> "
          f"--transaction {args.skeleton or 'settle.json'} "
          "--sender-privkey <the lender's OpenDAMP key> "
          "--out settle.signed.json\n"
          "  then RETURN goes on last, and it is broadcast:\n"
          f"    pignus-cli repo-settle {args.terms} --txid {args.txid} "
          + (f"--vout {args.vout} " if args.vout is not None else "")
          + "--attach settle.signed.json --broadcast", file=sys.stderr)
    if args.skeleton:
        with open(args.skeleton, "w") as f:
            f.write(json.dumps(doc, indent=1) + "\n")
        print(f"wrote {args.skeleton}", file=sys.stderr)
        print(json.dumps({"skeleton": args.skeleton,
                          "vault_index": R.SETTLEMENT_VAULT_INDEX,
                          "debt_input_signed": debt_signed}))
        return 0
    print(json.dumps(doc, indent=1))
    return 0



# --- driving a loan from a node wallet ---------------------------------------
#
# Everything the page does, from the command line, with a node wallet holding
# the keys. Each command derives the address it acts on from the terms and
# checks it against the coin before building anything, which is the same check
# `verify` makes by hand.

import math                                                  # noqa: E402
import urllib.error                                          # noqa: E402
import urllib.request                                        # noqa: E402

from pignus import fees as F                                 # noqa: E402
from pignus.offers import offer_address, offer_vault_address  # noqa: E402
from pignus.vault import (                                   # noqa: E402
    Outpoint, VaultSpender, _holdings, fund_offer, take_offer, withdraw_offer,
    wallet_payout, payout_spk,
)

RECOVER_GAP_DAYS = 30          # how long after maturity RECOVER opens
# Where a node stops reading an absolute locktime as a block height and starts
# reading it as a Unix time. Consensus, not convention.
LOCKTIME_THRESHOLD = 500_000_000


def median_time(node, height=None):
    """The chain's median time past, which is what consensus compares a TIME
    locktime to. None when it cannot be read -- and unknown is never "open"."""
    try:
        if height is None:
            height = int(node.getblockcount())
        return int(node.getblockheader(node.getblockhash(height))["mediantime"])
    except Exception:                                   # noqa: BLE001
        return None


def locktime_open(node, deadline, name, height=None):
    """Raise unless an absolute locktime has opened, IN ITS OWN UNITS.

    A deadline below `LOCKTIME_THRESHOLD` is a block height; at or above it a
    node reads it as a Unix TIME. Comparing a time to `getblockcount` says such
    a deadline opens about nine thousand years from now, so the exit it guards
    can never be taken -- and for a repurchase's FORFEIT, or a loan's DEFAULT,
    that exit is somebody's only way to get their money back. The node's
    median-time-past is what consensus compares a time locktime to, so it is
    what this compares to.
    """
    deadline = int(deadline)
    if height is None:
        height = int(node.getblockcount())
    if deadline < LOCKTIME_THRESHOLD:
        if height >= deadline:
            return
        refuse(f"{name} opens at block {deadline}; it is {height}")
    try:
        mtp = int(node.getblockheader(node.getblockhash(height))["mediantime"])
    except Exception as e:                              # noqa: BLE001
        refuse(
            f"{name} opens at Unix time {deadline} and this node would not "
            f"say what its median time is ({e}), so nothing here can tell "
            "whether it is open yet")
    if mtp >= deadline:
        return
    refuse(
        f"{name} opens at Unix time {deadline}; this chain's median time "
        f"is {mtp}. A locktime at or above {LOCKTIME_THRESHOLD} is a time, "
        "not a height, and consensus compares it to the median of the last "
        "eleven blocks -- which lags the wall clock by about an hour.")


def book_get(base, path):
    with urllib.request.urlopen(f"{base.rstrip('/')}{path}", timeout=15) as r:
        return json.loads(r.read().decode())


def book_delete(base, path, token):
    """A DELETE carrying a listing's manage token, in the HEADER.

    The token also travels as `?token=`, and a book redacts that from its log --
    but a query string is in the URL, so it reaches the proxy log, the shell
    history and anything in between. The header is where it belongs.
    """
    req = urllib.request.Request(f"{base.rstrip('/')}{path}", method="DELETE",
                                 headers={"X-Manage-Token": str(token)})
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            return r.status, json.loads(r.read().decode() or "{}")
    except urllib.error.HTTPError as e:
        try:
            return e.code, json.loads(e.read().decode() or "{}")
        except ValueError:
            return e.code, {"error": f"HTTP {e.code}"}
    except (urllib.error.URLError, OSError, ValueError) as e:
        return 0, {"error": f"{type(e).__name__}: {e}"}


def book_post(base, path, body):
    req = urllib.request.Request(
        f"{base.rstrip('/')}{path}", data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            return r.status, json.loads(r.read().decode())
    except urllib.error.HTTPError as e:
        try:
            return e.code, json.loads(e.read().decode() or "{}")
        except ValueError:
            return e.code, {"error": f"HTTP {e.code}"}
    except (urllib.error.URLError, OSError, ValueError) as e:
        # A relay that is down must not raise out of a responder pass. The
        # caller records what it did in its own state file first and reports
        # afterwards, so a failed report is a message to retry -- not a reason
        # to abandon the loan, and never a reason to stop the other passes.
        return 0, {"error": f"{type(e).__name__}: {e}"}


def add_book_arg(p):
    p.add_argument("--book", default=os.environ.get(
        "PIGNUS_BOOK", "http://127.0.0.1:8741"),
        help="the loan book (pignusd) to read markets and offers from")


def choose_fee(node, args, flow, prefer=(), committed=None):
    """(fee_asset, fee_atoms, rate) for this flow, from the node's own rates.

    The RATE comes back with the choice because the caller needs it for more
    than the fee: the threshold below which the node calls an output dust is
    per asset and rate-dependent, so change folded at a fixed number of atoms
    is either given away or refused by the relay.
    """
    table = F.fee_table(node)
    rates = table["rates"]
    # `is not None`, not truthiness. `--fee-amount 0` is a real request -- a
    # transaction whose fee is already paid by another input, which is how
    # `repo-settle --attach` works -- and a falsy test silently discarded it
    # and priced a fee instead, adding an output the caller did not ask for.
    if args.fee_amount is not None:
        if int(args.fee_amount) < 0:
            refuse("--fee-amount cannot be negative")
        asset = args.fee_asset or next(iter(prefer), None)
        if not asset:
            refuse("--fee-amount needs --fee-asset")
        asset = resolve_asset(node, asset)
        return asset, int(args.fee_amount), rates.get(asset)
    if args.fee_asset:
        asset = resolve_asset(node, args.fee_asset)
        rate = rates.get(asset)
        if not rate:
            refuse(f"the node publishes no fee rate for {args.fee_asset}")
        return asset, F.fee_atoms(rate, F.VSIZE[flow],
                                  table["feerate_rfa_per_kvb"]), rate
    # `committed` is what this transaction itself spends. A wallet funded with
    # exactly the debt has enough for the debt and enough for a fee in the same
    # asset, and not enough for both -- and without this the failure lands much
    # later as a coin-selection error naming neither.
    asset, fee = F.pick_fee(table, _holdings(node), flow, prefer,
                            committed=committed)
    return asset, fee, rates.get(asset)


def dust_fold_for(rate):
    """The change threshold for an asset at `rate`, or None when the node
    published no rate for it and the composer must fall back."""
    return F.dust_atoms(rate) if rate else None


_HEX = set("0123456789abcdefABCDEF")


def resolve_asset(node, label_or_hex):
    """An asset id, from a 64-hex id or from a label this node knows.

    HEX, not merely 64 characters. A 64-character label that is not hex used to
    be handed straight back as an asset id, and it then reaches a covenant
    builder or an RPC as an asset nothing has: the error that comes back names
    a byte length or a JSON field, never the argument that was wrong.
    """
    label_or_hex = str(label_or_hex)
    if len(label_or_hex) == 64 and all(c in _HEX for c in label_or_hex):
        return label_or_hex.lower()
    labels = node.dumpassetlabels()
    if label_or_hex in labels:
        return labels[label_or_hex]
    refuse(f"unknown asset {label_or_hex!r}: give a 64-hex id or a "
           "label the node knows")


def units_to_atoms(x, precision):
    """Whole units to atoms, through Decimal rather than a float multiplication.

    A loan is written in exact integers, and above about 90 million units a
    float cannot hold one: the principal a lender typed would arrive an atom or
    two out, and every number derived from it -- the collateral, the strike --
    with it."""
    return int(Decimal(str(x)).scaleb(int(precision)).to_integral_value())


def fmt_atoms(n, precision=8):
    return f"{int(n) / 10 ** int(precision):,.{int(precision)}f}".rstrip("0").rstrip(".")


def resolve_oracle_set(oracle_x, spec, threshold, book=None):
    """(oracle_x, oracles, threshold) from --oracle-x / --oracles.

    One resolver for `propose` and `offer-fund`, because the two forms compile
    to DIFFERENT vaults: one key given as a set of one is a 1-of-1 threshold
    tree, not the single-oracle tree, and the same intention must not land at a
    different address depending on which command wrote it. So a single key
    always becomes `oracle_x`.

    --oracles is a comma-separated list of x-only keys, or the literal `book`
    to use every oracle the book quotes against. Absent, a loan built against a
    book uses that book's primary oracle.
    """
    spec = (spec or "").strip()
    if oracle_x and spec:
        refuse("give --oracle-x or --oracles, not both: they compile "
               "to different vaults")
    if not spec:
        if oracle_x:
            return oracle_x, (), 0
        if book:
            return book_get(book, "/v1/oracle")["oracle_x"], (), 0
        refuse("give --oracle-x or --oracles")
    if spec.lower() == "book":
        if not book:
            refuse("--oracles book needs a book to ask (--book)")
        keys = book_get(book, "/v1/oracles")["oracles"]
    else:
        keys = [k.strip() for k in spec.split(",") if k.strip()]
    if not keys:
        refuse("--oracles resolved to no keys")
    if len(keys) == 1:
        return keys[0], (), 0
    if not threshold and spec.lower() == "book":
        refuse(f"--oracles book resolved to {len(keys)} oracles and no "
               f"--oracle-threshold was given. The default would be "
               f"{len(keys)}-of-{len(keys)}, which makes every liquidation of "
               f"this offer's loans depend on every one of those oracles "
               f"being up; say the threshold you mean (a majority, "
               f"{max(2, (len(keys) + 2) // 2)}, is the usual answer).")
    thr = threshold or len(keys)
    if not 1 <= thr <= len(keys):
        refuse(f"--oracle-threshold {thr} outside 1..{len(keys)}")
    if thr == 1:
        refuse("a 1-of-n oracle set is WEAKER than one oracle (any "
               "one key alone can liquidate); pick a threshold >= 2")
    if thr == len(keys):
        print(f"warning: {thr}-of-{thr} oracles: while any one of them is down "
              "this loan cannot be liquidated at all", file=sys.stderr)
    return "", keys, thr


def _resolve_oracles(args):
    return resolve_oracle_set(getattr(args, "oracle_x", "") or "", args.oracles,
                              args.oracle_threshold, book=args.book)


def _attestation_rows(args, market):
    """The attestations to choose evidence from, each naming its oracle if it
    knows it.

    A file or an oracle named on the command line comes first, so that a loan
    can be closed with the book down or with prices from an oracle the book
    does not carry. Whatever the source, the caller verifies the signature
    against the key the loan itself names.
    """
    path = getattr(args, "attestations", None)
    if path:
        with open(path) as f:
            d = json.load(f)
        return d["attestations"] if isinstance(d, dict) else d
    path = getattr(args, "attestation", None)
    if path:
        with open(path) as f:
            return [json.load(f)]
    url = getattr(args, "oracle", None)
    if url:
        return [book_get(url, f"/v1/attestation/{market}")]
    try:
        return book_get(args.book, f"/v1/attestations/{market}")["attestations"]
    except (OSError, KeyError, ValueError):
        d = book_get(args.book, f"/v1/attestation/{market}")
        return [d]


def _single_evidence(args, terms, market):
    """The attestation from the oracle THIS LOAN names, verified against it.

    A book serves several oracles; a loan baked to any but the primary one is
    still closeable, which is why the row is picked by key rather than taken
    from the market's headline price. Verified at THIS LOAN's price scale as
    well as against its key: the scale is not in the signed message, so a good
    signature at another scale is a good signature over a different number.
    """
    scales = set()
    for d in _attestation_rows(args, market):
        if d.get("oracle_x") not in (None, terms.oracle_x):
            continue
        att = O.Attestation.from_dict({k: v for k, v in d.items()
                                       if k != "oracle_x"})
        if O.verify(terms.oracle_x, att, terms.price_scale):
            return att
        if O.verify(terms.oracle_x, att):
            scales.add(int(att.price_scale))
    if scales:
        refuse(
            f"the oracle this loan names is quoting {terms.market} at price "
            f"scale {', '.join(str(s) for s in sorted(scales))} and this loan "
            f"computes at {terms.price_scale}; the covenant would misread that "
            "number, so nothing was built")
    refuse("no verifiable attestation from the oracle THIS LOAN "
           f"names ({terms.oracle_x[:16]}…); refusing to build it")


def _threshold_evidence(args, terms, market):
    """The {oracle_key: Attestation} mapping a threshold spend needs, verified
    against the keys THIS LOAN names and at the scale it computes with."""
    ev = {}
    for d in _attestation_rows(args, market):
        ox = d.get("oracle_x")
        if ox in terms.oracles:
            att = O.Attestation.from_dict({k: v for k, v in d.items()
                                           if k != "oracle_x"})
            if O.verify(ox, att, terms.price_scale):
                ev[ox] = att
    if not ev:
        refuse("none of this loan's oracles have a verifiable "
               "attestation at this loan's price scale right now")
    return ev


def _refuse_stale(args, evidence):
    """Refuse to seize on a price nobody has refreshed.

    The covenant cannot see a clock. It checks the signature and compares the
    integer, so an attestation signed a week ago liquidates exactly as well as
    one signed a second ago -- which makes this the only place recency is ever
    checked, on the way in.
    """
    if getattr(args, "allow_stale", False):
        return
    now = int(time.time())
    atts = evidence.values() if isinstance(evidence, dict) else [evidence]
    for att in atts:
        age = now - int(att.timestamp)
        if age > args.max_attestation_age:
            refuse(f"the attestation is {age}s old; pass --allow-stale to "
                   "build against it anyway")


def cmd_offer_fund(args):
    node = node_from(args)
    split_market(args.market)
    markets = book_get(args.book, "/v1/markets")
    mk = next((m for m in markets["markets"]
               if m["market"].upper() == args.market.upper()), None)
    if mk is None:
        refuse(f"the book has no market {args.market}")
    if not mk.get("lendable"):
        why = ("native BTC is cross-chain" if mk.get("cross_chain")
               else "the price is stale" if mk.get("stale")
               else "no asset id or no verified price")
        refuse(f"{mk['market']} is not lendable here: {why}")
    oracle_x, oracles, threshold = _resolve_oracles(args)
    # Terms are written in blocks, so a day is however many blocks this chain
    # makes in one; the book publishes its spacing rather than assuming 60s.
    blocks_per_day = 86400 // int(markets.get("block_seconds") or 60)
    height = node.getblockcount()
    price, scale = int(mk["price"]), int(mk["price_scale"])
    cp, dp = int(mk["collateral_precision"]), int(mk["debt_precision"])

    principal = units_to_atoms(args.principal, dp)
    debt = principal + int(round(principal * args.interest / 100))
    open_ltv, liq_ltv = args.open_ltv / 100, args.liq_ltv / 100
    if not 0 < open_ltv < liq_ltv <= 1:
        refuse("need 0 < --open-ltv < --liq-ltv <= 100")
    # collateral value must be principal / open_ltv, in debt atoms
    collateral = math.ceil(principal * scale / (price * open_ltv))
    strike = math.ceil(debt * scale / (collateral * liq_ltv))
    maturity = height + int(args.term_days * blocks_per_day)
    expiry = height + int((args.offer_days or args.term_days) * blocks_per_day)
    ver, prog, change_spk = wallet_payout(node)
    bver = 0 if args.borrower_ver is None else int(args.borrower_ver)
    placeholder = "00" * (20 if bver == 0 else 32)
    terms = LoanTerms(
        collateral_asset=mk["collateral_asset"], debt_asset=mk["debt_asset"],
        collateral_amount=collateral, principal=principal, debt=debt,
        lender_x=prog, lender_prog=prog, lender_ver=ver,
        borrower_x=placeholder, borrower_prog=placeholder, borrower_ver=bver,
        market=mk["market"], oracle_x=oracle_x or "",
        oracles=tuple(oracles), oracle_threshold=threshold, strike=strike,
        not_before=int(time.time()), maturity=maturity,
        recover_after=maturity + RECOVER_GAP_DAYS * blocks_per_day,
        bonus_num=100 + int(args.bonus), bonus_den=100, price_scale=scale,
        memo=args.memo or "")
    fee_asset, fee, rate = choose_fee(node, args, "fund",
                                      prefer=[terms.debt_asset],
                                      committed={terms.debt_asset:
                                                 int(principal) * int(args.lots)})
    spk = offer_address(terms, principal, collateral, expiry)
    # The strike is debt atoms per collateral atom; a person reads prices in
    # whole units, and the two differ by both assets' precisions.
    strike_unit = O.unquote_price(strike, cp, dp, scale)
    print(f"offer on {mk['market']}: lend {fmt_atoms(principal, dp)} "
          f"{mk['debt_ticker']} per loan x{args.lots}, repay "
          f"{fmt_atoms(debt, dp)}, against {fmt_atoms(collateral, cp)} "
          f"{mk['collateral_ticker']} (open LTV {args.open_ltv}%), liquidate "
          f"under {strike_unit:,.4f} {mk['debt_ticker']} per "
          f"{mk['collateral_ticker']} (LTV {args.liq_ltv}%), matures "
          f"{maturity}, offer expires {expiry}", file=sys.stderr)
    print(f"  strike {strike}  (debt atoms per collateral atom, scaled by "
          f"{scale})", file=sys.stderr)
    print(f"  offer address {spk.hex()}", file=sys.stderr)
    for w in terms.sanity_check():
        print(f"  ! {w}", file=sys.stderr)
    if args.dry_run:
        print("dry run: nothing broadcast. The terms follow; the funding "
              "transaction is not composed here, because it needs explicit "
              "coins only a real preparing send can produce.", file=sys.stderr)
        print(terms.to_json())
        return 0
    raw, _spk = fund_offer(node, terms, principal, collateral, expiry, args.lots,
                           fee_asset, fee, change_spk, fee_rate=rate,
                           prep_fee_asset=_prep_asset(node, args, fee_asset))
    # The listing goes to DISK BEFORE the broadcast. The terms are the only
    # thing that can ever spend this coin -- the offer address is their hash,
    # and nothing on chain carries them -- so a lost answer between the send
    # and the write leaves a lender's principal locked in a covenant whose
    # terms exist nowhere. The txid comes from the node's decoder rather than
    # from the send, which is a read and does not commit anything.
    txid = node.decoderawtransaction(raw)["txid"]
    body = {"terms": terms.to_json(), "kind": "funded", "outpoint": f"{txid}:0",
            "principal": str(principal), "collateral": str(collateral),
            "expiry_locktime": expiry}
    kept = _save_offer_body(txid, body)
    if not kept:
        refuse(
            "the offer's terms could not be written to disk, and they are the "
            "only thing that can ever spend the coin this is about to lock. "
            "Refusing to broadcast: fix the directory and run this again.")
    txid = node.sendrawtransaction(raw)
    print(f"  funded {txid}:0  (fee {fee} atoms of {fee_asset[:8]}…)",
          file=sys.stderr)
    print(f"  terms kept at {kept} before broadcasting, which is what can "
          f"spend it", file=sys.stderr)
    if args.publish:
        try:
            code, rec = book_post(args.book, "/v1/offers", body)
        except OSError as e:                       # the book is down
            code, rec = 0, {"error": str(e)}
        if code != 200:
            # The principal is already locked, so this is a warning and not a
            # failure: a caller that retries on a non-zero status would fund a
            # second offer for a listing that is all that went wrong.
            where = kept
            print(f"  the book did not list it ({rec.get('error')}); the "
                  f"principal is locked at {txid}:0. Publish it later with:\n"
                  f"    pignus-cli offer-publish --body {where}",
                  file=sys.stderr)
            print(json.dumps(body))
            return 0
        print(f"  published as offer {rec['offer_id']}", file=sys.stderr)
        _say_manage_token(rec)
        print(json.dumps(rec, indent=2))
    else:
        print("  not published. Save the record below and list it whenever you "
              "like with:\n    pignus-cli offer-publish --body <file>",
              file=sys.stderr)
        print(json.dumps(body))
    return 0


def _save_offer_body(txid, body):
    """Keep a funded offer's listing beside the operator, so a book that was
    down does not leave the principal locked and invisible. Returns the path,
    or None if it could not be written."""
    path = f"offer-{txid}.json"
    try:
        with open(path, "w") as f:
            json.dump(body, f, indent=2)
    except OSError as e:
        print(f"  (could not save {path}: {e})", file=sys.stderr)
        return None
    return path


def cmd_loan_export(args):
    """Write a loan's terms and vault outpoint in the form `verify`, `status`
    and the exits all read, so the loan can be checked and closed with the book
    gone."""
    loan = book_get(args.book, f"/v1/loan/{args.loan}")
    doc = {"terms": json.loads(loan["terms"]), "txid": loan["txid"],
           "vout": int(loan["vout"]),
           "single_leaf": bool(loan.get("single_leaf"))}
    out = json.dumps(doc, indent=2)
    if args.out:
        with open(args.out, "w") as f:
            f.write(out + "\n")
        print(f"wrote {args.out}", file=sys.stderr)
    else:
        print(out)
    return 0


def _spending_tx(node, v):
    """The transaction that spent a vault, read WITHOUT a transaction index.

    `getrawtransaction <txid>` answers only for transactions the node's own
    wallet authored unless it is running with `-txindex`, which the committee
    nodes are not -- and the borrower reading their own liquidation is the one
    party who did not author it. A mined spend is therefore read out of the
    block that carries it, and only a mempool spend is asked for by id, which a
    node answers for anything it is holding.
    """
    if v.spent_height:
        blk = node.getblock(node.getblockhash(int(v.spent_height)), 2)
        for tx in blk.get("tx", []):
            if tx.get("txid") == v.spent_by:
                return tx
        refuse(f"the block at height {v.spent_height} does not carry "
               f"{v.spent_by}; the chain has moved under this loan, "
               "so run this again once it has settled")
    if v.spent_by in (node.getrawmempool() or []):
        return node.getrawtransaction(v.spent_by, True)
    refuse("the spending transaction is in no block this node has "
           "and in no mempool it holds; there is nothing to read")


def _book_loan_key(terms, txid, vout):
    """The id a book files a loan under, computed the way a book computes it.

    A loan is a vault COIN, not a terms document -- the same borrower taking
    the same offer twice opens two loans at one address -- so the id is the
    terms id hashed with the funding outpoint. A borrower's own account of a
    close is only worth anything if it lines up against the book's, so it uses
    the book's own function rather than a copy of it.
    """
    from pignus.book import Book                         # noqa: PLC0415
    return Book.loan_key(terms, txid, int(vout or 0))


def cmd_explain(args):
    """How a loan ended: the exit taken, the price behind it, where the money
    went.

    A liquidated borrower is owed the evidence rather than a state tag, and all
    of it is in the closing transaction: the leaf the spender had to reveal
    names the exit, and a seizure's witness carries the oracle's own price,
    timestamp and signature.

    WHERE the checking happens depends on which way it is asked. With `--terms`
    this reads the chain through a node and verifies every attestation here,
    against the key baked into the vault address itself. With `--loan` it asks
    the book for its own account, and the book did that checking -- the answer
    is the book's, not this machine's. Both print the same fields; only one of
    them is independent of the book, and a borrower disputing a liquidation
    wants that one.
    """
    if args.loan:
        # Say whose account this is. `--terms` verifies locally; this does not,
        # and a reader deciding whether to believe a liquidation needs to know
        # which of the two they asked for.
        print(f"reading {args.book}'s own account of this loan. It did the "
              "checking, not this machine -- re-run with --terms and --rpc to "
              "verify every signature here.", file=sys.stderr)
        try:
            out = book_get(args.book, f"/v1/loan/{args.loan}/exit")
        except urllib.error.HTTPError as e:
            # The book's own sentence -- "that loan is still open", "no node
            # configured" -- says more than the status code it came with.
            try:
                said = json.loads(e.read().decode() or "{}").get("error")
            except ValueError:
                said = None
            refuse(f"the book: {said or e.reason}")
    else:
        node = node_from(args)
        t, doc = terms_doc(args.terms)
        txid = args.txid or doc.get("txid")
        vout = int(args.vout if args.vout is not None else (doc.get("vout") or 0))
        if not txid:
            refuse("give --txid: the terms file carries no funding "
                   "outpoint")
        single = bool(args.single_leaf or doc.get("single_leaf"))
        w = VaultWatcher(node)
        # The id a BOOK knows this loan by, computed the same way a book does:
        # the terms id hashed with the funding outpoint, because one terms
        # document can be funded more than once. Labelling this account with
        # anything else leaves a borrower unable to line it up against the
        # book's, which is the one thing this branch exists for.
        v = w.track(_book_loan_key(t, txid, vout), t, txid, vout,
                    single_leaf=single)
        w.poll()
        if not v.spent_by:
            refuse(f"{txid}:{vout} has not been spent, so this loan "
                   f"has no ending yet (it is {v.state.value})")
        tx = _spending_tx(node, v)
        k = next((i for i, vin in enumerate(tx.get("vin", []))
                  if vin.get("txid") == v.txid
                  and int(vin.get("vout", -1)) == v.vout), -1)
        if k < 0:
            refuse(f"{v.spent_by} does not spend {txid}:{vout}; this "
                   "is not the transaction that closed this loan")
        out = w.explain_exit(v, tx, k)
        # The same spelling the book uses, because comparing the two accounts
        # is the whole reason this branch exists: a diff between them should
        # show a disagreement about a number, never about its type.
        for key in ("seize_paid", "seize_expected", "surplus_paid",
                    "surplus_expected", "lender_paid", "debt", "collateral"):
            if isinstance(out.get(key), int) and not isinstance(out[key], bool):
                out[key] = str(out[key])
    print(json.dumps(out, indent=2))
    said = f"{out.get('exit')}"
    if out.get("price_used") is not None:
        said += f" at an attested price of {out['price_used']} (strike {out.get('strike')})"
    print(said, file=sys.stderr)
    for p in out.get("problems") or []:
        print(f"  ! {p}", file=sys.stderr)
    return 0


def _say_manage_token(rec):
    """Say what the token in this record is, wherever a listing is published.

    Served ONCE, and stored by the book only as a hash: nothing will ever show
    it again, and it is the only thing that takes the listing down before its
    expiry -- which is what a lender wants the moment their strike stops making
    sense. Printed into a JSON blob with no sentence beside it, it reads as one
    more field.
    """
    if not (isinstance(rec, dict) and rec.get("manage_token")):
        return
    print(f"KEEP the manage_token in the record below. It is served once and "
          f"stored only as a hash, so nothing can show it again, and it is "
          f"what `pignus-cli offer-delist --offer {rec.get('offer_id', '<id>')} "
          f"--token …` needs to take this listing down before it expires.",
          file=sys.stderr)


def cmd_offer_publish(args):
    """List an already-funded offer. Nothing is funded here, so a failure is
    safe to retry."""
    with open(args.body) as f:
        body = json.load(f)
    if body.get("kind") != "funded" or not body.get("outpoint"):
        refuse(f"{args.body} is not the record offer-fund prints: it "
               "needs kind 'funded' and the offer's outpoint")
    code, rec = book_post(args.book, "/v1/offers", body)
    if code != 200:
        print(f"the book refused the offer: {rec.get('error')}", file=sys.stderr)
        return 2
    print(f"published as offer {rec['offer_id']}", file=sys.stderr)
    _say_manage_token(rec)
    print(json.dumps(rec, indent=2))
    return 0


def _load_offer(args, node):
    off = book_get(args.book, f"/v1/offer/{args.offer}")
    terms = LoanTerms.from_json(off["terms"])
    txid, _, vout = off["outpoint"].rpartition(":")
    principal, collateral = int(off["principal"]), int(off["collateral"])
    expiry = int(off["expiry_locktime"])
    got = node.gettxout(txid, int(vout), True)
    if got is None:
        refuse("that offer's coin is gone: it was taken in full or "
               "withdrawn")
    want = offer_address(terms, principal, collateral, expiry).hex()
    have = got["scriptPubKey"]["hex"]
    if want != have:
        refuse("the offer's coin is not what its terms compile to\n"
               f"  terms compile to: {want}\n"
               f"  the coin pays:    {have}")
    if "value" not in got or "asset" not in got:
        refuse("that offer's coin is blinded; an offer must hold an explicit "
               "amount, because the covenant compares what it pays out against "
               "what the input holds")
    if got["asset"] != terms.debt_asset:
        refuse(f"the offer's coin holds {got['asset'][:12]}..., not the debt "
               f"asset {terms.debt_asset[:12]}... it lends")
    value = atoms(got["value"])
    return off, terms, Outpoint(txid, int(vout), value, terms.debt_asset), \
        principal, collateral, expiry


def cmd_offer_take(args):
    node = node_from(args)
    off, base, coin, principal, collateral, expiry = _load_offer(args, node)
    ver, prog, spk = wallet_payout(node)
    if ver != base.borrower_ver:
        refuse(f"this offer is for witness-v{base.borrower_ver} "
               f"borrowers and this wallet pays out at v{ver}")
    # WHEN, as well as what. The offer's TAKE leaf carries no locktime -- only
    # its REFUND does -- so a funded offer stays takeable for as long as the
    # lender leaves the coin there, which is the whole point of an offer a
    # lender can walk away from. But the LOAN it creates carries the maturity
    # and the sweep the lender wrote when they published it, and taking one
    # after those have passed is borrowing into a loan that can be called at
    # any price the moment it exists. The page refuses this; the command line
    # did not.
    height = int(node.getblockcount())
    if height >= int(base.maturity) and not args.force:
        refuse(
            f"this offer's loans mature at block {base.maturity} and the chain "
            f"is at {height}. A loan taken now is one anybody may call at any "
            f"price immediately, whatever the collateral is worth. Refusing; "
            f"--force to take it anyway.")
    if height >= int(base.recover_after) and not args.force:
        refuse(
            f"this offer's loans let the lender sweep the collateral without "
            f"an oracle from block {base.recover_after}, and the chain is at "
            f"{height}. There is no loan left in these terms. Refusing.")
    if expiry and height >= int(expiry) and not args.force:
        refuse(
            f"this offer expired at block {expiry} and the chain is at "
            f"{height}: the lender can take their coin back at any moment, and "
            f"a take that races that loses the fee. Refusing; --force to try.")
    # Through the READER, not the constructor. `from_json` turns the oracle
    # list back into a tuple and coerces the numbers; the constructor leaves a
    # list, which makes the terms unhashable and is a different value from the
    # one every other party built. The address happens to come out the same
    # today, and relying on that is how the two drift apart.
    terms = LoanTerms.from_json(json.dumps(
        {**json.loads(base.to_json()),
         "borrower_x": prog, "borrower_prog": prog}))
    vault = offer_vault_address(terms)
    print(f"taking offer {off['offer_id'][:12]}: borrow "
          f"{fmt_atoms(principal, off['debt_precision'])} {off['debt_ticker']}, "
          f"lock {fmt_atoms(collateral, off['collateral_precision'])} "
          f"{off['collateral_ticker']}, repay "
          f"{fmt_atoms(terms.debt, off['debt_precision'])} by block "
          f"{terms.maturity}", file=sys.stderr)
    print(f"  vault {vault.hex()}", file=sys.stderr)
    # What the PAGE tells a borrower before they commit, said here too. This
    # command locks collateral against terms the borrower did not write, and it
    # showed none of it: not the oracle whose signature can liquidate them, not
    # the bonus a liquidator keeps out of that collateral, and none of the
    # warnings `sanity_check` exists to raise. A command line is not a reason
    # to be told less.
    keys = [terms.oracle_x] + list(terms.oracles or [])
    print(f"  liquidation oracle{'' if len(keys) == 1 else 's'}: "
          + ", ".join(k[:16] + "…" for k in keys if k)
          + (f" ({terms.threshold}-of-{len(terms.oracles)})"
             if terms.oracles else "")
          + " -- whoever holds "
          + ("that key" if len(keys) == 1 else "those keys")
          + " can attest a price and liquidate this loan", file=sys.stderr)
    bonus_pct = (terms.bonus_num * 100 // terms.bonus_den) - 100
    print(f"  a liquidator keeps {bonus_pct}% of the debt out of YOUR "
          f"collateral as their bonus", file=sys.stderr)
    for w in terms.sanity_check():
        print(f"  ! {w}", file=sys.stderr)
    fee_asset, fee, rate = choose_fee(
        node, args, "take", prefer=[terms.collateral_asset, terms.debt_asset],
        committed={terms.collateral_asset: int(terms.collateral_amount)})
    if args.dry_run:
        print("dry run: nothing broadcast. The terms follow; the take is not "
              "composed here, because it needs explicit coins only a real "
              "preparing send can produce.", file=sys.stderr)
        print(terms.to_json())
        return 0
    raw, vault_spk = take_offer(node, terms, coin, coin.amount, principal,
                                collateral, expiry, fee_asset, fee, spk, spk,
                                fee_rate=rate,
                                prep_fee_asset=_prep_asset(node, args, fee_asset))
    if vault_spk != vault:
        # The vault printed above is the address the borrower was shown; a
        # composer that built a different one is not something to broadcast.
        refuse("the composed vault is not the address these terms compile to")
    txid = node.sendrawtransaction(raw)
    print(f"  broadcast {txid}; the vault is {txid}:0", file=sys.stderr)
    code, rec = book_post(args.book, "/v1/loans", {
        "terms": terms.to_json(), "txid": txid, "vout": 0, "single_leaf": True,
        "offer_id": off["offer_id"]})
    if code != 200:
        print(f"  book did not register it ({rec.get('error')}); it will "
              "discover the vault from the chain anyway", file=sys.stderr)
    print(json.dumps({"txid": txid, "vout": 0, "terms": json.loads(terms.to_json()),
                      "single_leaf": True,
                      "loan_id": rec.get("loan_id") if code == 200 else None},
                     indent=2))
    return 0


def cmd_offer_delist(args):
    """LENDER: take a listing down. The COIN is untouched.

    A listing and an offer are different things. The offer is the covenant coin,
    and it comes back to the lender at its expiry with `offer-withdraw`. The
    listing is a book's advertisement of that coin, and this takes the
    advertisement down: the terms stop being shown, nobody new takes them, and
    every loan already drawn from the coin is untouched. It is what a lender
    reaches for when a strike stops making sense an hour after publishing.

    The manage token is served once, when the listing is published, and the
    book stores only its hash: without it nothing here can prove the listing is
    yours, and there is no recovery.
    """
    code, r = book_delete(args.book, f"/v1/offers/{args.offer}", args.token)
    if code == 403:
        refuse(
            "that manage token is not this listing's. It is served once, when "
            "the listing is published, and only its hash is stored -- so a "
            "token that is gone cannot be recovered. The listing expires on "
            "its own at the offer's expiry, and the COIN is yours throughout: "
            "`offer-withdraw` brings it back once that expiry opens.")
    if code == 404:
        refuse(f"this book has no listing {args.offer}")
    if code != 200:
        refuse(f"the book refused: {r.get('error') or code}")
    print(json.dumps({"offer_id": args.offer, "delisted": True,
                      "note": "the listing is off the board; the book keeps "
                              "the record, since it is the only copy of the "
                              "terms the withdraw is built from. The coin is "
                              "untouched, and offer-withdraw brings it back "
                              "once the expiry opens"},
                     indent=2))
    return 0


def cmd_offer_withdraw(args):
    node = node_from(args)
    off, terms, coin, principal, collateral, expiry = _load_offer(args, node)
    height = node.getblockcount()
    if height < expiry:
        refuse(f"the offer expires at block {expiry}; it is {height}")
    _ver, _prog, spk = wallet_payout(node)
    fee_asset, fee, rate = choose_fee(node, args, "withdraw")
    # `withdraw_offer` selects its own funding, and selecting can broadcast a
    # preparing send, so a dry run checks first that the explicit coins are
    # already there and composes nothing when they are not.
    if args.dry_run and _funding_for(args, node, {fee_asset: fee},
                                     exclude=[(coin.txid, coin.vout)],
                                     fee_asset=fee_asset) is None:
        return 0
    raw = withdraw_offer(node, terms, coin, coin.amount, principal, collateral,
                         expiry, fee_asset, fee, spk, fee_rate=rate,
                         prep_fee_asset=_prep_asset(node, args, fee_asset))
    if args.dry_run:
        print(raw)
        return 0
    txid = node.sendrawtransaction(raw)
    print(f"withdrawn: {fmt_atoms(coin.amount, off['debt_precision'])} "
          f"{off['debt_ticker']} back to the lender in {txid}", file=sys.stderr)
    print(json.dumps({"txid": txid}))
    return 0


def _load_loan(args, node):
    """The loan to act on, from the book or from a terms file and an outpoint.

    A loan can always be closed from its own terms: the book is a way to find
    one, never a party to it, so a book that is down or that never listed a
    loan must not be able to stop its borrower repaying.
    """
    if args.terms:
        terms, doc = terms_doc(args.terms)
        txid = args.txid or doc.get("txid")
        vout = int(args.vout if args.vout is not None else (doc.get("vout") or 0))
        if not txid:
            refuse("give --txid: the terms file carries no funding "
                   "outpoint")
        loan = {"terms": terms.to_json(), "txid": txid, "vout": vout}
    else:
        loan = book_get(args.book, f"/v1/loan/{args.loan}")
        terms = LoanTerms.from_json(loan["terms"])
        txid, vout = loan["txid"], int(loan["vout"])
    got = node.gettxout(txid, vout, True)
    if got is None:
        refuse("that vault is already spent")
    try:
        single = match_vault(terms, got["scriptPubKey"]["hex"]) == "single-leaf"
    except ValueError as e:
        refuse(str(e))
    if "value" not in got or "asset" not in got:
        refuse("that vault coin is blinded, and the covenant cannot read a "
               "blinded amount; nothing can be composed against it")
    if got["asset"] != terms.collateral_asset:
        refuse(f"the vault holds {got['asset'][:12]}..., not the collateral "
               f"asset {terms.collateral_asset[:12]}... these terms name")
    # The amount comes from the CHAIN, not from the terms: every leaf reads the
    # input's own value, so an over-funded vault is exited by paying out what it
    # actually holds.
    coin = Outpoint(txid, vout, atoms(got["value"]), terms.collateral_asset)
    return loan, terms, coin, single


def _exit(args, exit_name):
    node = node_from(args)
    loan, terms, coin, single = _load_loan(args, node)
    height = node.getblockcount()
    # DEFAULT and RECOVER carry a locktime, so building one early only wastes a
    # preparing send and dies on the node's `non-final`; say so first instead.
    if exit_name == "default":
        locktime_open(node, terms.maturity, "DEFAULT", height)
    if exit_name == "recover":
        locktime_open(node, terms.recover_after, "RECOVER", height)
    _ver, _prog, spk = wallet_payout(node)
    flow = exit_name if single else exit_name + "4"
    # A REPAY moves the debt; the other exits move the collateral coin, which
    # is the vault's own input rather than something from this wallet.
    # Every exit but RECOVER pays the lender `debt` out of THIS wallet: REPAY
    # because it is the debt, LIQUIDATE and DEFAULT because the seizure tail
    # credits the lender first and the taker keeps collateral instead. A fee
    # asset picked without subtracting that is one the wallet cannot then
    # fund, and the failure names no asset.
    fee_asset, fee, rate = choose_fee(
        node, args, flow, prefer=[terms.debt_asset],
        committed=({terms.debt_asset: int(terms.debt)}
                   if exit_name != "recover" else None))
    spender = VaultSpender(node, terms, fee_asset, fee, single_leaf=single,
                           dust_fold=dust_fold_for(rate))
    need = {terms.debt_asset: terms.debt} if exit_name != "recover" else {}
    need[fee_asset] = need.get(fee_asset, 0) + fee
    funding = _funding_for(args, node, need, exclude=[(coin.txid, coin.vout)],
                           fee_asset=fee_asset)
    if funding is None:
        return 0
    if exit_name == "repay":
        raw = spender.repay(coin, funding, spk)
    elif exit_name == "recover":
        raw = spender.recover(coin, funding, spk)
    else:
        market = terms.market.replace("/", "_")
        if terms.oracles:
            evidence = _threshold_evidence(args, terms, market)
        else:
            evidence = _single_evidence(args, terms, market)
        _refuse_stale(args, evidence)
        if exit_name == "liquidate":
            raw = spender.liquidate(coin, funding, evidence, spk)
        else:
            raw = spender.call_default(coin, funding, evidence, spk)
    if args.dry_run:
        print(raw)
        return 0
    txid = node.sendrawtransaction(raw)
    print(f"{exit_name}: {txid}  (fee {fee} atoms of {fee_asset[:8]}…)",
          file=sys.stderr)
    print(json.dumps({"txid": txid, "exit": exit_name}))
    return 0


def cmd_repay(args):
    return _exit(args, "repay")


def cmd_liquidate(args):
    return _exit(args, "liquidate")


def cmd_default(args):
    return _exit(args, "default")


def cmd_recover(args):
    return _exit(args, "recover")


def add_wallet_commands(sub):
    def common(p):
        add_book_arg(p)
        add_node_args(p)
        add_fee_args(p)
        p.add_argument("--dry-run", action="store_true",
                       help="compose and print, broadcast nothing -- no "
                            "preparing sends either")

    p = sub.add_parser("offer-fund", help="lock principal in a funded offer, "
                       "and publish it")
    p.add_argument("--market", required=True, help="e.g. GOLD/USDX")
    p.add_argument("--principal", type=float, required=True,
                   help="lent per loan, in units of the debt asset")
    p.add_argument("--interest", type=float, default=3.0,
                   help="%% over the whole term (default 3)")
    p.add_argument("--open-ltv", type=float, default=50.0,
                   help="collateral required, as loan-to-value %% (default 50)")
    p.add_argument("--liq-ltv", type=float, default=75.0,
                   help="liquidate when loan-to-value crosses this %% (default 75)")
    p.add_argument("--term-days", type=float, default=30.0)
    p.add_argument("--offer-days", type=float,
                   help="how long the offer stays open (default: the term)")
    p.add_argument("--lots", type=int, default=1, help="how many loans")
    p.add_argument("--bonus", type=float, default=5.0,
                   help="liquidation bonus %% (default 5)")
    p.add_argument("--borrower-ver", type=int, default=0,
                   help="witness version borrowers pay out at: 0 for "
                   "extension/bech32 wallets (default), 1 for taproot")
    p.add_argument("--oracles", default="",
                   help="comma-separated oracle x-only keys, or 'book' to use "
                   "every oracle the book quotes against, for an m-of-n loan")
    p.add_argument("--oracle-threshold", type=int, default=0,
                   help="m in an m-of-n oracle set (default: all of them, "
                        "which no single oracle outage can then liquidate under)")
    p.add_argument("--memo", default="")
    p.add_argument("--no-publish", dest="publish", action="store_false",
                   help="fund the offer without listing it on the book")
    common(p)
    p.set_defaults(fn=cmd_offer_fund)

    p = sub.add_parser("offer-publish",
                       help="list an already-funded offer on the book")
    p.add_argument("--body", required=True,
                   help="the record offer-fund printed, saved to a file")
    add_book_arg(p)
    p.set_defaults(fn=cmd_offer_publish)

    p = sub.add_parser("loan-export", help="write a loan's terms and vault "
                       "outpoint to a file the other commands read")
    p.add_argument("--loan", required=True, help="loan id from the book")
    p.add_argument("--out", help="file to write (default: stdout)")
    add_book_arg(p)
    p.set_defaults(fn=cmd_loan_export)

    p = sub.add_parser("offer-take", help="borrow from a funded offer")
    p.add_argument("--offer", required=True, help="offer id from the book")
    p.add_argument("--force", action="store_true",
                   help="take an offer whose loans have already matured, whose "
                        "sweep has already opened, or that has expired. Each of "
                        "those is a loan somebody else can end the moment it "
                        "exists")
    common(p)
    p.set_defaults(fn=cmd_offer_take)

    p = sub.add_parser("offer-delist",
                       help="LENDER: take a listing off a book early. The coin "
                            "is untouched")
    p.add_argument("--offer", required=True)
    p.add_argument("--token", required=True,
                   help="the manage token this listing was published with, "
                        "served once and never again")
    add_book_arg(p)
    p.set_defaults(fn=cmd_offer_delist)

    p = sub.add_parser("offer-withdraw", help="return an expired offer's "
                       "principal to its lender")
    p.add_argument("--offer", required=True, help="offer id from the book")
    common(p)
    p.set_defaults(fn=cmd_offer_withdraw)

    for name, fn, help_ in (
            ("repay", cmd_repay, "pay the debt and take the collateral back"),
            ("liquidate", cmd_liquidate, "close an under-water loan for the bonus"),
            ("default", cmd_default, "seize a loan past maturity, at any price"),
            ("recover", cmd_recover, "sweep a long-overdue vault to the lender")):
        p = sub.add_parser(name, help=help_)
        g = p.add_mutually_exclusive_group(required=True)
        g.add_argument("--loan", help="loan id from the book")
        g.add_argument("--terms", help="the loan's terms file, for closing a "
                       "loan the book never listed or cannot serve; needs --txid")
        p.add_argument("--txid", help="the vault's funding transaction, with "
                       "--terms")
        p.add_argument("--vout", type=int, default=None,
                       help="the vault's output index (default 0)")
        if name in ("liquidate", "default"):
            p.add_argument("--attestation", help="a signed price from the "
                           "oracle this loan names, as a file, instead of the "
                           "book's")
            p.add_argument("--attestations", help="a file of signed prices, "
                           "each naming its oracle, for an m-of-n loan")
            p.add_argument("--oracle", help="a pignus-oracle to ask directly "
                           "for the price, instead of the book")
            p.add_argument("--max-attestation-age", type=int, default=600,
                           metavar="S",
                           help="refuse a price signed longer ago than this "
                                "(default 600 s); the covenant cannot check "
                                "recency, so this is the only place it is")
            p.add_argument("--allow-stale", action="store_true",
                           help="build against a price older than that anyway")
        common(p)
        p.set_defaults(fn=fn)

    p = sub.add_parser("explain", help="how a loan ended: the exit taken, the "
                       "price behind it, and where the money went")
    g = p.add_mutually_exclusive_group(required=True)
    g.add_argument("--loan", help="loan id from the book")
    g.add_argument("--terms", help="the loan's terms file, to rebuild the "
                   "account from a node with no book involved; needs --txid")
    p.add_argument("--txid", help="the vault's funding transaction, with --terms")
    p.add_argument("--vout", type=int, default=None,
                   help="the vault's output index (default 0)")
    p.add_argument("--single-leaf", action="store_true",
                   help="the vault came from a funded offer; needed once it is "
                        "spent, since the exit is read from the leaf revealed")
    add_book_arg(p)
    add_node_args(p)
    p.set_defaults(fn=cmd_explain)


COIN = 100_000_000

# --- Tier B: native BTC collateral, the cross-chain loan ----------------------
#
# A two-party protocol: the loan lives half on Bitcoin (the collateral) and half
# on Sequentia (the debt), bound by ONE HASH appearing in both chains' scripts.
# It is deliberately not an adaptor signature: nothing can check that a point
# and a hash came from one secret, so a lender could publish an unrelated hash
# and take the repayment and the collateral both. Each party holds a
# keyfile; a `ticket` JSON carries the PUBLIC state between them (never a secret,
# never `t`). The commands below are each one party's move. The oracle's only
# move is to sign a seize sighash, which `btc-seize-sighash` prints for it.


def btc_node_from(args):
    from pignus.node import Node
    return Node(url=args.btc_rpc, user=args.btc_rpc_user,
                password=args.btc_rpc_password, wallet=args.btc_rpc_wallet,
                cookie_path=args.btc_rpc_cookie)


def add_btc_node_args(p):
    p.add_argument("--btc-rpc", default="http://127.0.0.1:8332")
    p.add_argument("--btc-rpc-user")
    p.add_argument("--btc-rpc-password")
    p.add_argument("--btc-rpc-wallet")
    p.add_argument("--btc-rpc-cookie")


def _read_key(path):
    """A lender's or borrower's key, checked the way the oracle's is.

    This key is the whole of its holder's authority in a loan, so it gets the
    same two checks: that no other user can read the file, and that what is in
    it is a key at all. A truncated or empty file otherwise fails much later,
    as a release the borrower's covenant will not accept.
    """
    try:
        return O.read_key(path, "that key")
    except OSError as e:
        refuse(f"cannot read the key file {path}: {e.strerror}")
    except ValueError as e:
        refuse(str(e))


def _write_key(path, sec):
    import os as _os
    fd = _os.open(path, _os.O_WRONLY | _os.O_CREAT | _os.O_TRUNC, 0o600)
    with _os.fdopen(fd, "w") as f:
        f.write(sec.hex() + "\n")


def _ticket(args):
    with open(args.ticket) as f:
        return json.load(f)


def _save_ticket(args, tk):
    dst = getattr(args, "out", None) or args.ticket
    with open(dst, "w") as f:
        json.dump(tk, f, indent=2, sort_keys=True)
    print(f"wrote {dst}", file=sys.stderr)


def _loan(tk):
    from pignus.btc_collateral import loan_from_dict
    return loan_from_dict(tk)


def cmd_btc_keygen(args):
    from pignus import adaptor as A
    sec = A.new_secret()
    _write_key(args.out, sec)
    print(json.dumps({"pubkey_x": A.xonly_pubkey(sec).hex(),
                      "keyfile": args.out}, indent=2))
    return 0


# What a responder uses when neither a flag nor a config file says otherwise.
# They live here rather than in `add_argument(default=...)` because a truthy
# argparse default is indistinguishable from a value the operator chose -- and
# the merge below would then let it outrank the config file, silently.
RESPONDER_DEFAULTS = {
    "book": os.environ.get("PIGNUS_BOOK", "http://127.0.0.1:8741"),
    "interval": 5.0,
    # Two, not one: two Bitcoin blocks is the shortest depth that survives an
    # ordinary one-block reorg, which is the same reasoning MIN_ANCHOR_DEPTH
    # applies to every other cross-chain step. A principal paid against one
    # confirmation is paid against a funding the borrower can replace the
    # moment that block is orphaned.
    "disburse_conf": 2,
    "claim_depth": 6,
    "scan_interval": 300.0,
}


def _btc_cfg(args):
    """Load a responder configuration file, if one was given.

    Credentials belong in a file, not on a command line: every user on the
    machine can read `ps`. A flag still wins over the file, the file wins over
    the built-in default, and the default applies only where neither said
    anything -- which is why the flags this touches parse as None.
    """
    path = getattr(args, "config", None)
    cfg = {}
    # A flag wins, then the environment, then the file, then the default.
    if getattr(args, "book", None) is None and os.environ.get("PIGNUS_BOOK"):
        args.book = os.environ["PIGNUS_BOOK"]
    if path:
        with open(path) as f:
            cfg = json.load(f)
        for key, dest in (("book", "book"), ("lender_key", "lender_key"),
                          ("interval", "interval"), ("fee_asset", "fee_asset"),
                          ("disburse_conf", "disburse_conf"),
                          ("claim_depth", "claim_depth"), ("state", "state"),
                          ("scan_interval", "scan_interval")):
            if cfg.get(key) not in (None, "") \
                    and getattr(args, dest, None) is None:
                setattr(args, dest, cfg[key])
    for dest, value in RESPONDER_DEFAULTS.items():
        if getattr(args, dest, None) is None and hasattr(args, dest):
            setattr(args, dest, value)
    conf = getattr(args, "disburse_conf", None)
    if conf is not None and int(conf) < MIN_ANCHOR_DEPTH:
        refuse(f"disburse_conf is {conf}; the floor is {MIN_ANCHOR_DEPTH}. A "
               f"principal paid against fewer Bitcoin confirmations than that "
               f"is paid against a funding the borrower can replace the "
               f"moment one block is orphaned.")
    for prefix, block in (("rpc", cfg.get("rpc") or {}),
                          ("btc_rpc", cfg.get("btc_rpc") or {})):
        for field in ("url", "user", "password", "cookie", "wallet"):
            if not block.get(field):
                continue
            dest = f"{prefix}" if field == "url" else f"{prefix}_{field}"
            dest = dest.replace("rpc_cookie", "rpc_cookie")
            if not getattr(args, dest, None) or field == "url":
                setattr(args, dest, block[field])
    return cfg


class _ResponderState:
    """What the responder has already done, on disk, before it does it again.

    A principal paid twice is a principal given away, and the gap between
    sending a payment and recording that it was sent is exactly where a crash
    lands. So the intent is written BEFORE the send and the result after, and a
    take whose intent was written but never completed is never retried blind:
    it is reported, for a person to resolve.
    """

    def __init__(self, path, exclusive=False):
        self.path = path
        self.data = {}
        self._lockf = None
        if path and exclusive:
            self._must_be_writable()
            self._take_lock()
        if path and os.path.exists(path):
            try:
                with open(path) as f:
                    self.data = json.load(f)
            except OSError as e:
                refuse(
                    f"the responder's state file {path} exists and cannot be "
                    f"read ({e}). It is what stops a principal being paid "
                    f"twice; refusing to start rather than begin from an "
                    f"empty one.")
            except ValueError as e:
                # Mirroring `Book._load`, and for the same reason: an empty
                # state file is a responder that will pay every principal it
                # has already paid. A corrupt one is a thing to restore from
                # backup, never a thing to overwrite.
                refuse(
                    f"the responder's state file {path} is not valid JSON "
                    f"({e}); refusing to start rather than begin from an empty "
                    f"one and pay every principal again. Restore it from a "
                    f"backup, or move it aside on purpose if this key really "
                    f"has no loans in flight.")
        # Only the holder of the exclusive lock -- the responder itself --
        # writes here. `btc-responder-status` opens this file without the lock
        # and is documented as safe against a running responder, and a second
        # writer could land its whole snapshot on top of a disbursement the
        # responder had just recorded. Losing exactly that field is what this
        # file exists to prevent, and no report is worth the risk of it.
        if exclusive:
            self._date_old_waits()
            # Written now, empty, rather than at the first take. A status
            # command or a timer that reads this file before any take
            # arrives found nothing there and reported the responder as
            # unreadable, every five minutes, on every fresh install.
            if path and not os.path.exists(path):
                self._save()

    def _date_old_waits(self):
        """Give a wait that predates the stamp a date, once.

        A wait's clock starts when its REASON changes, which is right: it is
        the reason's age that matters, not the last poll's. But a state file
        written before waits were dated carries reasons with no date, and the
        reason a take is stuck on is exactly the reason that never changes --
        so the takes this was built to report would have gone on reporting
        "unknown" for ever, which is the answer they gave before.

        Stamped on the first load that sees one, and SAVED, so a restart does
        not reset it. The clock therefore starts late rather than at the true
        beginning: the age is a floor, and a floor that grows is enough to say
        "this stopped moving", which is the whole question being asked.
        """
        changed = False
        now = int(time.time())
        for rec in self.data.values():
            if isinstance(rec, dict) and rec.get("waiting") \
                    and not rec.get("waiting_since"):
                rec["waiting_since"] = now
                changed = True
        if changed and self.path:
            self._save()

    def _must_be_writable(self):
        """Refuse to start if this file cannot be written, and say so HERE.

        The whole crash-safety design is that the intent is written before the
        send: a state file that cannot be written is a responder that pays a
        principal it cannot record, and would pay it again on the next pass.

        It has to be checked at start-up, because the first thing that
        discovers it otherwise is a disbursement -- a hardened unit gives a
        service its keys read-only, which is right, and a state file left in
        that directory then silently records nothing until the moment it
        matters most.
        """
        d = os.path.dirname(os.path.abspath(self.path)) or "."
        probe = self.path + ".probe"
        try:
            fd = os.open(probe, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
            os.close(fd)
            os.unlink(probe)
        except OSError as e:
            refuse(
                f"the responder's state file cannot be written: {self.path} "
                f"({type(e).__name__}: {e}).\n"
                f"This file is what stops a principal being paid twice -- the "
                f"intent is written before the send and completed after -- so "
                f"a responder that cannot write it must not run.\n"
                f"Put it in a directory this process may write. Under systemd "
                f"that means one named by ReadWritePaths=, not the key "
                f"directory, which ProtectHome=read-only makes read-only on "
                f"purpose. ({d} is not writable.)")

    def _take_lock(self):
        """One responder per key, enforced by the filesystem.

        Two responders on one lender key each draw their own secret for the
        same take. Only one wins the relay's race, but the loser's state file
        now holds a secret that is not that loan's -- and the address a
        principal is paid into does not depend on it, so the loser pays a
        SECOND full principal into the same place. The lender pays twice and
        can claim neither repayment. A lock is cheaper than the arithmetic.

        Beside the state file, which `_must_be_writable` has just proved this
        process can write -- so in practice the fallback below never runs. It
        is kept for the case that probe cannot cover: a directory writable when
        the probe ran and not a moment later, and a filesystem that allows a
        write but not an `flock`. Being UNABLE to make a lock is not the same
        as somebody else holding one, and the two used to report the same
        sentence: an operator was told to go and stop a second responder that
        did not exist.
        """
        import fcntl
        import hashlib
        import tempfile
        here = os.path.abspath(self.path)
        tag = hashlib.sha256(here.encode()).hexdigest()[:16]
        places = [here + ".lock",
                  os.path.join(tempfile.gettempdir(), f"pignus-responder-{tag}.lock")]
        last = None
        for lock in places:
            try:
                f = open(lock, "a+")
            except OSError as e:            # nowhere to put it: try the next
                last = e
                continue
            try:
                fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
            except OSError as e:
                f.close()
                # THIS is contention: the file exists and somebody holds it.
                refuse(
                    f"another responder already holds {lock}: two on one "
                    f"lender key pay two principals for one loan. Stop that "
                    f"one first ({type(e).__name__}: {e})")
            self._lockf = f
            try:
                f.seek(0)
                f.truncate()
                f.write(f"{os.getpid()}\n")
                f.flush()
            except OSError:
                pass                        # the flock is the lock, not the text
            return
        refuse(
            f"no writable place for a responder lock: tried "
            f"{', '.join(places)} ({type(last).__name__}: {last}). One "
            "responder per lender key is not optional -- two pay two "
            "principals for one loan -- so this will not start without it.")

    def get(self, take_id):
        return self.data.get(take_id, {})

    def items(self):
        """Every take this responder has a record of, as (take_id, record).

        Copies, so a caller that annotates one is not annotating the state.
        This is the only enumeration of a lender's own moves that does not go
        through a relay -- which matters wherever the answer bounds what the
        lender may spend.
        """
        return [(k, dict(v)) for k, v in self.data.items()]

    def set(self, take_id, **fields):
        rec = dict(self.data.get(take_id, {}))
        # A wait gets a DATE, here rather than at each of the eleven places
        # one is recorded. Without it the state file says what a take is
        # waiting on and never how long, so the two cases an operator has to
        # tell apart look identical: a pass that has just found the pre-vault
        # unconfirmed, and a take that has been blocked since Tuesday on
        # something that will not change by itself -- an upgrade fee fixed
        # below what the parent chain now charges, a deadline already too
        # close -- while a borrower's collateral sits committed. The stamp
        # moves only when the REASON changes, so a wait's clock is the clock
        # of that reason and not of the last poll.
        if "waiting" in fields:
            if not fields["waiting"]:
                rec.pop("waiting_since", None)
            elif fields["waiting"] != rec.get("waiting"):
                rec["waiting_since"] = int(time.time())
        # A step that SUCCEEDED clears the last failure -- BEFORE the stamping
        # below, so the date goes with it. These four are the only things this
        # responder does, so recording one is the proof that whatever failed
        # before is not what is happening now.
        if any(fields.get(k) for k in ("disbursement_txid", "upgrade_txid",
                                       "claim_txid", "principal_refunded")):
            fields.setdefault("last_error", None)
        # `last_error` gets a date for the same reason a wait does. It was
        # written and never cleared, so an operator reading one had no way to
        # tell a failure from a minute ago from one sitting there since last
        # week, beside a take that recovered on its own an hour later.
        if "last_error" in fields:
            if not fields["last_error"]:
                rec.pop("last_error_at", None)
            elif fields["last_error"] != rec.get("last_error"):
                rec["last_error_at"] = int(time.time())
        rec.update(fields)
        self.data[take_id] = rec
        self._save()
        return rec

    def _save(self):
        """Write via a temporary file and rename, and make the rename itself
        durable.

        This file is the only record of what this key has already sent, and
        what it costs to lose is a principal paid twice. The bytes are fsynced
        before the rename, and the DIRECTORY after it: the rename is atomic
        against a process dying but not against a power cut, and without the
        second flush it can reach the disk ahead of the bytes it renames --
        leaving a state file that is empty or truncated exactly when the
        machine has just been through the kind of event that loses writes. The
        book's own writer does this and says so; this file matters more.
        """
        if not self.path:
            return
        tmp = self.path + ".tmp"
        fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        with os.fdopen(fd, "w") as f:
            json.dump(self.data, f, indent=1, sort_keys=True)
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp, self.path)
        try:
            dirn = os.path.dirname(os.path.abspath(self.path)) or "."
            dfd = os.open(dirn, os.O_RDONLY)
            try:
                os.fsync(dfd)
            finally:
                os.close(dfd)
        except OSError:
            # Written either way; only the durability of the rename is at
            # stake, and some filesystems refuse a directory fsync outright.
            pass


def _loan_from_take(tk):
    from pignus.btc_collateral import loan_from_dict
    return loan_from_dict(dict(tk["loan"]))


def cmd_btc_propose(args):
    """LENDER: write the terms of a loan and send them to a borrower.

    No secret is drawn here. The lender's secret belongs to a particular
    borrower's loan, and there is no borrower yet -- one secret shared between
    two loans would let either borrower's repayment release the other's
    collateral.
    """
    from pignus import adaptor as A
    from pignus.btc_collateral import BtcLoan, loan_to_dict
    from pignus import btc_relay as R
    lender = _read_key(args.lender_key)
    if args.upgrade_fee is None:
        # Priced from a node where there is one, for the reason
        # `btc-offer-publish` states: this fee is fixed at origination and can
        # never be raised. A proposal is a DRAFT and nothing is broadcast by
        # writing one, so with no node this falls back to the floor and says
        # so -- refusing to write a ticket offline would be worse than a fee
        # the sender can still change.
        try:
            from pignus.btc_collateral import upgrade_fee_now   # noqa: PLC0415
            args.upgrade_fee = upgrade_fee_now(btc_node_from(args))
            print(f"upgrade fee {args.upgrade_fee} satoshis, priced from the "
                  "Bitcoin node. It cannot be raised later.", file=sys.stderr)
        except Exception:                               # noqa: BLE001
            args.upgrade_fee = MIN_UPGRADE_FEE
            print(f"no Bitcoin node to price the upgrade fee: using the floor, "
                  f"{MIN_UPGRADE_FEE} satoshis. That fee is fixed once this "
                  f"loan is originated and can never be raised, so check it "
                  f"against the parent chain before anybody funds anything.",
                  file=sys.stderr)
    loan = BtcLoan(
        btc_amount=args.btc_amount, borrower_x="", 
        lender_x=A.xonly_pubkey(lender).hex(), oracle_x=args.oracle_x,
        recover_after=args.recover_after, debt_asset=args.debt_asset,
        debt=args.debt, principal=args.principal or args.debt,
        repay_deadline=args.repay_deadline,
        abort_after=args.abort_after, d_refund=args.d_refund,
        upgrade_fee=args.upgrade_fee,
        lender_prog=R.check_program(args.lender_prog, args.lender_ver),
        lender_ver=args.lender_ver,
        market=args.market or "", strike=args.strike or 0)
    # A seizure is a 2-of-2 between the lender and the oracle, and the STRIKE
    # is the only thing either of them can be held to afterwards. A loan with
    # no strike is one where nothing constrains that decision at all: an oracle
    # asked to co-sign has no number to compare a price against, and a borrower
    # disputing it afterwards has nothing to point at. The relay refuses a
    # cross-chain offer without one; a ticket passed by hand had no such check.
    if not int(args.strike or 0):
        refuse(
            "this loan names no strike, so there would be nothing to hold the "
            "lender and the oracle to if they took the collateral. A seizure "
            "here is the two of them signing together, not a script computing "
            "anything. Give --strike.")
    if args.oracle_x == loan.lender_x:
        refuse("the lender cannot be their own oracle: that is a "
               "loan whose holder decides on their own when to seize "
               "it")
    tk = loan_to_dict(loan)
    tk["_stage"] = "proposed"
    _save_ticket(args, tk)
    print(json.dumps({"stage": "proposed",
                      "note": "send this ticket to the borrower; they choose "
                              "their own origination secret"}, indent=2))
    return 0


def cmd_btc_prepare(args):
    """BORROWER: choose this loan's origination secret and fund the PRE-VAULT,
    without broadcasting it.

    Nothing is committed here. The funding stays local until the lender's
    release has been verified, and the collateral can be taken back from the
    pre-vault whatever the lender does next. The advance signature over the
    move into the vault is not made here either -- it cannot be, because the
    vault's address commits to a hash the lender has not drawn yet. That is
    `btc-originate`, one step later.
    """
    from pignus import adaptor as A
    from pignus.btc_collateral import (fund_bitcoin, sha256,
                                       timelocks_sane, loan_from_dict)
    from pignus import btc_relay as R
    btc = btc_node_from(args)
    borrower = _read_key(args.borrower_key)
    tk = _ticket(args)
    w = A.new_secret()
    _write_key(args.borrower_key + ".w", w)
    tk["borrower_x"] = A.xonly_pubkey(borrower).hex()
    tk["h_w"] = sha256(w).hex()
    tk["borrower_prog"] = R.check_program(args.borrower_prog, args.borrower_ver)
    tk["borrower_ver"] = args.borrower_ver
    loan = loan_from_dict(tk)

    seq_height = args.seq_height
    if seq_height is None and (args.rpc_user or args.rpc_password or args.rpc_cookie):
        seq_height = node_from(args).getblockcount()
    if seq_height is not None:
        problems = timelocks_sane(loan, btc.getblockcount(), int(seq_height))
        if problems and not args.force:
            refuse("these terms are not safe to enter:\n  - "
                   + "\n  - ".join(problems)
                   + "\nRe-run with --force only if you understand "
                     "exactly what you are accepting.")

    txid, vout, fhex = fund_bitcoin(btc, loan, feerate=args.feerate,
                                    broadcast=False)
    if args.reclaim_address:
        dest = bytes.fromhex(btc.getaddressinfo(args.reclaim_address)["scriptPubKey"])
    else:
        dest = bytes.fromhex(btc.getaddressinfo(
            btc.getnewaddress())["scriptPubKey"])
    tk.update(prevault_txid=txid, prevault_vout=vout, prevault_hex=fhex,
              prevault_value=loan.prevault_value(),
              reclaim_dest=dest.hex(), reclaim_fee=args.btc_fee,
              _stage="prepared")
    _save_ticket(args, tk)
    print(json.dumps({
        "prevault_txid": txid, "vout": vout,
        "prevault_address": loan.prevault_address(btc),
        "stage": "prepared",
        "note": "send this ticket to the lender; they draw this loan's secret "
                "and sign your release. Do NOT broadcast the funding yet. Your "
                f"own secret is in {args.borrower_key}.w -- back it up, it is "
                "what claims the principal."}, indent=2))
    return 0


def cmd_btc_adaptor(args):
    """LENDER: draw this loan's secret and hand the borrower a release.

    The secret's hash goes into both chains' scripts, so the borrower does not
    have to take anybody's word for the two being the same thing. What they get
    here is an ordinary signature they can check.
    """
    from pignus import adaptor as A
    from pignus.btc_collateral import NotBroadcast
    from pignus.btc_collateral import (lender_release, reclaim_tx, upgrade_tx,
                                       sha256, loan_from_dict)
    lender = _read_key(args.lender_key)
    tk = _ticket(args)
    t = A.new_secret()
    tk["payment_hash"] = sha256(t).hex()
    tk["adaptor_point"] = A.point(t).hex()      # only the DLC path uses this
    loan = loan_from_dict(tk)
    _write_key(f"{args.lender_key}.t.{tk['h_w'][:16]}", t)
    # The vault's address depends on the payment hash, so it is only knowable
    # now: recompute it rather than trusting what the ticket carried.
    vault_txid = upgrade_tx(loan, tk["prevault_txid"],
                            int(tk["prevault_vout"])).txid()
    rtx = reclaim_tx(loan, vault_txid, 0, bytes.fromhex(tk["reclaim_dest"]),
                     int(tk.get("reclaim_fee", args.btc_fee)))
    sig = lender_release(loan, lender, rtx)
    tk.update(release_sig=sig.hex(), vault_txid=vault_txid, vault_vout=0,
              _stage="signed")
    _save_ticket(args, tk)
    print(json.dumps({"stage": "signed", "payment_hash": tk["payment_hash"],
                      "vault_txid": vault_txid,
                      "note": "send the ticket back; the secret stays here"},
                     indent=2))
    return 0


def cmd_btc_originate(args):
    """BORROWER: check the lender's release, sign the move into the vault in
    advance, and commit the collateral.

    This is the one irreversible step on the borrower's side, and everything it
    checks is checked before the broadcast rather than after. The advance
    signature belongs here rather than in `btc-prepare` because the vault's
    address commits to the lender's hash, which does not exist until they have
    drawn this loan's secret.
    """
    from pignus.btc_collateral import (check_release, reclaim_tx, upgrade_tx,
                                       presign_upgrade, loan_from_dict)
    btc = btc_node_from(args)
    tk = _ticket(args)
    loan = loan_from_dict(tk)
    vault_txid = upgrade_tx(loan, tk["prevault_txid"],
                            int(tk["prevault_vout"])).txid()
    if vault_txid != tk.get("vault_txid"):
        refuse("the ticket's vault does not follow from its own "
               "funding and terms: something has been altered since "
               "you prepared it")
    rtx = reclaim_tx(loan, vault_txid, 0, bytes.fromhex(tk["reclaim_dest"]),
                     int(tk.get("reclaim_fee", args.btc_fee)))
    if not check_release(loan, rtx, bytes.fromhex(tk["release_sig"])):
        refuse("the lender's release does NOT verify against these "
               "terms. Nothing has been broadcast.")
    # Only now can the move into the vault be signed: its address commits to the
    # hash the lender has just drawn.
    presig = presign_upgrade(loan, tk["prevault_txid"],
                             int(tk["prevault_vout"]),
                             _read_key(args.borrower_key))
    txid = btc.sendrawtransaction(tk["prevault_hex"])
    tk["upgrade_presig"] = presig.hex()
    tk["_stage"] = "funded"
    _save_ticket(args, tk)
    print(json.dumps({"funding_txid": txid, "stage": "funded",
                      "note": "the collateral is committed but not yet lent "
                              "against: you can take it back after Bitcoin "
                              f"block {loan.abort_after} if no principal "
                              "arrives"}, indent=2))
    return 0


def _seq_fee_asset(args, node):
    """`--fee-asset` as an ASSET ID, resolved from a label the node knows.

    Tier A resolves it (see `choose_fee`); the cross-chain tier's Sequentia
    legs passed the string through untouched, so `--fee-asset USDX` reached
    the composer as the five characters "USDX". The node then moves money and
    fails on the fee output -- after the interesting part of the transaction
    was already built.
    """
    if not getattr(args, "fee_asset", None):
        return None
    return resolve_asset(node, args.fee_asset)


def cmd_btc_disburse(args):
    """LENDER: pay the principal, once the collateral is really there.

    The check is not that something confirmed: it is that THIS loan's
    collateral, at the agreed amount, is in the outpoint the borrower named.

    And it is paid ONCE. A principal paid twice is a principal given away: the
    second one sits at the same address, opened by the same secret, and the
    borrower takes both. The responder has a state file for this; a lender
    running the command by hand has the ticket and the chain, and both are
    checked here.
    """
    from pignus.btc_collateral import (collateral_committed, pay_disbursement,
                                       check_upgrade_presig, loan_from_dict)
    btc = btc_node_from(args)
    seq = node_from(args)
    tk = _ticket(args)
    loan = loan_from_dict(tk)
    if tk.get("disbursement_txid") and not args.force:
        refuse(
            f"this ticket already records a principal paid at "
            f"{tk['disbursement_txid']}. Paying again would put a second one "
            f"at the same address, opened by the same secret, and the borrower "
            f"would take both. Re-run with --force only if you know that "
            f"payment never went out.")
    # ...and the chain, because a ticket can be an older copy. The address is
    # derived from the terms, so this finds a payment made from anywhere.
    try:
        already = _find_output(seq, loan.disbursement_spk().hex(),
                               asset=loan.debt_asset,
                               amount=int(loan.principal) or int(loan.debt))
    except ScanFailed as e:
        refuse(
            f"cannot check whether a principal for this loan is already on "
            f"chain: {e}\nRefusing rather than risk paying twice -- a second "
            f"principal sits at the same address, opens to the same secret, "
            f"and the borrower takes both. Try again, or --force if you know "
            f"none went out.")
    if already and not args.force:
        refuse(
            f"a principal for this loan is already on chain, unspent, at "
            f"{already[0]}:{already[1]}. This ticket does not record it -- it "
            f"is an older copy, or the payment was made from elsewhere. "
            f"Update the ticket rather than paying a second time.")
    ok, why = collateral_committed(btc, loan, tk["prevault_txid"],
                                   int(tk["prevault_vout"]),
                                   min_conf=args.disburse_conf)
    if not ok:
        refuse(f"not disbursing: {why}")
    # The deadlines, against BOTH chains' tips now, as the responder checks
    # them. A borrower chooses when to fund and nothing makes them prompt; a
    # loan whose abort_after has passed is one the borrower can empty the
    # moment the principal lands.
    from pignus.btc_collateral import timelocks_sane    # noqa: PLC0415
    late = timelocks_sane(loan, btc.getblockcount(), seq.getblockcount())
    if late and not args.force:
        refuse("not disbursing -- " + "; ".join(late)
               + "\nRe-run with --force only if you understand what that "
                 "means for a principal paid into this loan.")
    if not check_upgrade_presig(loan, tk["prevault_txid"],
                                int(tk["prevault_vout"]),
                                bytes.fromhex(tk.get("upgrade_presig", ""))):
        refuse("the borrower's advance signature does not move that "
               "collateral into this loan's vault, so the loan could "
               "never start. Not disbursing.")
    txid, vout = pay_disbursement(seq, loan,
                                  fee_asset=_seq_fee_asset(args, seq))
    tk.update(disbursement_txid=txid, disbursement_vout=vout,
              _stage="disbursed")
    _save_ticket(args, tk)
    print(json.dumps({"disbursement_txid": txid, "vout": vout,
                      "stage": "disbursed",
                      "note": "the borrower claims this to start the loan"},
                     indent=2))
    return 0


def _secret_opens(tk, secret_hex):
    """True when `secret_hex` is the preimage this loan's hash was built on.

    The one fact in a lender's report that needs no lender: hash it and look.
    """
    h = str(tk.get("payment_hash", "")).lower()
    if not h:
        return False
    try:
        raw = bytes.fromhex(str(secret_hex or ""))
    except ValueError:
        return False
    # Only a ValueError from the hex, and nothing else: a broad catch here
    # would turn a mistake in this function into a silent "no secret", which
    # is the failure this whole check exists to avoid.
    return bool(raw) and _sha256(raw) == h


def _refresh_from_book(args, tk):
    """Pull the LENDER's reported steps into a ticket, from a relay.

    A ticket carries what its holder did. The other party's moves are reported
    to the relay, and a borrower driving a loan from the command line had no
    way to fetch them: the disbursement they are about to claim is recorded on
    the relay and nowhere in their own file, so `btc-claim-principal` failed
    with a KeyError on a loan that was proceeding perfectly.

    Each field is taken only when the LENDER signed for it. The relay carries
    messages and holds no key, so its copy of an outpoint is its word -- and
    the outpoint a claim is built on decides where the money comes from.
    """
    take_id = tk.get("take_id")
    book = getattr(args, "book", None)
    if not take_id or not book:
        return tk, []
    try:
        got = book_get(book, f"/v1/btc/take/{take_id}")
    except Exception as e:                              # noqa: BLE001
        print(f"could not read this take from {book}: {e}", file=sys.stderr)
        return tk, []
    from pignus import btc_relay as R
    lender_x = str(tk.get("lender_x", ""))
    pulled = []
    for field, tag, fields in (
            ("disbursement_txid", R.DISBURSED_TAG,
             lambda g: {"txid": str(g.get("disbursement_txid", "")).lower(),
                        "vout": int(g.get("disbursement_vout", 0))}),
            ("upgrade_txid", R.UPGRADED_TAG,
             lambda g: {"txid": str(g.get("upgrade_txid", "")).lower()}),
            ("claim_txid", R.CLAIMED_TAG,
             lambda g: {"txid": str(g.get("claim_txid", "")).lower(),
                        "secret": str(g.get("secret_t", "")).lower()}),
    ):
        if tk.get(field) or not got.get(field):
            continue
        # The relay keeps each report's signature under the report's own name:
        # `disbursed_auth`, `upgraded_auth`, `claimed_auth`.
        auth = str(got.get({"disbursement_txid": "disbursed_auth",
                            "upgrade_txid": "upgraded_auth",
                            "claim_txid": "claimed_auth"}[field], ""))
        if not R.verify_report(lender_x, tag, take_id, auth, **fields(got)):
            # A SECRET is the exception, because it proves itself: it either
            # hashes to this loan's payment hash or it does not, and no
            # signature could make a wrong one right or a right one wrong.
            # Withholding a hash-checked secret over a missing signature would
            # cost a borrower their collateral for a formality.
            if field == "claim_txid" and _secret_opens(tk, got.get("secret_t")):
                tk["secret_t"] = str(got["secret_t"]).lower()
                pulled.append("secret_t")
                continue
            print(f"the relay's {field} for this take is not signed by this "
                  f"loan's lender, so it will not be believed. Ask the lender "
                  f"for the outpoint directly.", file=sys.stderr)
            continue
        tk[field] = got[field]
        if field == "disbursement_txid":
            tk["disbursement_vout"] = int(got.get("disbursement_vout", 0))
        if field == "claim_txid" and got.get("secret_t"):
            tk["secret_t"] = got["secret_t"]
        pulled.append(field)
    if pulled:
        _save_ticket(args, tk)
        print("pulled from the book: " + ", ".join(pulled), file=sys.stderr)
    return tk, pulled


def cmd_btc_claim_principal(args):
    """BORROWER: take the principal. This publishes your secret, which is what
    starts the loan -- taking the money and starting the loan are one act."""
    from pignus.btc_collateral import claim_disbursement, loan_from_dict
    seq = node_from(args)
    tk = _ticket(args)
    tk, _ = _refresh_from_book(args, tk)
    if not tk.get("disbursement_txid"):
        refuse(
            "this ticket does not say where the principal was paid, and "
            "nothing could be pulled from a book. The lender reports it once "
            "they have paid: give --book, or ask them for the outpoint and "
            "add `disbursement_txid` and `disbursement_vout` to the ticket. "
            "Until then your collateral is still abortable at Bitcoin block "
            f"{tk.get('abort_after', '?')}.")
    loan = loan_from_dict(tk)
    w = _read_key(args.borrower_key + ".w")
    txid = claim_disbursement(seq, loan, tk["disbursement_txid"],
                              int(tk["disbursement_vout"]), w,
                              fee_asset=_seq_fee_asset(args, seq))
    tk.update(principal_claim_txid=txid, _stage="claimed-principal")
    _save_ticket(args, tk)
    _tell_book(args, tk, "claimed-principal", txid)
    print(json.dumps({"claim_txid": txid, "stage": "claimed-principal"},
                     indent=2))
    return 0


def cmd_btc_upgrade(args):
    """LENDER: start the loan, using the secret the borrower published.

    The secret is read off the Sequentia chain rather than taken from anyone,
    and only acted on once the transaction that published it is buried: a
    Bitcoin reorg can undo a Sequentia transaction, and a lender who upgrades on
    a claim that is then undone has moved collateral for a principal nobody
    took.
    """
    from pignus.btc_collateral import (preimage_from_spend, complete_upgrade,
                                       anchor_safe, loan_from_dict)
    seq = node_from(args)
    btc = btc_node_from(args)
    tk = _ticket(args)
    loan = loan_from_dict(tk)
    w, spend_txid, conf = preimage_from_spend(
        seq, tk["disbursement_txid"], int(tk["disbursement_vout"]),
        expect_hash=loan.h_w)
    safe = anchor_safe(seq, spend_txid, min_depth=args.min_depth,
                       btc_node=btc)
    if not safe[0] and not args.force:
        # WHICH test failed. Four different things reach this refusal and only
        # one of them is about Sequentia depth -- reporting that number as the
        # answer sends a lender to wait for Sequentia blocks when the problem
        # is a shallow Bitcoin anchor, and no number of them would help.
        refuse(
            "the claim that published the secret is not safe to act on yet: "
            + safe.explain(args.min_depth)
            + ". Sequentia reorgs when Bitcoin reorgs; re-run with --force to "
              "accept that risk.")
    tx = complete_upgrade(loan, tk["prevault_txid"], int(tk["prevault_vout"]),
                          bytes.fromhex(tk["upgrade_presig"]), w,
                          _read_key(args.lender_key))
    txid = btc.sendrawtransaction(tx.hex())
    tk.update(upgrade_txid=txid, _stage="live")
    _save_ticket(args, tk)
    print(json.dumps({"upgrade_txid": txid, "stage": "live",
                      "note": "the collateral is in the vault; the loan runs"},
                     indent=2))
    return 0


def cmd_btc_abort(args):
    """BORROWER: take the collateral back because no principal ever came."""
    from pignus.btc_collateral import abort_tx, loan_from_dict
    btc = btc_node_from(args)
    borrower = _read_key(args.borrower_key)
    tk = _ticket(args)
    loan = loan_from_dict(tk)
    height = btc.getblockcount()
    if height < loan.abort_after and not args.force:
        refuse(f"the collateral becomes abortable at Bitcoin block "
               f"{loan.abort_after}; the chain is at {height}")
    dest = (bytes.fromhex(btc.getaddressinfo(args.dest)["scriptPubKey"])
            if args.dest else bytes.fromhex(tk["reclaim_dest"]))
    tx = abort_tx(loan, tk["prevault_txid"], int(tk["prevault_vout"]), dest,
                  args.btc_fee, borrower)
    txid = btc.sendrawtransaction(tx.hex())
    tk["_stage"] = "aborted"
    _save_ticket(args, tk)
    print(json.dumps({"abort_txid": txid, "stage": "aborted"}, indent=2))
    return 0


def cmd_btc_refund_principal(args):
    """LENDER: take back a principal the borrower never claimed."""
    from pignus.btc_collateral import refund_disbursement, loan_from_dict
    seq = node_from(args)
    tk = _ticket(args)
    loan = loan_from_dict(tk)
    # The locktime, before composing anything. A transaction built into a
    # closed one is rejected by the node as `non-final` -- after the preparing
    # send that made its explicit coins has already been paid for -- and the
    # rejection says nothing about WHEN it opens.
    locktime_open(seq, loan.d_refund, "the principal's refund")
    txid = refund_disbursement(seq, loan, tk["disbursement_txid"],
                               int(tk["disbursement_vout"]),
                               fee_asset=_seq_fee_asset(args, seq))
    tk["_stage"] = "principal-refunded"
    _save_ticket(args, tk)
    print(json.dumps({"refund_txid": txid, "stage": "principal-refunded"},
                     indent=2))
    return 0


def lender_x_of(args):
    """This responder's public key, or "" when the key is not to hand."""
    key = getattr(args, "lender_key", None)
    if not key:
        return ""
    try:
        from pignus import adaptor as A                  # noqa: PLC0415
        return A.xonly_pubkey(_read_key(key)).hex()
    except Exception:                                    # noqa: BLE001
        return ""


def _disowned_offers(args, lender_x, written_off=()):
    """Offers a book serves under this key whose signature does not verify.

    Read-only and best effort: with no book, or no key to check against, this
    is simply empty rather than an error, because the rest of the status is
    still worth printing.
    """
    book = getattr(args, "book", None)
    if not (book and lender_x):
        return {"bad_sig": [], "bad_id": []}
    try:
        offers = book_get(book, "/v1/btc/offers?status=all")["offers"]
    except Exception as e:                               # noqa: BLE001
        print(f"could not read {book} ({type(e).__name__}: {e}); offer "
              f"signatures were NOT checked", file=sys.stderr)
        return {"bad_sig": [], "bad_id": [], "unchecked": book}
    from pignus import btc_relay as R                    # noqa: PLC0415
    # Only offers a person could still be needed for: one still on the board,
    # or one with money in flight under it. A withdrawn offer whose every
    # take ended, or never got past the handshake, is history -- reporting it
    # every five minutes for ever is what teaches an operator to stop reading
    # the report, which is the one outcome worse than not having it.
    try:
        takes = book_get(book, "/v1/btc/takes")["takes"]
    except Exception:                                    # noqa: BLE001
        takes = []
    # Money this key can still act on. A take written off, or one from
    # before the format that lets its loan be rebuilt (no payout programs),
    # is not something a person can be asked for again.
    in_flight = {t.get("btc_offer_id") for t in takes
                 if t.get("status") in ("disbursed", "live", "claimed")
                 and not t.get("secret_t")
                 and t.get("take_id") not in set(written_off)
                 and (t.get("loan") or {}).get("borrower_prog")}
    out = {"bad_sig": [], "bad_id": []}
    for o in offers:
        loan = o.get("loan") or {}
        if loan.get("lender_x") != lender_x:
            continue
        oid = str(o.get("btc_offer_id", "?"))
        if o.get("status", "open") != "open" and oid not in in_flight:
            continue
        lots = int(o.get("lots", 1) or 1)
        if not R.verify_offer(loan, o.get("market", ""), lots,
                              o.get("offer_sig", "")):
            out["bad_sig"].append(oid)
        elif oid.lower() != R.offer_id(loan, o.get("market", ""), lots):
            # Signed, but served under an id that is not the hash of what was
            # signed: the responder refuses it for the same reason it refuses
            # a bad signature, and re-signing cannot repair it -- the id is
            # the record's key. What repairs it is withdrawing and publishing
            # the coin again, once nothing is in flight under it.
            out["bad_id"].append(oid)
    return out


def cmd_btc_responder_status(args):
    """What a responder has done, and what it is waiting on.

    The state file is the only record of a lender's own moves, and it is JSON
    on disk. This prints it a take at a time: the stage each one reached, and
    what it is waiting for. It only reads, so it is safe against a running
    responder, and it exits 4 when a take needs a person.
    """
    # The same config file the responder itself runs from, so an operator
    # diagnosing one names it the same way rather than working out where its
    # state file went.
    _btc_cfg(args)
    path = args.state or (args.lender_key and args.lender_key + ".state.json")
    if not path:
        refuse(
            "give --state, or --lender-key, or a --config naming one: this "
            "reads a responder's state file and nothing here says which.")
    try:
        with open(path) as f:
            data = json.load(f)
    except OSError as e:
        refuse(f"cannot read {path}: {e}")
    except ValueError as e:
        refuse(
            f"{path} is not valid JSON ({e}). The responder refuses to start "
            f"on it too, rather than begin from an empty one and pay every "
            f"principal again. Restore it from a backup.")

    def stage(h):
        if h.get("secret_published"):
            return "settled: the secret is published"
        if h.get("claim_txid"):
            return "claimed; publishing the secret once it is buried"
        if h.get("claiming"):
            return "CLAIM BROADCAST, not recorded -- recovering next pass"
        if h.get("refunding"):
            return "REFUND BROADCAST, not recorded -- recovering next pass"
        if h.get("principal_refunded"):
            return "the principal was taken back"
        if h.get("upgrade_txid"):
            return "live: the collateral is in the vault"
        if h.get("disbursement_txid"):
            return "disbursed; waiting for the borrower to claim it"
        if h.get("disbursing"):
            return "DISBURSEMENT IN FLIGHT -- sent, not recorded"
        if h.get("vault_txid"):
            return "signed; waiting for the collateral"
        if h.get("t"):
            return "reserved: this loan's secret is drawn"
        return "nothing recorded"

    rows = []
    for take_id, h in sorted(data.items()):
        rows.append({
            "take_id": take_id,
            "stage": stage(h),
            "waiting_on": h.get("waiting") or None,
            "written_off": h.get("written_off") or None,
            "waiting_hours": (None if not h.get("waiting_since")
                              else round((time.time()
                                          - int(h["waiting_since"])) / 3600, 1)),
            "has_secret": bool(h.get("t")),
            "disbursement_txid": h.get("disbursement_txid"),
            "upgrade_txid": h.get("upgrade_txid"),
            "claim_txid": h.get("claim_txid"),
            "secret_published": bool(h.get("secret_published")),
            "reports_unsent": [k for k, flag in
                               (("disbursed", "disbursed_reported"),
                                ("upgraded", "upgraded_reported"),
                                ("refunded", "refunded_reported"))
                               if h.get({"disbursed": "disbursement_txid",
                                         "upgraded": "upgrade_txid",
                                         "refunded": "principal_refunded"}[k])
                               and not h.get(flag)],
            "last_error": h.get("last_error"),
            "last_error_hours": (None if not h.get("last_error_at")
                                 else round((time.time()
                                             - int(h["last_error_at"])) / 3600,
                                            1)),
        })
    # A send that went out and was NOT recorded: the one thing a responder
    # cannot repair for itself, because clearing the flag blind pays a second
    # principal. The claim and the refund are different -- each writes its
    # intent before broadcasting and finds its own transaction on the next
    # pass by the outpoint it spent -- so raising the alarm on them sends an
    # operator to `btc-responder-clear`, which refuses them, for something that
    # was already fixing itself.
    written = [r for r in rows if r.get("written_off")]
    rows_live = [r for r in rows if not r.get("written_off")]
    stuck = [r for r in rows_live if "DISBURSEMENT IN FLIGHT" in r["stage"]]
    # And a wait that has stopped being a wait. Most reasons here clear
    # themselves within a block or two -- a pre-vault not yet confirmed, an
    # anchor not yet buried -- so a take still on the same one hours later is
    # on one that will not clear: an upgrade fee fixed below what the parent
    # chain now charges, a deadline already too close, a secret this key never
    # had. Nothing reported those, because the state file looked no different
    # from a take that had been waiting ten seconds; and every one of them has
    # a borrower's collateral committed behind it. What the lender does about
    # it is usually to withdraw the OFFER, since a reason like that repeats for
    # every take of it.
    slow = [r for r in rows_live
            if r not in stuck and r["waiting_on"]
            and (r["waiting_hours"] or 0) >= args.waiting_hours]
    # A wait written before waits were dated has no date, and the reason a take
    # is stuck on is the reason that never changes -- so these would never be
    # dated by an ordinary pass. The responder stamps them the next time it
    # starts, which only it may do: this command holds no lock, and a second
    # writer here could land its snapshot on top of a disbursement the
    # responder had just recorded. So it says so instead of guessing.
    undated = [r for r in rows_live if r["waiting_on"] and r["waiting_hours"] is None]
    healing = [r for r in rows_live
               if r not in stuck and "not recorded" in r["stage"]]
    # And the offers this key signed that a book is now serving with a
    # signature that does not check out. A responder skips every take of one,
    # silently, so a live loan under it stops being claimed and its borrower's
    # collateral is never released -- and nothing else here would show it,
    # because the state file looks exactly as it did the day before.
    dis = _disowned_offers(args, lender_x_of(args),
                           written_off={r["take_id"] for r in written})
    disowned = dis["bad_sig"]
    unfixable = dis["bad_id"]
    unchecked = dis.get("unchecked")
    print(json.dumps({"state": path, "takes": len(rows),
                      "needing_attention": (len(stuck) + len(disowned)
                                            + len(unfixable) + len(slow)),
                      "recovering": len(healing),
                      "disowned_offers": disowned,
                      "offers_under_an_old_id": unfixable,
                      "offers_unchecked_at": unchecked,
                      "written_off": [r["take_id"] for r in written],
                      "rows": rows}, indent=2))
    if unchecked:
        print(f"the book at {unchecked} could not be read, so the offers "
              f"served under this key were NOT checked; a stale or forged one "
              f"would not show here. Exit 1 says so.", file=sys.stderr)
    if unfixable:
        print(f"{len(unfixable)} offer(s) carry this key's signature but are "
              f"served under an id that is not the hash of their terms: "
              f"{', '.join(unfixable)}. The responder skips every take of "
              f"them, because an offer under a second id is how a relay would "
              f"count its lots twice, and a record from before ids were that "
              f"hash looks the same. Re-signing cannot repair this -- the id "
              f"is the record's key. A principal already paid under one is "
              f"refunded with `pignus-cli btc-refund-principal` once its "
              f"d_refund opens; when nothing is in flight, withdraw the offer "
              f"and publish the coin again.", file=sys.stderr)
    if disowned:
        print(f"{len(disowned)} offer(s) name this key and do not verify under "
              f"it: {', '.join(disowned)}. Every take of them is being skipped, "
              f"live loans included, so their borrowers' collateral is not "
              f"being released.\n"
              f"  Repair with: pignus-cli btc-offer-resign --offer "
              f"{disowned[0]}\n"
              f"  Do NOT republish. An offer's id is the hash of its own terms "
              f"under the signature over them, so a republished offer is a "
              f"SECOND offer at a second id -- the board would carry both, and "
              f"this key would stand behind twice the lots it meant to. "
              f"Re-signing keeps the id, the takes and the loans already under "
              f"it.", file=sys.stderr)
    if slow:
        print(f"{len(slow)} take(s) have been waiting on the same thing for "
              f"{args.waiting_hours}h or more, with a borrower's collateral "
              f"committed behind each:", file=sys.stderr)
        for r in slow:
            print(f"  {r['take_id']}: {r['waiting_on']} "
                  f"({r['waiting_hours']}h)", file=sys.stderr)
        print("  Most reasons here clear on their own within a block or two, "
              "so one this old is one that will not. If it is `upgrade-fee` or "
              "a deadline, it will repeat for every take of that offer: "
              "withdraw the offer and publish a new one, and tell the borrower "
              "they can abort their pre-vault and take their Bitcoin back.",
              file=sys.stderr)
    if undated:
        print(f"{len(undated)} take(s) are waiting on something with no date "
              f"on it, so how long cannot be said here: "
              + ", ".join(r["take_id"] for r in undated)
              + ". Restarting the responder dates them; only it may write this "
                "file.", file=sys.stderr)
    if healing:
        print(f"{len(healing)} take(s) are recovering on their own: a claim or "
              f"a refund was broadcast and the answer lost, and each wrote the "
              f"outpoint it spent before sending -- so the next pass finds its "
              f"own transaction on the chain. Nothing to do; "
              f"`btc-responder-clear` refuses these, and rightly.",
              file=sys.stderr)
    if stuck:
        print(f"{len(stuck)} take(s) need attention: a send went out and was "
              f"not recorded. The responder retries by looking at the chain; "
              f"if it cannot find the payment it says so every pass and will "
              f"not send again. `pignus-cli btc-responder-clear` is the only "
              f"safe way to tell it to.", file=sys.stderr)
    # 4 means "a person is needed", and an offer whose takes are being skipped
    # needs one exactly as much as a send that was lost: both leave a loan that
    # will not move, and neither clears itself.
    # 4 means "a person is needed". A take recovering on its own does not need
    # one, so it is reported and does not raise the alarm.
    # Status 4 is "the command ran and reported a state that is not settled and
    # not safe to act on", which a take blocked on an unclearing reason is: a
    # borrower's collateral is committed and nothing is going to move it. A
    # timer that only looks at the exit code was told everything was fine.
    if stuck or disowned or unfixable or slow:
        return 4
    return 1 if unchecked else 0


def cmd_btc_responder_clear(args):
    """Tell a responder that a send it recorded as in-flight never went out.

    The one recovery a responder cannot make for itself: a flag cleared blind
    pays a principal twice, so it stays set until a person says otherwise.
    Editing the state file by hand is not that -- a running responder
    overwrites it on its next save, silently. This takes the lock the
    responder holds, so it refuses while the unit is up, and it looks at the
    chain before it changes anything: if the payment IS there, `--found
    <txid>:<vout>` records it rather than clearing it.
    """
    _btc_cfg(args)
    path = args.state or (args.lender_key and args.lender_key + ".state.json")
    if not path:
        refuse(
            "give --state, or --lender-key, or a --config naming one: this "
            "writes a responder's state file and nothing here says which.")
    state = _ResponderState(path, exclusive=True)   # refuses if one is running
    have = state.get(args.take)
    if getattr(args, "write_off", None):
        # A take this key can do nothing more about -- a record from before
        # the format that lets it be rebuilt, a principal paid to a plain
        # address that no covenant ever bound -- written off, on the record,
        # so the status command and the timer stop asking for a person.
        # The reason is kept; a write-off with no reason is a blind spot.
        if have.get("disbursing"):
            refuse(f"take {args.take} has a disbursement in flight; clear or "
                   f"record that first, then write the take off.")
        # A paid principal nobody has claimed is a LIVE loan to every pass:
        # the claim pass is what turns its repayment into this key's money,
        # and a written-off take is skipped by every pass. Writing one off
        # is walking away from that claim, which a person may decide -- the
        # principal went somewhere no covenant bound -- but not by accident.
        paid = have.get("disbursement_txid")
        done = have.get("claim_txid") or have.get("secret_published") \
            or have.get("principal_refunded")
        if paid and not done and not getattr(args, "force", False):
            refuse(f"take {args.take} has a paid principal ({str(paid)[:16]}...) "
                   f"and no claim of its repayment. Written off, no pass will "
                   f"claim it: this key would be walking away from that money. "
                   f"If that is the decision -- the principal went somewhere no "
                   f"covenant bound, say -- repeat with --force.")
        state.set(args.take, written_off=str(args.write_off),
                  written_off_at=int(time.time()), waiting=None)
        print(f"take {args.take} written off: {args.write_off}. It is kept in "
              f"the state file and reported as written off, and the responder "
              f"acts on it no more.", file=sys.stderr)
        return 0
    if not have:
        refuse(f"{path} has no record of take {args.take}")
    if not have.get("disbursing"):
        refuse(
            f"take {args.take} has no disbursement in flight; there is nothing "
            f"to clear. Its state: {json.dumps(have, sort_keys=True)[:200]}")
    spk = have["disbursing"]
    if args.rpc_user or args.rpc_password or args.rpc_cookie:
        node = node_from(args)
        try:
            found = _find_output(node, spk)
        except ScanFailed as e:
            refuse(
                f"the chain could not be checked ({e}). Refusing: clearing "
                f"this flag while a payment might be on chain is how a second "
                f"principal goes out.")
        if args.found:
            # The chain CONFIRMS what was passed rather than refusing over it.
            # Refusing here made the command's own instruction a dead end: it
            # told an operator to re-run with `--found` and then refused that
            # too, so the one repair a responder cannot make for itself could
            # not be made at all.
            want_txid, _, want_vout = str(args.found).rpartition(":")
            try:
                out = node.gettxout(want_txid, int(want_vout), True)
            except (ValueError, Exception) as e:        # noqa: BLE001
                refuse(f"{args.found} cannot be read on this node "
                       f"({type(e).__name__}: {e})")
            if out is None:
                refuse(
                    f"{args.found} is not an unspent output on this node. If "
                    f"the borrower has already claimed the principal it is "
                    f"gone from the UTXO set, and the responder finds it from "
                    f"their own report instead -- leave the flag alone and let "
                    f"it.")
            if out.get("scriptPubKey", {}).get("hex") != spk:
                refuse(
                    f"{args.found} does not pay this loan's disbursement "
                    f"address. That address is derived from the loan's own "
                    f"commitment, so a coin somewhere else is somebody else's "
                    f"money: refusing to record it as this principal.")
            if found and (found[0], found[1]) != (want_txid, int(want_vout)):
                print(f"note: the scan also found a payment to this address at "
                      f"{found[0]}:{found[1]}; recording the one you named.",
                      file=sys.stderr)
        elif found:
            refuse(
                f"a payment to {spk[:24]}… IS on chain at {found[0]}:{found[1]}. "
                f"Do not clear this: record it instead, with "
                f"`btc-responder-clear --take {args.take} --found "
                f"{found[0]}:{found[1]}`.")
    elif args.found and not args.force:
        refuse(
            "give node credentials so the outpoint you named can be checked "
            "against this loan's own disbursement address, or --force if you "
            "have checked by hand. Recording the wrong outpoint tells the "
            "responder a principal was paid where it was not.")
    elif not args.force:
        refuse(
            "give node credentials so this can check the chain first, or "
            "--force if you have checked by hand. Clearing the flag while a "
            "payment is on chain pays a second principal.")
    if args.found:
        txid, _, vout = str(args.found).rpartition(":")
        state.set(args.take, disbursing=None, disbursement_txid=txid,
                  disbursement_vout=int(vout), disbursed_reported=False)
        print(f"recorded the disbursement at {txid}:{vout}; the responder will "
              f"report it on its next pass", file=sys.stderr)
    else:
        state.set(args.take, disbursing=None)
        print(f"cleared the in-flight flag for {args.take}; the responder will "
              f"consider paying it again", file=sys.stderr)
    print(json.dumps({"take_id": args.take, "state": path,
                      "cleared": True, "recorded": args.found or None}))
    return 0


def _btc_vault_exit(loan, witness):
    """Which leaf of a cross-chain vault a spend used: reclaim, seize, timeout.

    A taproot script spend puts the leaf script itself second-from-last in the
    witness, so the answer is already on the chain and needs no index and no
    explorer. Returns None when the witness names no leaf of THIS loan's vault,
    which is the honest answer for a spend of something else.
    """
    try:
        tree = loan.funding_tree()
        used = bytes.fromhex(str(witness[-2]))
    except Exception:                                   # noqa: BLE001
        return None
    for name, script in tree.leaves.items():
        if bytes(script) == used:
            return name
    return None


def cmd_btc_check(args):
    """Where a loan stands, on both chains, and what each party can do next.

    A cross-chain loan has two halves that can disagree, and reading two block
    explorers to work out which is a good way to act on the wrong one.
    """
    from pignus.btc_collateral import (loan_from_dict, collateral_committed,
                                       tx_confirmations, timelocks_sane,
                                       effective_repay_deadline, spend_witness)
    tk = _ticket(args)
    loan = loan_from_dict(tk)
    out = {"stage": tk.get("_stage", "?"),
           "market": loan.market, "btc_amount": loan.btc_amount,
           "debt": loan.debt, "principal": int(loan.principal) or loan.debt,
           # BOTH deadlines, and which is which. The written one is when the
           # borrower's own refund opens; the effective one is the last block a
           # lender will still claim at, because claiming publishes the secret
           # and doing that as the refund opens would hand the borrower the
           # debt and the collateral both. Quoting only the written figure
           # invites a borrower to pay into a window nobody will answer.
           "repay_by": effective_repay_deadline(loan),
           "repay_deadline_written": loan.repay_deadline,
           "recover_after": loan.recover_after,
           "strike": loan.strike}
    btc = seq = None
    try:
        btc = btc_node_from(args)
        out["btc_height"] = btc.getblockcount()
    except Exception as e:                              # noqa: BLE001
        out["btc_error"] = str(e)
    try:
        seq = node_from(args)
        out["seq_height"] = seq.getblockcount()
    except Exception as e:                              # noqa: BLE001
        out["seq_error"] = str(e)
    if btc and tk.get("prevault_txid"):
        ok, why = collateral_committed(btc, loan, tk["prevault_txid"],
                                       int(tk["prevault_vout"]), min_conf=1)
        out["prevault"] = {"outpoint": f"{tk['prevault_txid']}:{tk['prevault_vout']}",
                           "committed": ok, "detail": why}
    if btc and tk.get("upgrade_txid"):
        # Is the collateral still THERE? Nothing else in this repository ever
        # asks. A vault seized by the lender and the oracle together, or swept
        # at the timeout, leaves a ticket that still reads like a live loan --
        # and a borrower who cannot see that has no way to know their
        # collateral is gone until they try to reclaim it.
        vout = int(tk.get("vault_vout", 0))
        v = {"txid": tk["upgrade_txid"],
             "confirmations": tx_confirmations(btc, tk["upgrade_txid"], vout)}
        try:
            v["unspent"] = btc.gettxout(tk["upgrade_txid"], vout, True) is not None
        except Exception as e:                          # noqa: BLE001
            v["unspent"] = None
            v["detail"] = f"the node would not say: {e}"
        if v.get("unspent") is False:
            v["detail"] = ("the collateral has LEFT this vault. That is a "
                           "reclaim, a seizure by the lender and the oracle "
                           "together, or the lender's timeout sweep.")
            try:
                found = spend_witness(btc, tk["upgrade_txid"], vout)
                if found:
                    v["spent_by"] = found[0]
                    v["spend_confirmations"] = found[2]
                    # ...and WHICH of the three, which the witness says
                    # outright. A taproot script spend carries the leaf it
                    # used, and the three leaves mean opposite things to a
                    # borrower: a reclaim is their own collateral coming back,
                    # a seizure is one they may want to dispute, and a sweep is
                    # a deadline they missed. Fetching the witness and then
                    # printing "one of these three" left them to read a block
                    # explorer to find out which.
                    v["exit"] = _btc_vault_exit(loan, found[1])
                    v["exit_detail"] = {
                        "reclaim": "you took the collateral back with the "
                                   "secret the lender published.",
                        "seize": "the lender and the oracle co-signed a "
                                 "seizure. The price it was justified below is "
                                 "this loan's strike, and the attestation "
                                 "behind it is published at the oracle's "
                                 "/v1/seizures.",
                        "timeout": "the lender swept it at the timeout, which "
                                   "opens when the loan was not repaid and "
                                   "reclaimed in time.",
                    }.get(v["exit"], "this spend does not use any leaf of this "
                                     "loan's own vault.")
                    v["detail"] = v["exit_detail"]
            except Exception:                           # noqa: BLE001
                pass
        out["vault"] = v
    if seq and tk.get("disbursement_txid"):
        # A different key from the AMOUNT above. This used to be `principal`
        # too, so the moment a loan reached the one stage where a borrower most
        # wants to see what they are owed, the figure vanished and an object
        # took its place.
        out["principal_output"] = {
            "outpoint": f"{tk['disbursement_txid']}:{tk.get('disbursement_vout', 0)}",
            "still_unclaimed": bool(seq.gettxout(tk["disbursement_txid"],
                                                 int(tk.get("disbursement_vout", 0)),
                                                 True))}
    if btc and seq:
        out["timelocks"] = timelocks_sane(loan, out.get("btc_height", 0),
                                          out.get("seq_height", 0)) or "sane"
    out["next"] = {
        "proposed": "borrower: btc-prepare",
        "prepared": "lender: btc-release",
        "signed": "borrower: btc-originate",
        # The ticket is written before the collateral is broadcast, so this is
        # what a crash between the two leaves behind. The chain above settles
        # it: `collateral` says whether the funding is actually there.
        "funding": "borrower: check `collateral` above. The funding was "
                   "composed and written down; whether it was relayed is what "
                   "this reads from the chain. Re-run btc-originate if it "
                   "never went out",
        "funded": "lender: btc-disburse (or borrower: btc-abort, after "
                  f"Bitcoin block {loan.abort_after})",
        "disbursed": "borrower: btc-claim-principal (or lender: "
                     f"btc-refund-principal, after Sequentia block {loan.d_refund})",
        "claimed-principal": "lender: btc-upgrade",
        # The EFFECTIVE deadline, which is the one a borrower is really held
        # to. A lender stops claiming CLAIM_MARGIN_BLOCKS before the written
        # one, because claiming publishes the secret and doing that as the
        # borrower's own refund opens hands them the debt and the collateral
        # both -- so a repayment made against the written figure is one nobody
        # will answer. Every other surface here quotes the effective one; this
        # was the last that did not.
        "live": (f"borrower: btc-repay, before Sequentia block "
                 f"{effective_repay_deadline(loan)} (the written deadline is "
                 f"{loan.repay_deadline}; a lender stops claiming before it, "
                 f"and a repayment nobody claims releases no collateral)"),
        "repaid": "lender: btc-claim",
        "claimed": "borrower: btc-reclaim",
    }.get(tk.get("_stage"), "nothing further")
    print(json.dumps(out, indent=2))
    return 0


def _tell_book(args, tk, kind, txid, vout=0):
    """BORROWER: tell a book where their own payment landed.

    A hint, and the relay treats it as one -- it records the outpoint and moves
    no status, because a status a borrower can move is one a borrower can use
    against the lender acting on it. What it buys is a page that keeps up: a
    borrower who has just repaid sees a loan that says so, instead of one that
    still says `live` until the lender's next scan happens to find the spend.

    Silent when there is no book or no borrower key, because both steps this is
    attached to are complete without it: the payment is on chain either way.
    """
    book = getattr(args, "book", None)
    key = getattr(args, "borrower_key", None)
    if not (book and key and tk.get("take_id")):
        return
    from pignus import btc_relay as R
    tag = (R.CLAIMED_PRINCIPAL_TAG if kind == "claimed-principal"
           else R.REPAID_TAG)
    try:
        sec = _read_key(key)
        code, r = book_post(book, f"/v1/btc/{kind}", {
            "take_id": tk["take_id"], "txid": txid, "vout": int(vout),
            "auth": R.sign_report(sec, tag, tk["take_id"],
                                  txid=txid, vout=int(vout))})
        if code != 200:
            print(f"the book would not record this: {r.get('error')}. That is "
                  "a page that lags, not a payment that failed.",
                  file=sys.stderr)
    except Exception as e:                              # noqa: BLE001
        print(f"could not tell {book} about this: {e}. That is a page that "
              "lags, not a payment that failed.", file=sys.stderr)


def cmd_btc_repay(args):
    """BORROWER: pay the debt into the output the lender can only open by
    publishing the secret that releases your collateral."""
    from pignus.btc_collateral import pay_repayment, loan_from_dict
    node = node_from(args)
    tk = _ticket(args)
    loan = loan_from_dict(tk)
    # Paid ONCE, and the two guards are the lender's own, in the other
    # direction. Every other borrower command is idempotent because it spends
    # an outpoint and the second attempt is rejected; this one pays into a
    # fresh hashlocked address, so a second run simply puts a second debt
    # there. Both are opened by the same secret, the lender takes both, and
    # the borrower cannot reach either before `repay_deadline`.
    if tk.get("repay_txid") and not args.force:
        refuse(
            f"this ticket already records a repayment at {tk['repay_txid']}. "
            f"Paying again puts a second debt at the same address, opened by "
            f"the same secret, and the lender takes both -- you could not "
            f"reach either before Sequentia block {loan.repay_deadline}. "
            f"Re-run with --force only if you know that payment never went "
            f"out.")
    # ...and the chain, because a ticket can be an older copy, or the write
    # that would have recorded it can be the thing that failed.
    try:
        already = _find_output(node, loan.repayment_spk().hex(),
                               asset=loan.debt_asset, amount=int(loan.debt))
    except ScanFailed as e:
        refuse(
            f"cannot check whether a repayment for this loan is already on "
            f"chain: {e}\nRefusing rather than risk paying twice -- a second "
            f"debt sits at the same address, opens to the same secret, and "
            f"the lender takes both. Try again, or --force if you know none "
            f"went out.")
    if already and not args.force:
        refuse(
            f"a repayment for this loan is already on chain, unspent, at "
            f"{already[0]}:{already[1]}. This ticket does not record it -- it "
            f"is an older copy, or the payment was made from elsewhere. "
            f"Update the ticket rather than paying a second time: add "
            f"`repay_txid` and `repay_vout`, or re-run `btc-check`.")
    txid, vout = pay_repayment(node, loan,
                               fee_asset=_seq_fee_asset(args, node))
    tk.update(repay_txid=txid, repay_vout=vout, _stage="repaid")
    _save_ticket(args, tk)
    _tell_book(args, tk, "repaid", txid, vout)
    print(json.dumps({"repay_txid": txid, "vout": vout, "stage": "repaid"},
                     indent=2))
    return 0


def cmd_btc_claim(args):
    """LENDER: take the repayment, which publishes the secret that releases the
    borrower's collateral.

    Two refusals, and they are the same two the responder applies to the same
    action -- a lender doing this by hand is not a lender who should get less
    protection than one running a daemon.
    """
    from pignus.btc_collateral import (claim_repayment, loan_from_dict,
                                       effective_repay_deadline,
                                       tx_confirmations)
    node = node_from(args)
    tk = _ticket(args)
    loan = loan_from_dict(tk)
    height = int(node.getblockcount())
    # Claiming PUBLISHES the secret. Doing it once the borrower's own refund is
    # open makes both spends of the repayment valid at once: they read the
    # secret out of the mempool, reclaim the Bitcoin, and take the repayment
    # back. The margin exists so that is never a race the lender can enter.
    limit = effective_repay_deadline(loan)
    if height >= limit and not args.force:
        refuse(
            f"it is Sequentia block {height} and this loan stops being safely "
            f"claimable at {limit} (its written deadline is "
            f"{loan.repay_deadline}). Claiming now publishes the secret with "
            f"the borrower's own refund already open, so they could take back "
            f"the repayment AND the collateral. Re-run with --force only if "
            f"you understand that.")
    # And a zero-confirmation repayment is one its own author can still
    # replace, after reading the secret out of the claim's witness.
    conf = tx_confirmations(node, tk["repay_txid"], int(tk["repay_vout"]))
    if conf < args.min_depth and not args.force:
        refuse(
            f"the repayment at {tk['repay_txid'][:16]}… has {max(0, conf)} "
            f"confirmation(s), below the {args.min_depth} this asks for. It "
            f"signals replacement, so claiming now puts the secret in the "
            f"mempool over a parent the borrower can still replace. Wait, or "
            f"re-run with --force.")
    t = _read_key(args.secret_file or f"{args.lender_key}.t.{tk['h_w'][:16]}")
    txid = claim_repayment(node, loan, tk["repay_txid"], int(tk["repay_vout"]),
                           t, fee_asset=_seq_fee_asset(args, node))
    tk.update(claim_txid=txid, _stage="claimed")
    _save_ticket(args, tk)
    print(json.dumps({"claim_txid": txid, "stage": "claimed",
                      "note": "the secret is now public on the Sequentia chain"},
                     indent=2))
    return 0


def cmd_btc_reclaim(args):
    """BORROWER: read the secret off the chain, check it is buried, and take
    the collateral back."""
    from pignus.btc_collateral import (reclaim_tx, complete_reclaim,
                                       preimage_from_spend, anchor_safe,
                                       loan_from_dict)
    node = node_from(args)
    btc = btc_node_from(args)
    borrower = _read_key(args.borrower_key)
    tk = _ticket(args)
    loan = loan_from_dict(tk)
    t, spend_txid, _conf = preimage_from_spend(
        node, tk["repay_txid"], int(tk["repay_vout"]),
        expect_hash=loan.payment_hash)
    safe = anchor_safe(node, spend_txid, min_depth=args.min_depth,
                       btc_node=btc)
    if not safe[0] and not args.force:
        # The same sentence the other side of this loan gets, from the same
        # check. Two refusals for one condition, each asserting a different
        # half of it as though it were the whole, is how a lender concludes
        # the tool is broken.
        refuse(
            "the claim that published your secret is not safe to act on yet: "
            + safe.explain(args.min_depth)
            + ". Spending Bitcoin on a secret a reorg could undo loses both "
              "sides of this loan; re-run with --force to accept that risk.")
    rtx = reclaim_tx(loan, tk["vault_txid"], int(tk.get("vault_vout", 0)),
                     bytes.fromhex(tk["reclaim_dest"]),
                     int(tk.get("reclaim_fee", args.btc_fee)))
    tx = complete_reclaim(loan, rtx, bytes.fromhex(tk["release_sig"]), t,
                          borrower)
    txid = btc.sendrawtransaction(tx.hex())
    tk["_stage"] = "reclaimed"
    _save_ticket(args, tk)
    print(json.dumps({"reclaim_txid": txid, "stage": "reclaimed"}, indent=2))
    return 0


def cmd_btc_seize_sighash(args):
    """LENDER: print the seizure request an ORACLE co-signs.

    The request carries the loan, not just a number: an oracle handed a bare
    sighash has no way to tell whether it is seizing an under-water loan or a
    healthy one, and signing what it cannot read is the whole of the risk this
    tier carries.
    """
    from pignus.btc_collateral import seize_request, loan_from_dict
    btc = btc_node_from(args)
    tk = _ticket(args)
    loan = loan_from_dict(tk)
    dest = (bytes.fromhex(btc.getaddressinfo(args.dest)["scriptPubKey"])
            if args.dest else
            bytes.fromhex(btc.getaddressinfo(btc.getnewaddress())["scriptPubKey"]))
    req = seize_request(loan, tk["vault_txid"], int(tk.get("vault_vout", 0)),
                        dest, args.btc_fee)
    # The STRIKE is what an oracle judges by, and it is in no Bitcoin script,
    # so the request's own sighash cannot pin it. The lender's signature over
    # the offer that fixed it can, and a ticket taken from an offer carries
    # that signature -- so pass it on, and say plainly when there is none.
    req["offer_sig"] = str(tk.get("offer_sig") or "")
    req["offer_lots"] = int(tk.get("offer_lots", 1) or 1)
    # ...and the BORROWER's acceptance, which is what pins the strike: the
    # lender's signature above can be made again over any number. From the
    # ticket when it carries it, else from the book's own take record.
    for k in ("take_auth", "prevault_txid", "prevault_vout"):
        if tk.get(k) not in (None, ""):
            req[k] = tk[k]
    if "take_auth" not in req and getattr(args, "book", None) and tk.get("take_id"):
        try:
            got = book_get(args.book, f"/v1/btc/take/{tk['take_id']}")
            for k in ("take_auth", "prevault_txid", "prevault_vout"):
                if got.get(k) not in (None, ""):
                    req[k] = got[k]
        except Exception as e:                          # noqa: BLE001
            print(f"could not fetch the take from the book: {e}", file=sys.stderr)
    if "take_auth" not in req:
        print("this request carries no borrower acceptance (`take_auth`), so "
              "nothing shows the strike is the one the borrower agreed to. An "
              "oracle should refuse it, and will unless its operator passes "
              "--allow-unpinned-strike. Give --book so it can be fetched from "
              "the take.", file=sys.stderr)
    if not req["offer_sig"]:
        print("this ticket carries no lender-signed offer, so nothing pins the "
              "strike this asks an oracle to judge against. An oracle should "
              "refuse it, and will unless its operator passes "
              "--allow-unpinned-strike. Add `offer_sig` (and `offer_lots`) "
              "from the offer this loan was taken from.", file=sys.stderr)
    if args.out:
        with open(args.out, "w") as f:
            json.dump(req, f, indent=2, sort_keys=True)
        print(f"wrote {args.out}", file=sys.stderr)
    tk.update(seize_dest=dest.hex(), seize_fee=args.btc_fee)
    _save_ticket(args, tk)
    print(json.dumps(req, indent=2))
    return 0


def cmd_btc_seize(args):
    """LENDER + oracle: seize the collateral, with the oracle's signature over
    the identical transaction."""
    from pignus.btc_collateral import seize_tx, loan_from_dict
    btc = btc_node_from(args)
    lender = _read_key(args.lender_key)
    tk = _ticket(args)
    loan = loan_from_dict(tk)
    dest = bytes.fromhex(args.dest_spk or tk["seize_dest"])
    tx = seize_tx(loan, tk["vault_txid"], int(tk.get("vault_vout", 0)), dest,
                  int(tk.get("seize_fee", args.btc_fee)), lender,
                  bytes.fromhex(args.oracle_sig))
    txid = btc.sendrawtransaction(tx.hex())
    tk["_stage"] = "seized"
    _save_ticket(args, tk)
    print(json.dumps({"seize_txid": txid, "stage": "seized"}, indent=2))
    return 0


def cmd_btc_timeout(args):
    """LENDER: sweep the collateral after the term, when nobody repaid."""
    from pignus.btc_collateral import timeout_tx, loan_from_dict
    btc = btc_node_from(args)
    lender = _read_key(args.lender_key)
    tk = _ticket(args)
    loan = loan_from_dict(tk)
    # The TIMEOUT locktime, before anything is composed. Bitcoin rejects a
    # transaction built into a closed one as `non-final`, and says nothing
    # about when it opens; this is that sentence, on a chain where a lender
    # sweeping too early has a long wait ahead of them.
    height = int(btc.getblockcount())
    if height < int(loan.recover_after):
        refuse(
            f"the TIMEOUT sweep opens at Bitcoin block {loan.recover_after} "
            f"and the chain is at {height}: {int(loan.recover_after) - height} "
            f"block(s) to go, about "
            f"{(int(loan.recover_after) - height) * 10 // 60} hour(s). Bitcoin "
            f"would reject this as non-final.")
    dest = (bytes.fromhex(btc.getaddressinfo(args.dest)["scriptPubKey"])
            if args.dest else
            bytes.fromhex(btc.getaddressinfo(btc.getnewaddress())["scriptPubKey"]))
    tx = timeout_tx(loan, tk["vault_txid"], int(tk.get("vault_vout", 0)), dest,
                    args.btc_fee, lender)
    txid = btc.sendrawtransaction(tx.hex())
    tk["_stage"] = "timed-out"
    _save_ticket(args, tk)
    print(json.dumps({"timeout_txid": txid, "stage": "timed-out"}, indent=2))
    return 0


def cmd_btc_refund(args):
    """BORROWER: take the repayment back after the deadline, if the lender
    never claimed it."""
    from pignus.btc_collateral import refund_repayment, loan_from_dict
    node = node_from(args)
    tk = _ticket(args)
    loan = loan_from_dict(tk)
    locktime_open(node, loan.repay_deadline, "the repayment's refund")
    txid = refund_repayment(node, loan, tk["repay_txid"], int(tk["repay_vout"]),
                            fee_asset=_seq_fee_asset(args, node))
    tk["_stage"] = "refunded"
    _save_ticket(args, tk)
    print(json.dumps({"refund_txid": txid, "stage": "refunded"}, indent=2))
    return 0


def _await_take(args, take_id, ready, seconds):
    """Poll a take until it is ready, or give up. Nothing is committed while
    waiting, so giving up costs nothing."""
    for _ in range(max(1, int(seconds / 2))):
        rec = book_get(args.book, f"/v1/btc/take/{take_id}")
        if ready(rec):
            return rec
        time.sleep(2)
    return None


def cmd_btc_offer_take(args):
    """BORROWER: take a cross-chain offer from a relay, without a browser.

    The same sequence the page runs, in one command: rebuild the offer's terms,
    fund the pre-vault without broadcasting it, sign the move into the loan in
    advance, ask for the release, check it, and only then commit the collateral.
    It writes a ticket, so every other btc-* command works on the loan
    afterwards.
    """
    from pignus import adaptor as A
    from pignus import btc_relay as R
    from pignus.btc_collateral import (loan_from_dict, loan_to_dict, sha256,
                                       fund_bitcoin, presign_upgrade,
                                       upgrade_tx, reclaim_tx, check_release,
                                       effective_repay_deadline,
                                       timelocks_sane)
    btc = btc_node_from(args)
    seq = node_from(args)
    borrower = _read_key(args.borrower_key)

    offer = book_get(args.book, f"/v1/btc/offer/{args.offer}")
    if offer.get("status", "open") != "open":
        refuse(f"that offer is {offer.get('status')}")
    if not R.verify_offer(offer["loan"], offer.get("market", ""),
                          int(offer.get("lots", 1)),
                          offer.get("offer_sig", "")):
        refuse("that offer is not signed by the key it names as the "
               "lender. Refusing to fund anything against it.")
    if offer["loan"].get("oracle_x") == offer["loan"].get("lender_x"):
        refuse("the lender has named their own key as the oracle, so "
               "they could seize the collateral on their own say-so")

    w = A.new_secret()
    _write_key(args.borrower_key + ".w", w)
    d = dict(offer["loan"])
    d.update(borrower_x=A.xonly_pubkey(borrower).hex(), h_w=sha256(w).hex(),
             borrower_prog=R.check_program(args.borrower_prog,
                                           args.borrower_ver),
             borrower_ver=args.borrower_ver)
    loan = loan_from_dict(d)

    # WHAT IS BEING TAKEN, before any of it is committed. This command locks a
    # borrower's Bitcoin against terms they did not write, and it showed them
    # none of it: not the debt, not the price a seizure would be judged
    # against, not the deadline they are held to, and not which oracle's
    # signature can take the collateral. The page shows every one of these
    # before its Borrow button; a command line is not a reason to be told less.
    debt_atoms = int(loan.debt)
    drawn = int(loan.principal) or debt_atoms
    strike = int(loan.strike or 0)
    per_btc = (strike * 100_000_000 // int(loan.price_scale or 100_000)
               if strike else 0)
    print(f"taking offer {args.offer[:12]}: lock "
          f"{fmt_atoms(int(loan.btc_amount), 8)} BTC, receive "
          f"{fmt_atoms(drawn, 8)}, repay {fmt_atoms(debt_atoms, 8)} of "
          f"{loan.debt_asset[:12]}… by Sequentia block "
          f"{effective_repay_deadline(loan)}", file=sys.stderr)
    print(f"  the written deadline is {loan.repay_deadline}; a lender stops "
          f"claiming before it, and a repayment nobody claims releases no "
          f"collateral", file=sys.stderr)
    print(f"  seized below "
          + (f"{fmt_atoms(per_btc, 8)} per BTC" if per_btc else "-- NO STRIKE")
          + f", which {loan.oracle_x[:16]}… and the lender sign together. No "
            f"script tests that price: their two signatures ARE the "
            f"liquidation", file=sys.stderr)
    print(f"  the lender may sweep the collateral at Bitcoin block "
          f"{loan.recover_after}; you may abort it at {loan.abort_after} if no "
          f"principal ever arrives", file=sys.stderr)

    # The upgrade's fee was fixed when this offer was PUBLISHED and can never be
    # raised. If the parent chain has grown busier since, the transaction that
    # starts this loan will not confirm -- and by then the borrower's collateral
    # is committed, so the only way out is to wait for `abort_after`. This is
    # the last moment anybody can decline it, so say so here.
    try:
        from pignus.btc_collateral import (btc_feerate,      # noqa: PLC0415
                                           UPGRADE_VSIZE)
        want = btc_feerate(btc) * UPGRADE_VSIZE
        if int(loan.upgrade_fee) < want * 0.5:
            msg = (f"this offer carries {loan.upgrade_fee} satoshis to pay for "
                   f"moving your collateral into the loan, and the Bitcoin node "
                   f"prices that transaction at about {int(want)}. It cannot be "
                   f"replaced or bumped, so it may never confirm -- and your "
                   f"collateral would then sit until you abort it at Bitcoin "
                   f"block {loan.abort_after}.")
            if not args.force:
                refuse(msg + " Re-run with --force to take it anyway.")
            print("warning: " + msg, file=sys.stderr)
    except SystemExit:
        raise
    except Exception as e:                              # noqa: BLE001
        # The estimate could not be had, so this check did not happen. The
        # constant floor below still does, and that is why this is not a
        # refusal -- but the difference between "your fee was priced against
        # the chain and is fine" and "your fee clears a constant" is the whole
        # value of the check, and saying nothing lets a borrower commit
        # Bitcoin believing the first when they got the second.
        print(f"warning: this node would not price the upgrade "
              f"({type(e).__name__}: {e}), so the offer's fee has only been "
              f"checked against a fixed floor and not against what Bitcoin is "
              f"charging now. That fee can never be raised.", file=sys.stderr)

    problems = timelocks_sane(loan, btc.getblockcount(), seq.getblockcount())
    if problems and not args.force:
        refuse("this offer's deadlines are not safe to enter:\n  - "
               + "\n  - ".join(problems))

    ptxid, pvout, phex = fund_bitcoin(btc, loan, feerate=args.feerate,
                                      broadcast=False)
    dest = bytes.fromhex(btc.getaddressinfo(
        args.reclaim_address or btc.getnewaddress())["scriptPubKey"])
    # The borrower's acceptance: a signature over the offer's id and the four
    # things this taker chose. It is what an oracle later judges a seizure
    # request against, because the lender's own offer signature can be made
    # again over any strike.
    from pignus import btc_relay as R                  # noqa: PLC0415
    take_auth = R.sign_take(borrower, btc_offer_id=args.offer,
                            borrower_x=loan.borrower_x, h_w=loan.h_w,
                            borrower_prog=loan.borrower_prog,
                            borrower_ver=args.borrower_ver,
                            prevault_txid=ptxid, prevault_vout=pvout)
    code, take = book_post(args.book, "/v1/btc/take", {
        "btc_offer_id": args.offer, "borrower_x": loan.borrower_x,
        "take_auth": take_auth,
        "borrower_seq_spk": ("0014" if args.borrower_ver == 0 else "5120")
                            + loan.borrower_prog,
        "borrower_prog": loan.borrower_prog, "borrower_ver": args.borrower_ver,
        "h_w": loan.h_w, "w_seq": 0,
        "prevault_txid": ptxid, "prevault_vout": pvout,
        "prevault_value": str(loan.prevault_value()),
        "btc_height": btc.getblockcount(),
        "reclaim_dest": dest.hex(), "reclaim_fee": args.btc_fee})
    if code != 200:
        refuse(f"the relay refused the take: {take.get('error')}")

    # The lender draws this loan's secret, and its hash goes into both chains'
    # scripts -- so the vault, and the advance signature that moves collateral
    # into it, cannot be built until this lands. Nothing is committed meanwhile.
    reserved = _await_take(args, take["take_id"],
                           lambda r: r.get("payment_hash"), args.wait)
    if not reserved:
        refuse("the lender did not answer in time, and nothing has "
               "been broadcast. Your Bitcoin is untouched.")
    loan = loan_from_dict({**loan_to_dict(loan),
                           "payment_hash": reserved["payment_hash"],
                           "adaptor_point": reserved.get("adaptor_point", "")})
    vault_txid = upgrade_tx(loan, ptxid, pvout).txid()
    presig = presign_upgrade(loan, ptxid, pvout, borrower)
    code, r = book_post(args.book, "/v1/btc/presig",
                        {"take_id": take["take_id"],
                         "upgrade_presig": presig.hex()})
    if code != 200:
        refuse(f"the relay refused the advance signature: "
               f"{r.get('error')}")

    signed = _await_take(args, take["take_id"],
                         lambda r: r.get("status") in ("signed", "disbursed"),
                         args.wait)
    if not signed:
        refuse("the lender did not sign in time, and nothing has "
               "been broadcast. Your Bitcoin is untouched; try again "
               "when their responder is back online.")
    if not check_release(loan, reclaim_tx(loan, vault_txid, 0, dest,
                                          args.btc_fee),
                         bytes.fromhex(signed.get("release_sig")
                                       or signed["adaptor_sig"])):
        refuse("the lender's release does NOT verify against these "
               "terms. Nothing has been broadcast.")
    # The ticket goes to DISK BEFORE the broadcast. Everything in it is already
    # decided -- the pre-vault's txid comes from the signed transaction, not
    # from the node -- and it is the only thing that can ever move this
    # collateral again: `btc-abort` needs it, `btc-claim-principal` needs it,
    # `btc-check` needs it. Broadcasting first means a lost answer or a killed
    # process leaves the borrower's Bitcoin committed to a script they hold no
    # record of, with no way to rebuild one: the relay never sees the funding,
    # and the loan's own secret is in that file.
    tk = loan_to_dict(loan)
    tk.update(prevault_txid=ptxid, prevault_vout=pvout, prevault_hex=phex,
              prevault_value=loan.prevault_value(), upgrade_presig=presig.hex(),
              vault_txid=vault_txid, vault_vout=0, reclaim_dest=dest.hex(),
              reclaim_fee=args.btc_fee,
              release_sig=(signed.get("release_sig")
                           or signed["adaptor_sig"]),
              take_id=take["take_id"], btc_offer_id=args.offer,
              # The lender's own signature over the offer this was taken from.
              # It is what pins the STRIKE, which is in no Bitcoin script and
              # which an oracle judges a seizure by -- so it travels with the
              # loan rather than being fetched from a relay later.
              offer_sig=offer.get("offer_sig", ""),
              offer_lots=int(offer.get("lots", 1) or 1),
              _stage="funding")
    _save_ticket(args, tk)
    txid = btc.sendrawtransaction(phex)
    # ...and the stage moves once it is out. A ticket left at `funding` says
    # the transaction may or may not have been relayed, which is the truth, and
    # `btc-check` reads the chain to settle it.
    tk["_stage"] = "funded"
    _save_ticket(args, tk)
    print(json.dumps({
        "take_id": take["take_id"], "funding_txid": txid, "stage": "funded",
        "note": "the collateral is committed. The lender pays the principal "
                "next; claim it with btc-claim-principal, which is what starts "
                f"the loan. If it never comes, btc-abort after Bitcoin block "
                f"{loan.abort_after}."}, indent=2))
    return 0


def cmd_btc_offer_publish(args):
    """LENDER: publish a cross-chain offer to a relay.

    The offer is signed by the key it names. A relay that accepted an unsigned
    one would let anyone publish in a lender's name, and that lender's own
    responder would pay it out.
    """
    from pignus import adaptor as A
    from pignus import btc_relay as R
    _btc_cfg(args)
    lender = _read_key(args.lender_key)
    lender_x = A.xonly_pubkey(lender).hex()
    if args.oracle_x == lender_x:
        refuse("the lender cannot be their own oracle")
    # The upgrade's fee, priced from a Bitcoin node rather than left at a
    # constant. That transaction is signed in advance by both parties, spends
    # a covenant leaf and sets a final sequence, so it can be neither replaced
    # nor paid for by a child: whatever this offer commits to is the only fee
    # it will ever have. A number chosen when the parent chain was quiet is an
    # offer whose loans cannot be STARTED when it is busy -- the collateral
    # sits in the pre-vault until the borrower aborts, and nobody is paid.
    if args.upgrade_fee is None:
        try:
            from pignus.btc_collateral import upgrade_fee_now   # noqa: PLC0415
            btc = btc_node_from(args)
            args.upgrade_fee = upgrade_fee_now(btc)
            print(f"upgrade fee {args.upgrade_fee} satoshis, priced from the "
                  f"Bitcoin node. It cannot be raised later.", file=sys.stderr)
        except Exception as e:                          # noqa: BLE001
            refuse(
                f"no --upgrade-fee given and the Bitcoin node at "
                f"{args.btc_rpc} would not price one ({type(e).__name__}: "
                f"{e}). That fee is fixed at origination and can never be "
                f"raised, so it must not be guessed: give --upgrade-fee, or "
                f"point --btc-rpc at a node (the example configuration's "
                f"48332 is testnet4).")
    loan = {
        "btc_amount": args.btc_amount, "lender_x": lender_x,
        "oracle_x": args.oracle_x, "recover_after": args.recover_after,
        "debt_asset": args.debt_asset, "debt": args.debt,
        "principal": args.principal or args.debt,
        "repay_deadline": args.repay_deadline, "abort_after": args.abort_after,
        "upgrade_fee": args.upgrade_fee, "d_refund": args.d_refund,
        "lender_prog": R.check_program(args.lender_prog, args.lender_ver),
        "lender_ver": args.lender_ver, "market": args.market or "",
        "strike": args.strike or 0, "price_scale": args.price_scale,
    }
    # The four deadlines, judged the way the relay's expiry sweep and every
    # responder will judge them. An offer past these margins is accepted by
    # nobody: the relay expires it on its next poll and says so only in its
    # own log, while the lender's terminal has printed an offer id.
    try:
        tips = book_get(args.book, "/v1/markets")
        btc_h, seq_h = tips.get("btc_height"), tips.get("height")
    except Exception as e:                              # noqa: BLE001
        refuse(f"could not read the chain tips from {args.book} "
               f"({type(e).__name__}: {e}); the deadlines cannot be judged")
    if btc_h is not None and seq_h is not None:
        from pignus.btc_collateral import loan_from_dict, timelocks_sane
        judged = dict(loan, borrower_x="00" * 32, h_w="00" * 32)
        problems = timelocks_sane(loan_from_dict(judged), int(btc_h), int(seq_h))
        if problems:
            refuse("these deadlines leave no room, judged from Bitcoin block "
                   f"{btc_h} and Sequentia block {seq_h}: " + "; ".join(problems)
                   + ". The rules, in time: d_refund at least two hours "
                   "ahead; abort_after at least a day after d_refund; "
                   "repay_deadline at least a day after d_refund plus the "
                   "two-hour claim margin; recover_after at least a day after "
                   "repay_deadline, and past abort_after.")
    lots = max(1, int(args.lots))
    body = {"loan": loan, "market": args.market or "", "lots": lots,
            "note": args.note or "",
            "offer_sig": R.sign_offer(lender, loan, args.market or "", lots)}
    code, rec = book_post(args.book, "/v1/btc/offers", body)
    if code != 200:
        refuse(f"relay refused the offer: {rec.get('error')}")
    print(json.dumps({"btc_offer_id": rec["btc_offer_id"], "lots": lots,
                      "note": "keep the responder running while this rests"},
                     indent=2))
    return 0


def cmd_payout_program(args):
    """A fresh payout program from the node wallet, in the form the offer and
    take commands want it: `--lender-prog`/`--borrower-prog` and the matching
    `--*-ver`. Nothing else prints one, and a program typed from memory is
    money paid to nobody."""
    from pignus.vault import wallet_payout
    node = node_from(args)
    ver, prog, spk = wallet_payout(node)
    print(json.dumps({"ver": ver, "prog": prog, "spk": spk.hex(),
                      "note": "20 bytes at version 0, 32 at version 1; it is "
                              "baked into addresses and cannot be changed "
                              "afterwards"}, indent=2))
    return 0


def cmd_btc_offer_withdraw(args):
    """LENDER: take an offer down. The coin is untouched -- there is no coin;
    this stops the relay offering terms nobody is answering."""
    from pignus import btc_relay as R
    _btc_cfg(args)
    lender = _read_key(args.lender_key)
    code, r = book_post(args.book,
                        f"/v1/btc/offers/{args.offer}/withdraw",
                        {"sig": R.sign_report(lender, R.WITHDRAW_TAG,
                                              args.offer)})
    if code != 200:
        refuse(f"relay refused: {r.get('error')}")
    print(json.dumps({"btc_offer_id": args.offer, "status": "withdrawn"},
                     indent=2))
    return 0


def cmd_btc_offer_resign(args):
    """LENDER: re-sign an offer a book already holds, changing nothing else.

    An offer's signature is what makes it the lender's, and their own responder
    checks it before acting on any take of that offer. One that stops verifying
    stops every loan under it -- live ones included -- and the collateral behind
    them is never released, silently. There are two ways that happens: a book
    serving a record it altered, and an offer whose stored signature was made
    over a different serialisation of the same terms. This repairs the second;
    nothing repairs the first, and nothing should.

    The book verifies the new signature over the terms it ALREADY holds before
    accepting it, so this can only ever replace a signature with one that
    checks out. It cannot change a term, and nobody without the key can use it.
    """
    from pignus import btc_relay as R
    _btc_cfg(args)
    lender = _read_key(args.lender_key)
    offer = book_get(args.book, f"/v1/btc/offer/{args.offer}")
    loan = offer.get("loan") or {}
    from pignus import adaptor as A
    lender_x = A.xonly_pubkey(lender).hex()
    if loan.get("lender_x") != lender_x:
        refuse(
            f"that offer names lender {str(loan.get('lender_x'))[:16]}... and "
            f"this key is {lender_x[:16]}...: only the lender an offer names "
            "can sign for it")
    market, lots = offer.get("market", ""), int(offer.get("lots", 1))
    if R.verify_offer(loan, market, lots, offer.get("offer_sig", "")):
        print("that offer's signature already verifies under this key; "
              "nothing to repair.", file=sys.stderr)
        return 0
    sig = R.sign_offer(lender, loan, market, lots)
    code, r = book_post(args.book, f"/v1/btc/offers/{args.offer}/resign",
                        {"offer_sig": sig})
    if code != 200:
        refuse(f"relay refused: {r.get('error')}")
    print(json.dumps({"btc_offer_id": args.offer, "resigned": True},
                     indent=2))
    return 0


def cmd_btc_respond(args):
    """LENDER responder: the lender's whole side of every open cross-chain loan.

    Non-custodial in the only sense that matters here: the key never leaves this
    machine and the relay is never trusted for anything -- every offer it serves
    is checked against the lender's own signature before a single move is made
    on it.
    """
    from pignus import adaptor as A
    from pignus import btc_relay as R
    from pignus.btc_collateral import (                 # noqa: PLC0415
        _preimage_from_witness, loan_to_dict)
    from pignus.btc_collateral import (loan_from_dict, reclaim_tx,
                                       lender_release, timelocks_sane,
                                       check_upgrade_presig,
                                       collateral_committed, pay_disbursement,
                                       preimage_from_spend, complete_upgrade,
                                       anchor_safe, claim_repayment,
                                       refund_disbursement, upgrade_tx,
                                       effective_repay_deadline, spend_witness,
                                       btc_feerate, UPGRADE_VSIZE)
    _btc_cfg(args)
    if not args.lender_key:
        refuse("--lender-key, or a config file naming one, is required")
    lender = _read_key(args.lender_key)
    lender_x = A.xonly_pubkey(lender).hex()
    state = _ResponderState(args.state or (args.lender_key + ".state.json"),
                            exclusive=True)

    seq_node = (node_from(args)
                if (args.rpc_user or args.rpc_password or args.rpc_cookie)
                else None)
    btc_node = (btc_node_from(args)
                if (args.btc_rpc_user or args.btc_rpc_password
                    or args.btc_rpc_cookie) else None)
    # The paying wallet, checked now rather than at the first disbursement. A
    # wallet loaded by hand is gone after a node restart, and the first sign
    # of that used to be a principal "in flight" that had never gone out,
    # with a borrower's collateral committed behind it.
    if seq_node is not None:
        try:
            seq_node.getwalletinfo()
        except Exception as e:                          # noqa: BLE001
            refuse(f"the Sequentia wallet {args.rpc_wallet or '(default)'} "
                   f"cannot be reached ({type(e).__name__}: {e}). It is the "
                   f"one that pays every principal; load it on the node -- and "
                   f"name it in the node's `wallet=` configuration so a restart "
                   f"reloads it -- then start again.")
    # A responder without BOTH nodes can still draw secrets and sign releases,
    # and can do nothing else: `disburse_pass`, `upgrade_pass` and `claim_pass`
    # all return immediately. That is the worst possible half-working state --
    # borrowers commit collateral against releases this key signed, and nobody
    # ever pays a principal or claims a repayment, so every one of those loans
    # ends at its timeout with the lender sweeping collateral for a loan that
    # never started. Refuse to start instead, and say which is missing.
    missing = [n for n, v in (("--rpc (the Sequentia node)", seq_node),
                              ("--btc-rpc (the Bitcoin node)", btc_node))
               if v is None]
    if missing and not args.sign_only:
        refuse(
            "a responder needs both chains: " + ", ".join(missing) + " is not "
            "configured. Without it this key would sign releases and then "
            "never pay a principal or claim a repayment, and every loan taken "
            "against it would end at its timeout. Configure both, or pass "
            "--sign-only if you really mean to run the signing half alone.")

    disowned = set()

    def ours(offer):
        """An offer is this responder's only if this key signed it. The relay
        is a message carrier, not an authority.

        An offer that NAMES this key and whose signature does not check out is
        reported, once, and loudly. Every pass below skips a take whose offer
        this returns False for, so a signature that stops verifying is
        indistinguishable from having no work to do -- and if that offer has a
        live loan under it, the lender stops claiming repayments and stops
        publishing secrets, which is a borrower's collateral held hostage with
        nothing said. There are only two ways to get here: a relay serving a
        record it altered, or a change to what the signature covers. Both need
        a person, and neither should look like an idle night.
        """
        loan = offer.get("loan") or {}
        if loan.get("lender_x") != lender_x:
            return False
        lots = int(offer.get("lots", 1) or 1)
        if R.verify_offer(loan, offer.get("market", ""), lots,
                          offer.get("offer_sig", "")):
            # ...and served under ITS OWN id, which is the hash of what was
            # signed. The lot cap is counted per offer id, so one signed offer
            # served twice under two ids was two offers of `lots` each, and a
            # relay could have this key pay two principals for an offer of
            # one. An id that is not the content's hash is not this offer.
            want = R.offer_id(loan, offer.get("market", ""), lots)
            if str(offer.get("btc_offer_id", "")).lower() == want:
                return True
            oid = offer.get("btc_offer_id", "?")
            if oid not in disowned:
                disowned.add(oid)
                print(f"offer {oid} carries this key's signature but is served "
                      f"under an id that is not the hash of its terms "
                      f"({want}), so every take of it is being skipped. A "
                      f"relay serving one offer under two ids would be a "
                      f"relay counting its lots twice; a record from before "
                      f"ids were that hash looks the same, and the safe answer "
                      f"is the same. If it is one of yours and still wanted, "
                      f"withdraw it and publish it again.", file=sys.stderr)
            return False
        oid = offer.get("btc_offer_id", "?")
        if oid not in disowned:
            disowned.add(oid)
            print(f"offer {oid} names this key but its signature does not "
                  f"verify, so every take of it is being skipped. Either this "
                  f"relay altered the record, or it was signed over something "
                  f"different from what is being served. Any live loan under "
                  f"it is NOT being claimed or settled.", file=sys.stderr)
        return False

    # A taker chooses four things and nothing else. Everything else about a
    # loan is what the lender signed, so the loan acted on here is REBUILT from
    # the verified offer rather than read out of the relay's copy -- otherwise
    # a relay could hand back a take whose principal was a hundred times the
    # offer's and this responder would pay it.
    TAKER_FIELDS = ("borrower_x", "h_w", "borrower_prog", "borrower_ver")

    def loan_for(offer, tk, have=None):
        d = dict(offer["loan"])
        got = dict(tk.get("loan") or {})
        for k in TAKER_FIELDS:
            d[k] = got.get(k, "")
        # The payment hash is this responder's own, from its state, not the
        # relay's: it is the hash of the secret in the state file.
        secret = (have or {}).get("t")
        if secret:
            d["payment_hash"] = _sha256(bytes.fromhex(secret))
            # ...but if the relay is holding a DIFFERENT hash for this take,
            # the secret in this state file is not this loan's. That happens
            # when a second responder on the same key drew its own secret and
            # lost the race, and acting on it pays a second principal into an
            # address that does not depend on the secret at all. Stop.
            theirs = got.get("payment_hash")
            if theirs and theirs != d["payment_hash"]:
                raise ValueError(
                    "the secret recorded for this take does not match the "
                    "hash the relay holds for it: another responder reserved "
                    "it. Refusing to act on it.")
        elif got.get("payment_hash"):
            d["payment_hash"] = got["payment_hash"]
        loan = loan_from_dict(d)
        if not loan.borrower_x or not loan.h_w or not loan.borrower_prog:
            raise ValueError("that take names no borrower, no commitment or no "
                             "payout program")
        R.check_program(loan.borrower_prog, loan.borrower_ver)
        for k, v in got.items():
            if k in TAKER_FIELDS or k in ("payment_hash", "adaptor_point"):
                continue
            if k in d and str(d[k]) != str(v):
                raise ValueError(
                    f"the relay's copy of this take differs from the offer "
                    f"that was signed, at {k}: refusing to act on it")
        return loan

    def btc_height():
        """The parent chain's own tip. A height a client sent us is a height an
        attacker chose, and every cross-chain margin is measured against it."""
        return int(btc_node.getblockcount()) if btc_node else 0

    def seq_height():
        return int(seq_node.getblockcount()) if seq_node else 0

    heights_missing_said = set()

    def heights_known(take_id, doing):
        """False, said once per take, when a deadline check would be made
        against height 0.

        `timelocks_sane` measures every margin from the chains' tips, and a
        tip of 0 -- which is what a responder with no node reports -- puts
        every deadline thousands of blocks in the future. A sign-only
        responder without nodes therefore passed a loan whose abort_after
        was already behind it, and a principal paid into that loan is one
        the borrower keeps after aborting the collateral. No nodes, no
        deadline check; no deadline check, no signature."""
        if btc_node is not None and seq_node is not None:
            return True
        if take_id not in heights_missing_said:
            heights_missing_said.add(take_id)
            print(f"take {take_id}: not {doing} -- this responder has no "
                  f"{'Bitcoin' if btc_node is None else 'Sequentia'} node to "
                  f"read a height from, so the loan's deadlines cannot be "
                  f"checked. Give it read-only RPC to both chains; a wallet is "
                  f"not needed for that.", file=sys.stderr)
        return False

    def offers_by_id():
        out = {}
        for o in book_get(args.book, "/v1/btc/offers?status=all")["offers"]:
            out[o["btc_offer_id"]] = o
        return out

    missing_said = set()

    def our_takes(offers, status=None):
        """Every take of an offer THIS key signed, with this responder's own
        record of it.

        The passes below iterate this rather than asking the relay for a
        status, because a status is a thing the borrower can move: they report
        their own steps, and a pass that scans by status is a pass a borrower
        can steer away from their own loan -- leaving the lender's principal
        paid and the collateral never taken into the vault. What a pass acts on
        is the state file, which only this responder writes, and the two
        chains, which nobody rewrites.

        `status` narrows to a step of the HANDSHAKE, where the relay's word is
        all there is and moving it can only cost the borrower their own loan.
        """
        out = []
        served = {}
        for tk in book_get(args.book, "/v1/btc/takes")["takes"]:
            served[tk.get("take_id")] = tk
            if status is not None and tk.get("status") != status:
                continue
            offer = offers.get(tk.get("btc_offer_id"))
            if not offer or not ours(offer):
                continue
            out.append((offer, tk, state.get(tk["take_id"])))
        # Every pass enumerates through the relay, so a take the relay
        # stopped serving -- dropped, pruned, re-keyed -- stopped being acted
        # on, and "not served" looked exactly like "no work": no refund of an
        # unclaimed principal, no re-claim after a reorg, no secret published.
        # Said, once per take, because the money behind it is this key's.
        # A wait recorded on a take the relay no longer serves as open --
        # expired, aborted, gone -- with nothing of this key's money behind
        # it is a row, not a person needed: cleared, so a routine refusal
        # near an offer's deadline does not alarm the timer for a month.
        # The relay never moves a take it merely stopped answering; it stays
        # `requested` until pruned, a month on. So a take still at the very
        # start whose OFFER is no longer open is over too, and so is one
        # that has sat there past the relay's own lot-holding window.
        # Judged over EVERY take the relay serves, not only those whose offer
        # verifies under this key today: whether a take is over is a fact
        # about the chain and this key's own record, and an offer that has
        # stopped verifying -- served under an old id, say -- must not keep
        # a wait alive on a take that nothing will ever act on.
        now_ts = int(time.time())
        seq_now = seq_height() if seq_node is not None else 0
        ended = set()
        over = set()
        for take_id, tk in served.items():
            _h = state.get(take_id) or {}
            o = offers.get(tk.get("btc_offer_id")) or {}
            st = str(tk.get("status", ""))
            d_refund = int((tk.get("loan") or {}).get("d_refund") or 0)
            if st not in ("requested", "reserved", "pending", "signed",
                          "disbursed", "live"):
                ended.add(take_id)
            elif st in ("requested", "reserved") and (
                    str(o.get("status", "open")) != "open"
                    or now_ts - int(tk.get("created") or now_ts) > 3600):
                ended.add(take_id)
            elif st in ("pending", "signed") and not _h.get("disbursement_txid") \
                    and not _h.get("disbursing") and seq_now and d_refund \
                    and seq_now > d_refund:
                # The principal's own refund window has opened and none was
                # paid: this key will pay nothing into it now, and the
                # borrower takes their collateral back at their own height.
                # Nothing here is a person's to do -- and nothing here is a
                # PASS's to do either, so the take leaves the list: left in
                # it, the disburse pass would re-check the deadlines every
                # minute and record the same refusal again, which is a wait
                # the timer reads as a person needed, for ever.
                ended.add(take_id)
                over.add(take_id)
        # A take written off is not this key's to act on, in any pass; nor is
        # one that is over.
        out = [(o, tk, h) for o, tk, h in out
               if not h.get("written_off") and tk["take_id"] not in over]
        for take_id, have in state.items():
            if have.get("waiting") and not have.get("disbursing") \
                    and not have.get("disbursement_txid") \
                    and (take_id not in served or take_id in ended):
                state.set(take_id, waiting=None)
        for take_id, have in state.items():
            if take_id in served or take_id in missing_said:
                continue
            if have.get("secret_published") or have.get("principal_refunded"):
                continue            # finished; the book pruning it is routine
            if have.get("disbursement_txid") or have.get("disbursing"):
                missing_said.add(take_id)
                print(f"take {take_id}: this key paid a principal for it and "
                      f"the relay no longer serves it. Nothing here will act "
                      f"on it until it is served again; its terms are in the "
                      f"state file under `loan` for the btc-* commands.",
                      file=sys.stderr)
        return out

    def lots_committed(offers, except_take=None):
        """How many lots of each offer this responder has already put money
        behind, counted from its OWN state file.

        `lots` is the one term that bounds a lender's total exposure, so the
        count has to come from the one record a relay cannot edit. Enumerating
        the relay's take list instead makes the cap the relay's to set: a take
        it omits, prunes or never serves back is a lot this count does not see,
        and the principal behind it is spent again.

        `btc_offer_id` is written into the state the first time a take is
        touched. A record from before that is counted through the relay's copy
        as a fallback, which is worth less but is better than not counting it.
        """
        used = {}
        by_id = {tk["take_id"]: tk for _o, tk, _h in our_takes(offers)}
        for take_id, have in state.items():
            if take_id == except_take:
                continue
            if not (have.get("disbursement_txid") or have.get("disbursing")):
                continue
            oid = have.get("btc_offer_id") \
                or (by_id.get(take_id) or {}).get("btc_offer_id")
            if oid:
                used[oid] = used.get(oid, 0) + 1
        return used

    # What each report calls its fields on the wire, against the canonical names
    # the signature covers. The two differ because the wire names say which
    # transaction is meant and the signed names say what is being asserted;
    # keeping one mapping here is what stops them drifting apart.
    REPORT_BODY = {
        "/v1/btc/hash": {"payment_hash": "payment_hash",
                         "adaptor_point": "adaptor_point"},
        "/v1/btc/disbursed": {"txid": "disbursement_txid",
                              "vout": "disbursement_vout"},
        "/v1/btc/upgraded": {"txid": "upgrade_txid"},
        "/v1/btc/claimed": {"txid": "claim_txid", "secret": "secret_t"},
        "/v1/btc/refunded": {"txid": "refund_txid"},
        # The release carries its own names on the wire already.
        # `adaptor_sig` is what the wire has always called this field; what it
        # carries is an ordinary release signature. Both names are sent so a
        # relay of either vintage understands it, and the signature covers the
        # canonical name.
        "/v1/btc/adaptor": {"adaptor_point": "adaptor_point",
                            "payment_hash": "payment_hash",
                            "adaptor_sig": "adaptor_sig"},
    }

    def report(tag, take_id, path, **fields):
        names = REPORT_BODY[path]
        body = {names[k]: v for k, v in fields.items()}
        body["take_id"] = take_id
        body["auth"] = R.sign_report(lender, tag, take_id, **fields)
        code, r = book_post(args.book, path, body)
        if code != 200:
            print(f"take {take_id}: the relay refused a {path.rsplit('/', 1)[1]} "
                  f"report: {r.get('error')}", file=sys.stderr)
        return code, r

    def wallet_short(loan):
        """What the paying wallet lacks for this loan's principal, or None.

        Asked BEFORE a release is signed, because a signature is a promise:
        the borrower funds collateral against it, and a wallet that cannot
        then pay leaves that collateral sitting until they abort.
        """
        if seq_node is None:
            return None
        need = int(loan.principal) or int(loan.debt)
        try:
            bal = seq_node.getbalance()
            labels = seq_node.dumpassetlabels()
        except Exception as e:                          # noqa: BLE001
            return f"wallet: {type(e).__name__}: {e}"
        by_hex = {v: k for k, v in labels.items()}
        amt = bal.get(loan.debt_asset)
        if amt is None:
            amt = bal.get(by_hex.get(loan.debt_asset, ""), 0)
        have_atoms = int(Decimal(str(amt)) * 100_000_000)
        if have_atoms < need:
            return (f"wallet: holds {have_atoms} atoms of the debt asset, the "
                    f"principal is {need}")
        return None

    def refused(tk, why):
        """Say why a take is not being answered, once per reason, and RECORD
        it: a refusal that lives only in the journal is one the status
        command and the timer's check cannot see, while the offer keeps
        attracting takes that fail the same way."""
        have = state.get(tk["take_id"])
        if have.get("waiting") != why:
            state.set(tk["take_id"], waiting=why,
                      btc_offer_id=tk.get("btc_offer_id"))
            print(f"take {tk['take_id']}: {why}", file=sys.stderr)

    def hash_pass(offers):
        """Draw this loan's secret and publish its hash.

        One secret per LOAN. Sharing one across an offer's takes would let any
        borrower's repayment release every other borrower's collateral, and the
        borrower cannot even derive their own vault until this lands: its
        address commits to the hash.
        """
        n = 0
        for offer, tk, have in our_takes(offers, status="requested"):
            t = (bytes.fromhex(have["t"]) if have.get("t") else A.new_secret())
            try:
                loan = loan_for(offer, tk, {"t": t.hex()})
            except ValueError as e:
                refused(tk, f"terms: {e}")
                continue
            if not heights_known(tk["take_id"], "answering"):
                continue
            problems = timelocks_sane(loan, btc_height(), seq_height())
            if problems:
                refused(tk, "not answering -- " + "; ".join(problems))
                continue
            # The OFFER, in this key's own record. Everything that bounds what
            # this lender may spend is counted from the state file, and a take
            # whose offer only the relay knows is a lot that count cannot see.
            state.set(tk["take_id"], t=t.hex(), payment_hash=_sha256(t),
                      btc_offer_id=tk.get("btc_offer_id"))
            code, r = report(R.HASH_TAG, tk["take_id"], "/v1/btc/hash",
                             payment_hash=_sha256(t),
                             adaptor_point=A.point(t).hex())
            if code == 200:
                print(f"reserved take {tk['take_id']}", file=sys.stderr); n += 1
        return n

    def prevault_of(tk, have, loan):
        """The pre-vault outpoint and advance signature THIS key verified.

        `sign_pass` checked the advance signature against a particular outpoint
        before publishing a release, and everything after it -- paying the
        principal, broadcasting the move -- rests on that check. Reading either
        back out of the relay is reading it from the party this code is written
        never to believe: one that edited them after the release was published
        would have the lender pay a principal against a pre-vault nobody
        checked, and broadcast a move built on it.

        So it comes from the state file. Where the state file predates that
        record, the relay's copy is not refused but PROVED: `vault_txid` is
        this key's own, written by `sign_pass`, and it is the txid of the move
        out of one particular outpoint -- so an outpoint that reproduces it is
        the one that was signed against, whoever handed it over. The advance
        signature is re-checked against it either way. What is proved is then
        recorded, so it is proved once.

        Raises ValueError when nothing here can establish it.
        """
        theirs = (str(tk.get("prevault_txid", "")),
                  int(tk.get("prevault_vout", 0) or 0),
                  str(tk.get("upgrade_presig", "")))
        mine = (have.get("prevault_txid"), have.get("prevault_vout"),
                have.get("upgrade_presig"))
        if all(x is not None and x != "" for x in mine):
            txid, vout, presig = str(mine[0]), int(mine[1]), str(mine[2])
            if theirs != (txid, vout, presig):
                raise ValueError(
                    f"the relay's copy of this take names pre-vault "
                    f"{theirs[0][:16]}...:{theirs[1]} and this key signed "
                    f"against {txid[:16]}...:{vout}. Refusing to act on it")
            return txid, vout, presig
        txid, vout, presig = theirs
        vault = have.get("vault_txid")
        if not (txid and presig and vault):
            raise ValueError(
                "this responder has no record of the pre-vault it signed "
                "against and cannot rebuild one, so nothing here can check "
                "what it is being asked to spend on")
        try:
            rebuilt = upgrade_tx(loan, txid, vout).txid()
        except Exception as e:                          # noqa: BLE001
            raise ValueError(f"the pre-vault this relay names does not build a "
                             f"move at all ({type(e).__name__}: {e})") from None
        if rebuilt != vault:
            raise ValueError(
                f"the relay names pre-vault {txid[:16]}...:{vout}, whose move "
                f"would be {rebuilt[:16]}..., and this key signed a release "
                f"over {str(vault)[:16]}.... Refusing to act on it")
        if not check_upgrade_presig(loan, txid, vout, bytes.fromhex(presig)):
            raise ValueError("the advance signature this relay holds does not "
                             "move that collateral into this loan")
        state.set(tk["take_id"], prevault_txid=txid, prevault_vout=vout,
                  upgrade_presig=presig)
        print(f"take {tk['take_id']}: recorded the pre-vault this key signed "
              f"against, proved against its own release", file=sys.stderr)
        return txid, vout, presig

    def sign_pass(offers):
        n = 0
        used = lots_committed(offers)
        for offer, tk, have in our_takes(offers, status="pending"):
            if not have.get("t"):
                refused(tk, "no secret recorded for it; it was reserved by "
                            "another responder or the state file was lost")
                continue
            t = bytes.fromhex(have["t"])
            try:
                loan = loan_for(offer, tk, {"t": t.hex()})
            except ValueError as e:
                refused(tk, f"terms: {e}")
                continue
            # The lot cap, HERE and not only at disbursement: a release signed
            # for a lot this key has already funded is a borrower who commits
            # collateral and waits until they abort.
            lots = int(offer.get("lots", 1) or 1)
            if used.get(tk.get("btc_offer_id"), 0) >= lots:
                refused(tk, f"lots: offer {tk.get('btc_offer_id')} has already "
                            f"funded all {lots} of its lots; not signing")
                continue
            short = wallet_short(loan)
            if short:
                refused(tk, short + "; not signing a release the wallet could "
                                    "not honour")
                continue
            # The pre-vault THIS key already verified, when it has one. A
            # take served `pending` again after this key signed and paid --
            # with a fresh pre-vault and a presig for it, which the borrower
            # can always produce -- used to be re-signed here, and the record
            # every later pass rests on overwritten with the new outpoint: the
            # paid principal then watched a pre-vault nobody had funded, and
            # the funded one was never vaulted. Once recorded, the pre-vault
            # is this key's own fact and the relay's copy must agree with it.
            mine = (have.get("prevault_txid"), have.get("prevault_vout"),
                    have.get("upgrade_presig"))
            if all(x not in (None, "") for x in mine):
                theirs = (str(tk.get("prevault_txid", "")),
                          int(tk.get("prevault_vout", 0) or 0),
                          str(tk.get("upgrade_presig", "")))
                if theirs != (str(mine[0]), int(mine[1]), str(mine[2])):
                    refused(tk, "the relay now serves this take with a "
                                "different pre-vault or advance signature "
                                "from the one this key signed against; not "
                                "signing again. The recorded release stands.")
                    continue
                pv_txid, pv_vout, presig = str(mine[0]), int(mine[1]), str(mine[2])
            else:
                pv_txid, pv_vout, presig = (str(tk["prevault_txid"]),
                                            int(tk["prevault_vout"]),
                                            str(tk["upgrade_presig"]))
            if not check_upgrade_presig(loan, pv_txid, pv_vout,
                                        bytes.fromhex(presig)):
                refused(tk, "the advance signature does not move that "
                            "collateral into this loan; not signing")
                continue
            if not heights_known(tk["take_id"], "signing"):
                continue
            problems = timelocks_sane(loan, btc_height(), seq_height())
            if problems:
                refused(tk, "not signing -- " + "; ".join(problems))
                continue
            if have.get("waiting"):
                state.set(tk["take_id"], waiting=None)
            # The vault's address depends on the payment hash, so it is only
            # knowable now. Deriving it here rather than reading the relay's
            # copy is what stops a relay making every release useless.
            vault_txid = upgrade_tx(loan, pv_txid, pv_vout).txid()
            rtx = reclaim_tx(loan, vault_txid, 0,
                             bytes.fromhex(tk["reclaim_dest"]),
                             int(tk.get("reclaim_fee", 3000)))
            sig = lender_release(loan, lender, rtx)
            # Written BEFORE the report, not after it, and it carries
            # everything this pass VERIFIED: the pre-vault outpoint the advance
            # signature was checked against, and that signature.
            #
            # Two reasons for the order. A relay can store the release and lose
            # the answer -- a proxy restart, a timeout, a reset -- and this
            # key's record of having signed is what every later pass acts on;
            # without it the responder never disburses, the borrower's
            # collateral sits until they abort it, and nothing says why.
            # Re-sending a release the relay already holds is harmless: the
            # same signature over the same transaction, and the status ratchet
            # only moves forward. And reading the outpoint back out of the
            # relay afterwards would be reading it from the one party this code
            # is written never to believe.
            # ...and the loan itself, as this key rebuilt it from the signed
            # offer, with the borrower's acceptance. The state file is the
            # one record a relay cannot edit, and until now it held the
            # secret and the outpoints but not the terms -- so a relay that
            # dropped one take after the principal was paid took with it the
            # only copy of what this key had agreed to.
            state.set(tk["take_id"], vault_txid=vault_txid,
                      payment_hash=_sha256(t),
                      prevault_txid=pv_txid, prevault_vout=pv_vout,
                      upgrade_presig=presig,
                      loan=loan_to_dict(loan),
                      take_auth=str(tk.get("take_auth") or ""),
                      reclaim_dest=str(tk.get("reclaim_dest") or ""),
                      reclaim_fee=int(tk.get("reclaim_fee", 3000) or 3000))
            code, r = report(R.ADAPTOR_TAG, tk["take_id"], "/v1/btc/adaptor",
                             adaptor_point=A.point(t).hex(),
                             payment_hash=_sha256(t), adaptor_sig=sig.hex())
            if code == 200:
                print(f"signed take {tk['take_id']}", file=sys.stderr); n += 1
            else:
                # The release is recorded either way, above. This says the
                # RELAY did not take it, which is a message to re-send rather
                # than a loan to abandon: the next pass finds the take still
                # `pending` and offers the same signature again.
                print(f"take {tk['take_id']}: the relay would not take this "
                      f"release ({r.get('error')}); it is recorded here and "
                      f"will be offered again", file=sys.stderr)
        return n

    def disburse_pass(offers):
        """Pay the principal of every loan whose collateral is committed.

        The only pass that spends without a deadline forcing it, so every
        refusal here is deliberate: the loan is rebuilt from the offer this key
        signed, its deadlines are re-checked against both chains' tips as they
        are NOW rather than as they were when it was signed, the collateral is
        read off the parent chain, and the offer's own lot count is enforced
        from this responder's records rather than the relay's.
        """
        if not (seq_node and btc_node):
            return 0
        n = 0
        used = lots_committed(offers)
        # Every take this responder signed a release for and has not yet paid.
        # `vault_txid` is written by sign_pass, so it is this key's own record
        # of having committed to the loan -- not a status a borrower can move.
        for offer, tk, have in our_takes(offers):
            if have.get("disbursement_txid"):
                continue                       # already paid; never pay twice
            # THE SECRET, first. A principal is paid into an address derived
            # from `h_w`, which does not depend on `t` at all -- so a responder
            # that has lost its state file would fund the loan perfectly well
            # and then be unable to claim the repayment, because the repayment
            # address does depend on `t`. The borrower repays on time, nobody
            # claims it, they take it back at their own deadline, and the
            # lender sweeps the collateral at the Bitcoin timeout. The borrower
            # did everything right and loses the difference.
            #
            # `t` is unrecoverable -- it is random, and it is only ever drawn
            # for a take the relay still calls `requested` -- so there is no
            # repairing this later. The only safe move is to refuse and say so,
            # which is what `sign_pass` already does one step earlier.
            if not have.get("t"):
                if have.get("waiting") != "no-secret":
                    state.set(tk["take_id"], waiting="no-secret")
                    print(f"take {tk['take_id']}: no secret recorded for it, "
                          "so its repayment could never be claimed. Not "
                          "disbursing. Restore this key's state file, or leave "
                          "this take alone: the borrower can abort their "
                          "collateral at their own deadline.", file=sys.stderr)
                continue
            if not have.get("vault_txid"):
                continue                       # not signed by THIS key yet
            try:
                loan = loan_for(offer, tk, have)
            except ValueError as e:
                print(f"take {tk['take_id']}: {e}", file=sys.stderr)
                continue
            if have.get("disbursing"):
                # A principal was sent and the answer was lost. Find it, and
                # look in BOTH places a paid principal can be: still sitting
                # unspent at the address, or already CLAIMED by the borrower --
                # in which case the coin is gone from the UTXO set and only the
                # borrower's own report says where it was. Searching one of the
                # two and concluding "never sent" is how a responder that was
                # slow to restart pays a second principal into an address the
                # borrower already holds the secret to.
                try:
                    found = _find_output(seq_node, have["disbursing"],
                                         asset=loan.debt_asset,
                                         amount=int(loan.principal) or int(loan.debt))
                except ScanFailed as e:
                    # NOT "never sent". Coming back next pass costs a minute;
                    # reading it as "nothing is there" and clearing the flag
                    # would cost a second principal.
                    print(f"take {tk['take_id']}: cannot check whether the "
                          f"disbursement went out ({e}); leaving it alone",
                          file=sys.stderr)
                    continue
                said = str(tk.get("principal_claim_txid") or "")
                if not found and said:
                    # The borrower's signed report names the transaction that
                    # CLAIMED it. Its input is the disbursement, so reading it
                    # back names the outpoint that was paid.
                    # The hint is the BORROWER's word, and a borrower who
                    # claimed a principal that this key never recorded -- a
                    # crash between the send and the write -- could name any
                    # transaction of theirs and have the first spent input
                    # recorded as the disbursement. Only an input whose
                    # witness carries THIS loan's secret spent this loan's
                    # disbursement; nothing else is believed.
                    try:
                        raw = seq_node.getrawtransaction(said, True)
                        for vin in raw.get("vin", []):
                            w = _preimage_from_witness(
                                vin.get("txinwitness") or [],
                                expect_hash=loan.h_w)
                            if w is not None:
                                found = (vin["txid"], int(vin["vout"]))
                                break
                    except Exception:                   # noqa: BLE001
                        pass
                if found:
                    state.set(tk["take_id"], disbursing=None,
                              disbursement_txid=found[0],
                              disbursement_vout=found[1])
                    code, _r = report(R.DISBURSED_TAG, tk["take_id"],
                                      "/v1/btc/disbursed",
                                      txid=found[0], vout=found[1])
                    state.set(tk["take_id"], disbursed_reported=(code == 200))
                    print(f"take {tk['take_id']}: recovered a disbursement that "
                          f"was sent but not recorded: {found[0]}",
                          file=sys.stderr)
                else:
                    print(f"take {tk['take_id']}: a disbursement was started "
                          "and cannot be found -- not unspent at "
                          f"{have['disbursing']}, and no claim of it reported. "
                          "It will NOT be sent again until somebody says so. "
                          "Check the wallet, then: `pignus-cli "
                          f"btc-responder-clear --take {tk['take_id']}`, which "
                          "takes this responder's own lock so it cannot run "
                          "against a live one and re-checks the chain first.",
                          file=sys.stderr)
                continue
            # An offer is for so many lots, and that number is the whole of a
            # lender's exposure to it. Counting from this responder's own
            # records is the point: the relay counts too, and the relay is the
            # party this code is written never to believe.
            lots = int(offer.get("lots", 1) or 1)
            if used.get(tk.get("btc_offer_id"), 0) >= lots:
                if have.get("waiting") != "lots":
                    state.set(tk["take_id"], waiting="lots")
                    print(f"take {tk['take_id']}: offer "
                          f"{tk.get('btc_offer_id')} has already funded all "
                          f"{lots} of its lots; not disbursing", file=sys.stderr)
                continue
            # The borrower chooses when to fund, and nothing makes them do it
            # promptly. Deadlines that were safe when this take was signed can
            # be behind us by the time the collateral appears, and disbursing
            # into a window that has already closed is how a lender pays for a
            # loan that can never start.
            late = timelocks_sane(loan, btc_height(), seq_height())
            if late:
                if have.get("waiting") != "; ".join(late):
                    state.set(tk["take_id"], waiting="; ".join(late))
                    print(f"take {tk['take_id']}: not disbursing -- "
                          + "; ".join(late), file=sys.stderr)
                continue
            # Can the loan still be STARTED? The upgrade's fee was fixed when
            # the offer was published and can never be raised, and the parent
            # chain has had until now to get busier. Disbursing into a loan
            # whose upgrade will not confirm is how a lender pays a principal
            # for collateral that never reaches the vault: the borrower claims
            # it, nobody can vault the collateral, and they abort it at their
            # own deadline. This is the last moment that can be refused.
            try:
                want = btc_feerate(btc_node) * UPGRADE_VSIZE
                if int(loan.upgrade_fee) < want * 0.5:
                    msg = (f"the upgrade fee is {loan.upgrade_fee} satoshis and "
                           f"this node prices that move at about {int(want)}. "
                           f"It cannot be replaced or bumped, so the loan could "
                           f"not be started. Not disbursing.")
                    if have.get("waiting") != "upgrade-fee":
                        state.set(tk["take_id"], waiting="upgrade-fee")
                        print(f"take {tk['take_id']}: {msg}", file=sys.stderr)
                    continue
            except Exception as e:                      # noqa: BLE001
                # No estimate, so this check did not happen: the constant floor
                # still applied, which is why this is not a refusal. But a
                # principal is about to be paid into a loan whose start cannot
                # be replaced or bumped, and the difference between a fee
                # priced against the chain and one that merely clears a
                # constant is the whole of the check. Recorded rather than
                # passed over, so `btc-responder-status` shows a lender that
                # their Bitcoin node has stopped answering.
                why = f"btc-feerate-unavailable: {type(e).__name__}: {e}"
                if have.get("last_error") != why:
                    state.set(tk["take_id"], last_error=why)
                    print(f"take {tk['take_id']}: {why} -- the upgrade fee has "
                          f"only been checked against a fixed floor",
                          file=sys.stderr)
            # The pre-vault this key checked the advance signature against,
            # not the one the relay is serving today.
            try:
                pv_txid, pv_vout, _presig = prevault_of(tk, have, loan)
            except ValueError as e:
                if have.get("waiting") != str(e):
                    state.set(tk["take_id"], waiting=str(e))
                    print(f"take {tk['take_id']}: {e}", file=sys.stderr)
                continue
            ok, why = collateral_committed(btc_node, loan, pv_txid, pv_vout,
                                           min_conf=args.disburse_conf)
            if not ok:
                # Say why, once, rather than skipping in silence: an operator
                # watching a loan that will not move needs to know whether they
                # are waiting for a confirmation or looking at a bad take.
                if have.get("waiting") != why:
                    state.set(tk["take_id"], waiting=why)
                    print(f"take {tk['take_id']}: {why}", file=sys.stderr)
                continue
            if have.get("waiting"):
                state.set(tk["take_id"], waiting=None)
                print(f"take {tk['take_id']}: no longer waiting; disbursing",
                      file=sys.stderr)
            # Written BEFORE the send: the gap between paying and recording is
            # where a crash would otherwise cost a second principal.
            # The offer id goes with it, so a lot this key has committed is
            # countable from the state alone even for a take reserved by an
            # older version that did not record it.
            state.set(tk["take_id"],
                      disbursing=loan.disbursement_spk().hex(),
                      btc_offer_id=tk.get("btc_offer_id"))
            used[tk.get("btc_offer_id")] = used.get(tk.get("btc_offer_id"), 0) + 1
            try:
                txid, vout = pay_disbursement(seq_node, loan,
                                              fee_asset=args.fee_asset or None)
            except NotBroadcast as e:
                # Nothing went out: the wallet is not loaded, could not fund
                # it, or could not sign it. The flag is cleared and the
                # reason recorded, and the next pass tries again -- a person
                # is needed only if it keeps failing, which the status
                # command's waiting clock will say.
                state.set(tk["take_id"], disbursing=None,
                          waiting=f"wallet: {e}", last_error=str(e))
                used[tk.get("btc_offer_id")] -= 1
                print(f"disburse {tk['take_id']}: not sent -- {e}",
                      file=sys.stderr)
                continue
            except Exception as e:                      # noqa: BLE001
                # The flag STAYS. pay_disbursement broadcasts before it reads
                # the transaction back, so a failure here can mean the payment
                # went out and the answer was lost -- and clearing the flag
                # would pay it again on the next pass. The recovery below looks
                # for the payment instead.
                state.set(tk["take_id"], last_error=f"{type(e).__name__}: {e}")
                print(f"disburse {tk['take_id']}: {type(e).__name__}: {e}; "
                      "checking whether the payment went out anyway",
                      file=sys.stderr)
                try:
                    found = _find_output(seq_node, loan.disbursement_spk().hex(),
                                         asset=loan.debt_asset,
                                         amount=int(loan.principal) or int(loan.debt))
                except ScanFailed as e:
                    print(f"take {tk['take_id']}: and the chain could not be "
                          f"checked either ({e}). The 'disbursing' flag stays; "
                          f"the next pass looks again.", file=sys.stderr)
                    continue
                if found:
                    state.set(tk["take_id"], disbursing=None,
                              disbursement_txid=found[0],
                              disbursement_vout=found[1])
                    report(R.DISBURSED_TAG, tk["take_id"], "/v1/btc/disbursed",
                           txid=found[0], vout=found[1])
                    print(f"take {tk['take_id']}: it had gone out after all, at "
                          f"{found[0]}", file=sys.stderr)
                continue
            state.set(tk["take_id"], disbursing=None, disbursement_txid=txid,
                      disbursement_vout=vout)
            code, _r = report(R.DISBURSED_TAG, tk["take_id"], "/v1/btc/disbursed",
                              txid=txid, vout=vout)
            state.set(tk["take_id"], disbursed_reported=(code == 200))
            print(f"disbursed to {tk['take_id']}: {txid}", file=sys.stderr)
            n += 1
        return n

    def upgrade_pass(offers):
        """Start every loan whose borrower has taken the principal."""
        if not (seq_node and btc_node):
            return 0
        n = 0
        # Driven by this responder's OWN record of having paid a principal,
        # and by the chain that says the borrower took it. A relay status would
        # do neither: the borrower reports their own steps, so a status is a
        # thing they can move -- and moving it out from under this pass leaves
        # the principal theirs and the collateral never taken into the vault.
        for offer, tk, have in our_takes(offers):
            if have.get("upgrade_txid"):
                continue
            d = have.get("disbursement_txid")
            if not d:
                continue
            try:
                loan = loan_for(offer, tk, have)
            except ValueError as e:
                print(f"take {tk['take_id']}: {e}", file=sys.stderr)
                continue
            try:
                w, spend_txid, _c = preimage_from_spend(
                    seq_node, d, int(have.get("disbursement_vout", 0)),
                    expect_hash=loan.h_w)
            except ValueError:
                continue                       # not claimed yet
            safe = anchor_safe(seq_node, spend_txid,
                                min_depth=args.claim_depth,
                                btc_node=btc_node)
            if not safe[0]:
                # Waiting is the right thing; saying nothing about WHAT it is
                # waiting for is not. An operator watching a loan that will not
                # start has no other way to tell a shallow anchor from a
                # missing one, and the two need different actions.
                why = safe.explain(args.claim_depth)
                if have.get("waiting") != f"anchor:{safe.reason}":
                    state.set(tk["take_id"], waiting=f"anchor:{safe.reason}")
                    print(f"take {tk['take_id']}: not starting the loan yet -- "
                          f"{why}", file=sys.stderr)
                continue
            try:
                pv_txid, pv_vout, presig = prevault_of(tk, have, loan)
                tx = complete_upgrade(loan, pv_txid, pv_vout,
                                      bytes.fromhex(presig), w, lender)
            except Exception as e:                      # noqa: BLE001
                print(f"upgrade {tk['take_id']}: {type(e).__name__}: {e}",
                      file=sys.stderr)
                continue
            txid = tx.txid()
            try:
                btc_node.sendrawtransaction(tx.hex())
            except Exception as e:                      # noqa: BLE001
                # A loan that has already started must not be retried for
                # ever: the node saying so is a success, not a failure.
                if not any(w_ in str(e).lower() for w_ in
                           ("already in", "txn-already", "duplicate")):
                    print(f"upgrade {tk['take_id']}: {type(e).__name__}: {e}",
                          file=sys.stderr)
                    continue
            state.set(tk["take_id"], upgrade_txid=txid)
            code, _r = report(R.UPGRADED_TAG, tk["take_id"], "/v1/btc/upgraded",
                              txid=txid)
            state.set(tk["take_id"], upgraded_reported=(code == 200))
            print(f"started loan {tk['take_id']}: {txid}", file=sys.stderr)
            n += 1
        return n

    def publish_pass(offers):
        """Hand the borrower the secret, once the claim that published it on
        chain is buried.

        The secret is what releases their collateral. A lender who claims a
        repayment and then sits on it holds the collateral hostage until the
        timeout sweeps it -- so this is not a courtesy, it is the other half of
        being paid. It waits for depth because a borrower who spent Bitcoin on
        a secret a reorg then undid would lose both.
        """
        if not seq_node:
            return 0
        n = 0
        # Driven by this responder's own claim record. A relay status cannot
        # drive it: the report that would have set one is exactly the report
        # that may have failed, and a secret that is never published costs the
        # borrower their collateral.
        for offer, tk, have in our_takes(offers):
            if have.get("secret_published") or not have.get("t"):
                continue
            claim_txid = have.get("claim_txid")
            repay_txid = have.get("claim_of_txid")
            if not (claim_txid and repay_txid):
                continue
            # Depth is measured from the REPAYMENT's spend, not by looking the
            # claim up by id. The claim's own outputs belong to the lender's
            # wallet and get spent, after which no indexless node can find it
            # and a backward scan ages out -- stranding the secret for good.
            # The spend of the repayment outpoint is a fact about a covenant
            # output nobody else can touch, and it is where the secret is.
            try:
                secret, spend_txid, _conf = preimage_from_spend(
                    seq_node, repay_txid, int(have.get("claim_of_vout", 0)),
                    expect_hash=_sha256(bytes.fromhex(have["t"])))
            except ValueError:
                # The claim is not on chain any more: a reorg undid it, or it
                # never confirmed. claim_pass will notice and try again.
                continue
            safe = anchor_safe(seq_node, spend_txid,
                                min_depth=args.claim_depth,
                                btc_node=btc_node)
            if not safe[0]:
                # The borrower is waiting on this secret to get their
                # collateral back, so what it is waiting for is worth saying.
                if have.get("waiting") != f"publish:{safe.reason}":
                    state.set(tk["take_id"], waiting=f"publish:{safe.reason}")
                    print(f"take {tk['take_id']}: not publishing the secret "
                          f"yet -- {safe.explain(args.claim_depth)}",
                          file=sys.stderr)
                continue
            code, _r = report(R.CLAIMED_TAG, tk["take_id"], "/v1/btc/claimed",
                              txid=spend_txid, secret=secret.hex())
            if code == 200:
                state.set(tk["take_id"], secret_published=True)
                print(f"published the secret for {tk['take_id']}: the borrower "
                      "can take their collateral back", file=sys.stderr)
                n += 1
        return n

    def refund_pass(offers):
        """Take back a principal nobody claimed, once its deadline has passed."""
        if not seq_node:
            return 0
        n = 0
        height = seq_node.getblockcount()
        # Every take this responder has paid a principal into, whatever the
        # relay currently says about it. A borrower can move a take out of any
        # status the relay tracks -- they report their own steps -- so a
        # principal nobody claimed must be found by looking at the coin.
        mine = [tk for tk in book_get(args.book, "/v1/btc/takes")["takes"]
                if state.get(tk["take_id"]).get("disbursement_txid")]
        for tk in mine:
            offer = offers.get(tk.get("btc_offer_id"))
            if not offer or not ours(offer):
                continue
            have = state.get(tk["take_id"])
            try:
                loan = loan_for(offer, tk, have)
            except ValueError:
                continue
            if height < loan.d_refund or have.get("principal_refunded"):
                continue
            d = have.get("disbursement_txid") or tk.get("disbursement_txid")
            vout = int(have.get("disbursement_vout",
                                tk.get("disbursement_vout", 0)))
            if have.get("refunding") and not have.get("principal_refunded"):
                # A refund was broadcast and the answer was lost. The outpoint
                # it spent is the handle: one outpoint has one spender, so
                # reading the spend names the refund without ambiguity. Every
                # lot of a multi-lot offer refunds the same principal, in the
                # same asset, to the same address, so searching for the PAYMENT
                # would find any of them; searching for the SPEND cannot.
                c = have["refunding"]
                got = spend_witness(seq_node, c["txid"], int(c.get("vout", 0)))
                if got:
                    state.set(tk["take_id"], refunding=None,
                              principal_refunded=got[0])
                    code, _r = report(R.REFUNDED_TAG, tk["take_id"],
                                      "/v1/btc/refunded", txid=got[0])
                    state.set(tk["take_id"], refunded_reported=(code == 200))
                    print(f"take {tk['take_id']}: recovered a refund that was "
                          f"broadcast and not recorded: {got[0]}",
                          file=sys.stderr)
                    n += 1
                    continue
                state.set(tk["take_id"], refunding=None)   # it never landed
                have = state.get(tk["take_id"])
            if not d or seq_node.gettxout(d, vout, True) is None:
                continue                       # already spent: claimed or gone
            # Written BEFORE the send, as the claim and the disbursement are.
            # Without it, a crash between broadcasting and recording left the
            # coin spent and the state file saying it was still waiting to be
            # claimed -- for ever, because the next pass finds the outpoint
            # gone and reads that as "the borrower took it". The money is with
            # its owner either way; what is lost is any true account of where
            # it went, which is what an operator has to read.
            state.set(tk["take_id"], refunding={"txid": d, "vout": int(vout)})
            try:
                txid = refund_disbursement(seq_node, loan, d, vout,
                                           fee_asset=args.fee_asset or None)
            except Exception as e:                      # noqa: BLE001
                print(f"refund {tk['take_id']}: {type(e).__name__}: {e}",
                      file=sys.stderr)
                state.set(tk["take_id"], refunding=None)
                continue
            state.set(tk["take_id"], principal_refunded=txid, refunding=None)
            code, _r = report(R.REFUNDED_TAG, tk["take_id"], "/v1/btc/refunded",
                              txid=txid)
            state.set(tk["take_id"], refunded_reported=(code == 200))
            print(f"took back the principal of {tk['take_id']}: {txid}",
                  file=sys.stderr)
            n += 1
        return n

    def claim_pass(offers):
        """Take every repayment that has arrived, which publishes the secret
        that releases the borrower's collateral. A lender who sits on this is
        holding a borrower's collateral hostage, so it is not optional."""
        if not seq_node:
            return 0
        n = 0
        # Every take this responder started, whatever the relay says about it.
        # `upgrade_txid` is this key's own record of having moved the
        # collateral into the vault, which is the moment a debt begins.
        for offer, tk, have in our_takes(offers):
            if not have.get("t"):
                continue
            if not (have.get("upgrade_txid") or tk.get("upgrade_txid")):
                continue
            try:
                loan = loan_for(offer, tk, have)
            except ValueError:
                continue
            if have.get("claiming") and not have.get("claim_txid"):
                # A claim was broadcast and never recorded: a crash between the
                # send and the write. The outpoint it spent is the handle, so
                # ask the chain whether the spend is there before doing it
                # again -- a second claim of a coin already claimed is a
                # transaction the node rejects, and the pass would stall on it
                # for ever.
                c = have["claiming"]
                try:
                    _sec, spend_txid, _n = preimage_from_spend(
                        seq_node, c["txid"], int(c.get("vout", 0)),
                        expect_hash=_sha256(bytes.fromhex(have["t"])))
                except ValueError:
                    state.set(tk["take_id"], claiming=None)   # it never landed
                else:
                    state.set(tk["take_id"], claiming=None, claim_txid=spend_txid,
                              claim_of_txid=c["txid"],
                              claim_of_vout=int(c.get("vout", 0)))
                    report(R.CLAIMED_TAG, tk["take_id"], "/v1/btc/claimed",
                           txid=spend_txid, secret="")
                    print(f"take {tk['take_id']}: recovered a claim that was "
                          f"broadcast and not recorded: {spend_txid}",
                          file=sys.stderr)
                have = state.get(tk["take_id"])
            if have.get("claim_txid") and not have.get("claim_of_txid"):
                # A record from before the claimed OUTPOINT was kept. Both
                # `publish_pass` and `_claim_undone` key on it, so without it
                # they each read "nothing to do" -- for ever, and in silence.
                # The secret is never handed to the borrower, and the one check
                # that notices a Bitcoin-driven reorg undoing this claim is off
                # for the life of the loan: the borrower would refund the
                # repayment at their own deadline and reclaim the collateral
                # with the secret the first claim already published, and the
                # lender would lose both.
                #
                # The repayment outpoint the borrower reported is the
                # candidate, and the CHAIN settles it: only this loan's claim
                # can reveal `t`, so a spend that does is the claim, whatever
                # a relay says about it.
                cand = have.get("repay_txid") or tk.get("repay_txid")
                cvout = int(have.get("repay_vout",
                                     tk.get("repay_vout", 0)) or 0)
                spend_txid = None
                if cand:
                    try:
                        _s, spend_txid, _c = preimage_from_spend(
                            seq_node, cand, cvout,
                            expect_hash=_sha256(bytes.fromhex(have["t"])))
                    except ValueError:
                        spend_txid = None
                if spend_txid:
                    state.set(tk["take_id"], claim_txid=spend_txid,
                              claim_of_txid=cand, claim_of_vout=cvout)
                    have = state.get(tk["take_id"])
                    print(f"take {tk['take_id']}: repaired an older record -- "
                          f"the claimed outpoint is {cand}:{cvout}",
                          file=sys.stderr)
                elif have.get("waiting") != "claim-outpoint":
                    # Loud, and once. An operator reading the status has no
                    # other way to tell this take apart from an idle night.
                    state.set(tk["take_id"], waiting="claim-outpoint")
                    print(f"take {tk['take_id']}: it was claimed at "
                          f"{have['claim_txid']}, but the outpoint that claim "
                          f"spent is not recorded and cannot be found. The "
                          f"secret will not be published and a reorg of that "
                          f"claim will not be noticed. This is a state file "
                          f"from before that outpoint was kept.",
                          file=sys.stderr)
            if have.get("claim_txid"):
                # A claim already made -- but a claim can be UNDONE. Sequentia
                # reorgs when Bitcoin reorgs, and a claim that vanished leaves
                # the repayment spendable by its own refund while the secret it
                # published stays valid for ever: the borrower takes back the
                # debt and reclaims the collateral. So check the coin, not the
                # record, and claim again if it is loose.
                if not _claim_undone(seq_node, loan, have):
                    continue
                print(f"take {tk['take_id']}: the claim of its repayment is no "
                      "longer on chain; claiming again", file=sys.stderr)
                state.set(tk["take_id"], claim_txid=None)
                have = state.get(tk["take_id"])
            # Scan the chain at most every few minutes per loan: the
            # borrower's signed report is the fast path and this is the
            # safety net behind it.
            last = float(have.get("scanned_at") or 0)
            scan = (time.time() - last) > args.scan_interval
            found = _find_repayment(seq_node, loan, tk, have, scan=scan,
                                    min_conf=args.claim_depth)
            if scan:
                state.set(tk["take_id"], scanned_at=time.time())
            if not found:
                continue
            # Claiming publishes the secret. Doing that when the borrower's
            # own refund is about to open hands them the debt AND the
            # collateral: they refund the repayment and reclaim with the
            # secret. So the claim stops before the deadline, not at it.
            if seq_height() >= effective_repay_deadline(loan):
                if have.get("waiting") != "margin":
                    state.set(tk["take_id"], waiting="margin")
                    print(f"take {tk['take_id']}: the repayment arrived too "
                          f"close to its own refund deadline (Sequentia block "
                          f"{loan.repay_deadline}); not claiming, and not "
                          "publishing the secret", file=sys.stderr)
                continue
            rtxid, rvout = found
            # Written BEFORE the send, as the disbursement is. `claim_txid` is
            # only known after `claim_repayment` returns, and a crash between
            # broadcasting and recording it left nothing at all: the next pass
            # would find the repayment already spent, `_find_repayment` would
            # return nothing, and the secret would never be published -- so the
            # borrower's collateral would sit until the lender swept it, with
            # the debt paid. The OUTPOINT is knowable first, and it is the one
            # handle the claim can be found by afterwards.
            state.set(tk["take_id"], claiming={"txid": rtxid, "vout": int(rvout)})
            try:
                txid = claim_repayment(seq_node, loan, rtxid, rvout,
                                       bytes.fromhex(have["t"]),
                                       fee_asset=args.fee_asset or None)
            except Exception as e:                      # noqa: BLE001
                print(f"claim {tk['take_id']}: {type(e).__name__}: {e}",
                      file=sys.stderr)
                continue
            # The outpoint that was claimed is recorded with the claim.
            # Publishing the secret later means finding the claim again, and
            # the only handle on it that nobody else can spend is the covenant
            # output it spent.
            state.set(tk["take_id"], claim_txid=txid, claim_of_txid=rtxid,
                      claim_of_vout=int(rvout), claiming=None)
            report(R.CLAIMED_TAG, tk["take_id"], "/v1/btc/claimed",
                   txid=txid, secret="")
            # The secret itself follows once the claim is buried. Publishing
            # it with the claim still in the mempool would let a borrower
            # take the collateral back off a transaction a reorg could undo.
            print(f"claimed the repayment of {tk['take_id']}: {txid}",
                  file=sys.stderr)
            n += 1
        return n

    def resend_pass(offers):
        """Re-send a report the relay refused or never received.

        Every report is recorded in the state file the moment it succeeds, and
        the relay was down or refusing when it did not. Without this the
        borrower's page never learns the principal was paid or the loan
        started, and shows a step that has already happened -- and a lot the
        offer counts as free stays counted as taken. The reports are signed and
        the relay's handlers are idempotent within one step, so re-sending
        costs nothing when it is already known.
        """
        n = 0
        facts = (("disbursement_txid", "disbursed_reported", R.DISBURSED_TAG,
                  "/v1/btc/disbursed"),
                 ("upgrade_txid", "upgraded_reported", R.UPGRADED_TAG,
                  "/v1/btc/upgraded"),
                 ("principal_refunded", "refunded_reported", R.REFUNDED_TAG,
                  "/v1/btc/refunded"))
        for _offer, tk, have in our_takes(offers):
            for field, flag, tag, path in facts:
                if not have.get(field):
                    continue
                # Re-send when this responder never got a 200 -- and ALSO when
                # the relay is holding the fact without the signature that
                # proves it. A borrower rebuilding their loan from a book
                # checks each of a lender's steps against the lender's own key
                # and refuses one it cannot check, so a report the relay
                # recorded before it kept signatures leaves that borrower with
                # no way forward. Re-sending it costs one request and repairs
                # them.
                auth_field = path.rsplit("/", 1)[1].replace("-", "_") + "_auth"
                if have.get(flag) and tk.get(auth_field):
                    continue
                fields = {"txid": have[field]}
                if path.endswith("disbursed"):
                    fields["vout"] = int(have.get("disbursement_vout", 0))
                code, _r = report(tag, tk["take_id"], path, **fields)
                if code == 200:
                    state.set(tk["take_id"], **{flag: True})
                    print(f"take {tk['take_id']}: re-sent the "
                          f"{path.rsplit('/', 1)[1]} report", file=sys.stderr)
                    n += 1
                elif code in (400, 403, 404, 409):
                    # A refusal this responder can never argue its way out of:
                    # the take is gone, the report is rejected, or the take has
                    # ended somewhere this report cannot reach. Retried on every
                    # pass it is an unbounded loop against the relay that never
                    # finishes, and it hides the passes that ARE making
                    # progress. Recorded as sent so it stops, and said ONCE so
                    # an operator can see why.
                    state.set(tk["take_id"], **{flag: True})
                    print(f"take {tk['take_id']}: the relay will not take the "
                          f"{path.rsplit('/', 1)[1]} report (HTTP {code}: "
                          f"{(_r or {}).get('error', '')}). Not trying again.",
                          file=sys.stderr)
        return n

    PASSES = (("hash", hash_pass), ("sign", sign_pass),
              ("disburse", disburse_pass), ("upgrade", upgrade_pass),
              ("claim", claim_pass), ("publish", publish_pass),
              ("refund", refund_pass), ("resend", resend_pass))

    def one_pass():
        """Run every pass, and let none of them stop the rest.

        These were one summed expression, which made any single failure -- a
        take in a state one pass could not read, a node that blinked -- stop
        every later pass for as long as it lasted. Publishing secrets and
        claiming repayments are how borrowers get their collateral back, so a
        responder that stops doing them quietly is worse than one that stops.
        """
        offers = offers_by_id()
        moves = 0
        for name, fn in PASSES:
            try:
                moves += fn(offers)
            except Exception as e:                      # noqa: BLE001
                print(f"{name} pass failed: {type(e).__name__}: {e}",
                      file=sys.stderr)
        return moves

    if not args.watch:
        print(json.dumps({"moves": one_pass()}))
        return 0
    print(f"responder for {lender_x[:16]} watching {args.book}", file=sys.stderr)
    while True:
        try:
            one_pass()
        except KeyboardInterrupt:
            return 0
        except Exception as e:                          # noqa: BLE001
            print(f"pass failed: {type(e).__name__}: {e}", file=sys.stderr)
        time.sleep(args.interval)


def _sha256(b):
    import hashlib
    return hashlib.sha256(b).hexdigest()


class ScanFailed(RuntimeError):
    """A UTXO scan could not RUN. Not the same as finding nothing, and the two
    lead opposite ways: "nothing is there" is what tells a responder a
    principal was never sent."""


def _find_output(node, spk_hex, asset=None, amount=None, since_height=None,
                 min_conf=0):
    """Find an unspent output paying `spk_hex`, without a wallet and without a
    transaction index.

    `scantxoutset` walks the UTXO set for a descriptor the wallet does not have
    to own, which is what this needs: every address here belongs to a covenant,
    not to anybody's wallet, so `listunspent` -- which only ever returns the
    wallet's own coins -- would find nothing however long it looked.

    Returns (txid, vout), or None for "nothing is there". A scan that could not
    RUN -- the node busy with another one, an RPC timeout -- raises `ScanFailed`
    instead, because the two answers lead opposite ways: "nothing is there" is
    what tells a responder a principal was never sent, and treating a failed
    scan as that is how it sends a second one.
    """
    try:
        desc = node.getdescriptorinfo(f"raw({spk_hex})")["descriptor"]
    except Exception as e:                              # noqa: BLE001
        raise ScanFailed(f"the node would not parse that address: {e}") from None
    try:
        res = node.scantxoutset("start", [desc])
    except Exception as e:                              # noqa: BLE001
        raise ScanFailed(
            f"the node would not scan the UTXO set: {e}. Only one scan runs at "
            f"a time, so this is usually another one still going.") from None
    tip = int((res or {}).get("height", 0) or 0)
    best = None
    for u in (res or {}).get("unspents", []):
        if asset is not None and u.get("asset") not in (None, asset):
            continue
        if amount is not None:
            got = _atoms(u.get("amount", 0))
            if got != int(amount):
                continue
        if since_height is not None and int(u.get("height", 0)) < int(since_height):
            continue
        # Depth, where the caller asked for it. The UTXO set holds only
        # confirmed coins, so this is never zero -- but one confirmation is not
        # what a claim that publishes a secret should be built on.
        if min_conf and tip and (tip - int(u.get("height", 0)) + 1) < int(min_conf):
            continue
        # Take the most RECENT match. Every one of these addresses is public,
        # and a coin that is not this loan's payment is something anyone can
        # arrange in advance -- so preferring the oldest match would prefer
        # theirs. With the asset and the exact amount checked above, the newest
        # is the one this responder just sent.
        if best is None or int(u.get("height", 0)) > int(best.get("height", 0)):
            best = u
    if best is None:
        return None
    return best["txid"], int(best["vout"])


def _claim_undone(seq_node, loan, have):
    """Has a claim this responder made stopped being on chain?

    A reorg can undo one -- Sequentia follows Bitcoin, in real time -- and the
    cost of not noticing is the whole loan: the repayment goes back to being
    spendable by the borrower's own refund while the secret the claim published
    stays valid for ever, so they take back the debt AND the collateral.

    Answered from the covenant outpoint that was claimed, because that is the
    one handle nobody else can spend. Unknown counts as not undone: acting on
    an unreadable node would claim a second time for no reason.
    """
    txid = have.get("claim_of_txid")
    if not txid:
        return False
    try:
        out = seq_node.gettxout(txid, int(have.get("claim_of_vout", 0)), True)
    except Exception:                                   # noqa: BLE001
        return False
    # Unspent again means the spend that took it is gone.
    return out is not None


def _find_repayment(seq_node, loan, take, have, scan=True, min_conf=1):
    """Where the borrower paid the debt, if they have.

    Nothing here is believed: the repayment address follows from the terms, and
    an outpoint offered by anybody is checked against that address, the debt
    asset and the exact amount before it is claimed. A hint that does not check
    out is ignored rather than retried for ever, and the chain is scanned
    instead -- the address is public, so anyone can pay dust to it, and a
    claim built on the wrong coin would fail until the deadline ran out.

    `min_conf` is the reason a zero-confirmation repayment is never claimed.
    The borrower builds it signalling replacement, so claiming one puts the
    secret into the mempool over a parent that its own author can still
    replace: they read the secret out of the witness, replace the repayment,
    and reclaim the collateral having paid nothing. Depth is what makes that
    stop being their choice.
    Returns (txid, vout) or None.
    """
    spk = loan.repayment_spk().hex()

    def sound(txid, vout):
        try:
            out = seq_node.gettxout(txid, int(vout), True)
        except Exception:                               # noqa: BLE001
            return False
        if out is None:
            return False
        if out.get("scriptPubKey", {}).get("hex") != spk:
            return False
        if out.get("asset") not in (None, loan.debt_asset):
            return False
        if int(out.get("confirmations", 0) or 0) < int(min_conf):
            return False
        # Exactly the debt, not at least it. The covenant pays out the whole
        # input, and every spender here sizes its output from the terms, so a
        # coin an atom heavy is one no tool in this repository can spend --
        # and accepting it here would stop the scan that finds the real one.
        return _atoms(out.get("value", 0)) == int(loan.debt)

    for src in (have, take):
        txid = (src or {}).get("repay_txid")
        if txid and sound(txid, (src or {}).get("repay_vout", 0)):
            return txid, int((src or {}).get("repay_vout", 0))
    # The scan is the fallback, and it walks the whole UTXO set, so it is not
    # something to do on every pass of every loan. The borrower's own signed
    # report is what usually finds it; this is for when they never sent one.
    if not scan:
        return None
    try:
        return _find_output(seq_node, spk, asset=loan.debt_asset,
                            amount=loan.debt, min_conf=min_conf)
    except ScanFailed:
        # "Not repaid yet" is the right thing to do with an unanswerable scan
        # here: the caller's only action is to claim, and not claiming costs a
        # pass. The borrower's own report is the fast path anyway.
        return None


def add_btc_commands(sub):
    def party_terms(p):
        """The terms a cross-chain loan is made of, on both chains."""
        p.add_argument("--oracle-x", required=True,
                       help="the oracle both sides accept for a seizure. It "
                            "must not be the lender's own key")
        p.add_argument("--btc-amount", type=int, required=True,
                       help="collateral, in satoshis")
        p.add_argument("--debt-asset", required=True)
        p.add_argument("--debt", type=int, required=True,
                       help="atoms the borrower repays, principal plus interest")
        p.add_argument("--principal", type=int, default=0,
                       help="atoms the borrower receives (default: the debt, "
                            "an interest-free loan)")
        p.add_argument("--recover-after", type=int, required=True,
                       help="Bitcoin height the lender's TIMEOUT sweep opens at")
        p.add_argument("--repay-deadline", type=int, required=True,
                       help="Sequentia height the borrower's repayment refund "
                            "opens at")
        p.add_argument("--abort-after", type=int, required=True,
                       help="Bitcoin height the borrower may take unlent "
                            "collateral back at")
        p.add_argument("--d-refund", type=int, required=True,
                       help="Sequentia height the lender may take an unclaimed "
                            "principal back at")
        p.add_argument("--upgrade-fee", type=int, default=None,
                       help=f"satoshis the pre-vault carries to pay for moving "
                            f"the collateral into the loan. That transaction is "
                            f"signed in advance and can be neither replaced nor "
                            f"fee-bumped, so whatever is committed here is the "
                            f"only fee it will ever have: a fee too low to "
                            f"confirm is a loan that cannot start. Left unset "
                            f"it is PRICED from the Bitcoin node, floored at "
                            f"{MIN_UPGRADE_FEE}, which every party refuses "
                            f"below")
        p.add_argument("--lender-prog", required=True,
                       help="the lender's Sequentia payout program, hex: 20 "
                            "bytes at witness version 0, 32 at version 1")
        p.add_argument("--lender-ver", type=int, default=0)
        p.add_argument("--market", default="",
                       help="the oracle feed a seizure is judged against")
        p.add_argument("--strike", type=int, default=0,
                       help="the price a seizure is justified below, so it can "
                            "be checked afterwards")

    p = sub.add_parser("btc-keygen", help="a party keypair for a BTC loan")
    p.add_argument("--out", required=True, help="keyfile path (0600)")
    p.set_defaults(fn=cmd_btc_keygen)

    p = sub.add_parser("btc-propose",
                       help="LENDER: write the terms and send them to a borrower")
    p.add_argument("--lender-key", required=True)
    party_terms(p)
    p.add_argument("--out", required=True, help="where to write the ticket")
    p.set_defaults(fn=cmd_btc_propose, ticket=None)

    p = sub.add_parser("btc-prepare",
                       help="BORROWER: choose this loan's origination secret "
                            "and prepare the pre-vault funding, unbroadcast")
    p.add_argument("ticket")
    p.add_argument("--borrower-key", required=True)
    p.add_argument("--borrower-prog", required=True,
                   help="your Sequentia payout program, hex")
    p.add_argument("--borrower-ver", type=int, default=0)
    p.add_argument("--reclaim-address", help="where the collateral comes back "
                                             "to (default: a new address)")
    p.add_argument("--feerate", type=float, default=None,
                   help="sat/vB for the funding transaction. Left unset, this "
                        "ASKS the Bitcoin node: a constant confirms when the "
                        "parent chain is quiet and sits in the mempool when it "
                        "is not, and a collateral funding that never confirms "
                        "is a loan that never starts")
    p.add_argument("--btc-fee", type=int, default=3000,
                   help="satoshis the reclaim transaction pays")
    p.add_argument("--seq-height", type=int,
                   help="the Sequentia height to judge the deadlines against, "
                        "if no Sequentia node is configured")
    p.add_argument("--force", action="store_true",
                   help="accept terms this refuses as unsafe")
    p.add_argument("--out")
    add_node_args(p)
    add_btc_node_args(p)
    p.set_defaults(fn=cmd_btc_prepare)

    # Named for what it does. `btc-adaptor` is kept as an alias because it is
    # the name in every script written against the old protocol -- where this
    # really did produce an adaptor signature. It no longer does: the two
    # chains are bound by one hash, and what this hands over is an ordinary
    # release signature.
    p = sub.add_parser("btc-release", aliases=["btc-adaptor"],
                       help="LENDER: draw this loan's secret, publish its hash "
                            "and hand over the release signature")
    p.add_argument("ticket")
    p.add_argument("--lender-key", required=True)
    p.add_argument("--btc-fee", type=int, default=3000)
    p.add_argument("--out")
    p.set_defaults(fn=cmd_btc_adaptor)

    p = sub.add_parser("btc-originate",
                       help="BORROWER: verify the release, sign the move into "
                            "the vault, and commit the collateral")
    p.add_argument("ticket")
    p.add_argument("--borrower-key", required=True)
    p.add_argument("--btc-fee", type=int, default=3000)
    p.add_argument("--out")
    add_btc_node_args(p)
    p.set_defaults(fn=cmd_btc_originate)

    p = sub.add_parser("btc-disburse",
                       help="LENDER: pay the principal into the output the "
                            "borrower opens to start the loan")
    p.add_argument("ticket")
    p.add_argument("--disburse-conf", type=int, default=2,
                   help="Bitcoin confirmations required on the collateral "
                        "first (default 2: the shortest depth that survives "
                        "an ordinary one-block reorg)")
    p.add_argument("--force", action="store_true",
                   help="pay even though this ticket, or the chain, already "
                        "shows a principal for this loan. A second one sits at "
                        "the same address, opens to the same secret, and the "
                        "borrower takes both")
    p.add_argument("--fee-asset", help="the asset this pays its fee in "
                                       "(default: the debt asset)")
    p.add_argument("--out")
    add_node_args(p)
    add_btc_node_args(p)
    p.set_defaults(fn=cmd_btc_disburse)

    p = sub.add_parser("btc-claim-principal",
                       help="BORROWER: take the principal, which starts the loan")
    p.add_argument("ticket")
    p.add_argument("--borrower-key", required=True)
    p.add_argument("--fee-asset")
    p.add_argument("--out")
    # The lender reports where they paid the principal to a relay, so a
    # borrower who took the offer through one can pull it into their ticket
    # rather than being told to ask for an outpoint by hand. Each field is
    # taken only when the lender's own signature covers it.
    add_book_arg(p)
    add_node_args(p)
    p.set_defaults(fn=cmd_btc_claim_principal)

    p = sub.add_parser("btc-upgrade",
                       help="LENDER: start the loan with the secret the "
                            "borrower published")
    p.add_argument("ticket")
    p.add_argument("--lender-key", required=True)
    p.add_argument("--min-depth", type=int, default=6,
                   help="confirmations required on the borrower's claim before "
                        "acting on it")
    p.add_argument("--force", action="store_true",
        help="upgrade even though the claim that published the secret is not yet buried. Sequentia reorgs when Bitcoin does, so a secret read from a shallow block can be un-published under you.")
    p.add_argument("--out")
    add_node_args(p)
    add_btc_node_args(p)
    p.set_defaults(fn=cmd_btc_upgrade)

    p = sub.add_parser("btc-abort",
                       help="BORROWER: take back collateral no principal ever "
                            "came for")
    p.add_argument("ticket")
    p.add_argument("--borrower-key", required=True)
    p.add_argument("--dest", help="a Bitcoin address (default: the reclaim "
                                  "address in the ticket)")
    p.add_argument("--btc-fee", type=int, default=3000)
    p.add_argument("--force", action="store_true",
        help="build the abort even though the collateral is not yet abortable. The transaction cannot confirm until abort_after, so this only produces one early.")
    p.add_argument("--out")
    add_btc_node_args(p)
    p.set_defaults(fn=cmd_btc_abort)

    p = sub.add_parser("btc-refund-principal",
                       help="LENDER: take back a principal nobody claimed")
    p.add_argument("ticket")
    p.add_argument("--fee-asset")
    p.add_argument("--out")
    add_node_args(p)
    p.set_defaults(fn=cmd_btc_refund_principal)

    p = sub.add_parser("btc-check",
                       help="where a loan stands on both chains, and whose "
                            "move it is")
    p.add_argument("ticket")
    add_node_args(p)
    add_btc_node_args(p)
    p.set_defaults(fn=cmd_btc_check)

    p = sub.add_parser("btc-repay", help="BORROWER: pay the debt into the hashlock")
    p.add_argument("ticket")
    p.add_argument("--force", action="store_true",
                   help="pay even though this ticket or the chain says a "
                        "repayment for this loan already exists. A second one "
                        "is a second debt at the same address, and the lender "
                        "takes both")
    p.add_argument("--fee-asset")
    p.add_argument("--out")
    # Optional, and only to tell a book where the repayment landed. The lender's
    # responder finds it by scanning the chain either way; the report is what
    # stops a page saying `live` about a loan that is paid.
    p.add_argument("--borrower-key",
                   help="sign a report to the book saying where this "
                        "repayment landed (optional: the lender finds it on "
                        "chain regardless)")
    add_book_arg(p)
    add_node_args(p)
    p.set_defaults(fn=cmd_btc_repay)

    p = sub.add_parser("btc-claim",
                       help="LENDER: take the repayment, publishing the secret "
                            "that releases the collateral")
    p.add_argument("ticket")
    p.add_argument("--lender-key", required=True)
    p.add_argument("--secret-file", help="the secret for this loan (default: "
                                         "beside the key)")
    p.add_argument("--min-depth", type=int, default=6,
                   help="confirmations the borrower's repayment needs before "
                        "it is claimed. A repayment signals replacement, so "
                        "claiming a shallow one puts the secret in the mempool "
                        "over a parent its own author can still replace "
                        "(default 6)")
    p.add_argument("--force", action="store_true",
                   help="claim anyway: past the margin before the borrower's "
                        "own refund opens, or on a repayment that is not "
                        "buried yet. Both are ways to lose the collateral AND "
                        "the repayment")
    p.add_argument("--fee-asset")
    p.add_argument("--out")
    add_node_args(p)
    p.set_defaults(fn=cmd_btc_claim)

    p = sub.add_parser("btc-reclaim",
                       help="BORROWER: take the collateral back once the "
                            "secret is public")
    p.add_argument("ticket")
    p.add_argument("--borrower-key", required=True)
    p.add_argument("--btc-fee", type=int, default=3000)
    p.add_argument("--min-depth", type=int, default=6)
    p.add_argument("--force", action="store_true",
        help="reclaim on a secret read from a block that is not yet buried. This spends BITCOIN on a secret Sequentia may still take back, and the collateral is what is at stake.")
    p.add_argument("--out")
    add_node_args(p)
    add_btc_node_args(p)
    p.set_defaults(fn=cmd_btc_reclaim)

    p = sub.add_parser("btc-seize-sighash",
                       help="LENDER: print the seizure request an oracle "
                            "co-signs")
    p.add_argument("ticket")
    p.add_argument("--dest", help="a Bitcoin address for the seized collateral")
    p.add_argument("--btc-fee", type=int, default=3000)
    p.add_argument("--out", help="write the request here, for the oracle")
    add_btc_node_args(p)
    add_book_arg(p)
    p.set_defaults(fn=cmd_btc_seize_sighash)

    p = sub.add_parser("btc-seize", help="LENDER+oracle: seize the collateral")
    p.add_argument("ticket")
    p.add_argument("--lender-key", required=True)
    p.add_argument("--oracle-sig", required=True)
    p.add_argument("--dest-spk", help="the script the request named")
    p.add_argument("--btc-fee", type=int, default=3000)
    p.add_argument("--out")
    add_btc_node_args(p)
    p.set_defaults(fn=cmd_btc_seize)

    p = sub.add_parser("btc-timeout", help="LENDER: sweep after the term")
    p.add_argument("ticket")
    p.add_argument("--lender-key", required=True)
    p.add_argument("--dest")
    p.add_argument("--btc-fee", type=int, default=3000)
    p.add_argument("--out")
    add_btc_node_args(p)
    p.set_defaults(fn=cmd_btc_timeout)

    p = sub.add_parser("btc-refund",
                       help="BORROWER: take the repayment back if the lender "
                            "never claimed it")
    p.add_argument("ticket")
    p.add_argument("--fee-asset")
    p.add_argument("--out")
    add_node_args(p)
    p.set_defaults(fn=cmd_btc_refund)

    p = sub.add_parser("btc-offer-publish",
                       help="LENDER: publish a cross-chain offer to a relay")
    p.add_argument("--config", help="a responder config file; keeps "
                                    "credentials off the command line")
    p.add_argument("--lender-key")
    party_terms(p)
    p.add_argument("--lots", type=int, default=1,
                   help="how many loans of these terms are on offer")
    p.add_argument("--price-scale", type=int, default=100000)
    p.add_argument("--note", default="")
    add_book_arg(p)
    # A Bitcoin node, to PRICE the upgrade fee. That fee is fixed for the life
    # of every loan taken from this offer and can never be raised, so it must
    # come from the chain rather than from a constant.
    add_btc_node_args(p)
    p.set_defaults(book=None)  # ...so a config file's `book` is not outranked
    p.set_defaults(fn=cmd_btc_offer_publish)

    p = sub.add_parser("btc-offer-take",
                       help="BORROWER: take a cross-chain offer from a relay")
    p.add_argument("--offer", required=True)
    p.add_argument("--borrower-key", required=True)
    p.add_argument("--borrower-prog", required=True,
                   help="your Sequentia payout program, hex")
    p.add_argument("--borrower-ver", type=int, default=0)
    p.add_argument("--reclaim-address",
                   help="where the collateral comes back to (default: a new "
                        "address from your Bitcoin wallet)")
    p.add_argument("--feerate", type=float, default=None,
                   help="sat/vB; left unset this asks the Bitcoin node")
    p.add_argument("--btc-fee", type=int, default=3000)
    p.add_argument("--wait", type=float, default=90,
                   help="seconds to wait for the lender's release")
    p.add_argument("--force", action="store_true",
        help="take the offer even though a check said not to: deadlines that are already too close, or an upgrade fee too small to confirm. Each refusal says which, and this overrides all of them.")
    p.add_argument("--out", required=True, help="where to write the ticket")
    add_book_arg(p)
    add_node_args(p)
    add_btc_node_args(p)
    p.set_defaults(fn=cmd_btc_offer_take, ticket=None)

    p = sub.add_parser("payout-program",
                       help="a fresh payout program from the node wallet, "
                            "for --lender-prog/--borrower-prog")
    add_node_args(p)
    p.set_defaults(fn=cmd_payout_program)

    p = sub.add_parser("btc-offer-withdraw",
                       help="LENDER: take one of your offers down")
    p.add_argument("--offer", required=True)
    p.add_argument("--config")
    p.add_argument("--lender-key")
    add_book_arg(p)
    p.set_defaults(book=None)  # ...so a config file's `book` is not outranked
    p.set_defaults(fn=cmd_btc_offer_withdraw)

    p = sub.add_parser("btc-offer-resign",
                       help="LENDER: replace the signature on an offer a book "
                            "already holds, when it stops verifying")
    p.add_argument("--offer", required=True)
    p.add_argument("--lender-key")
    p.add_argument("--config", help="a responder config file")
    add_book_arg(p)
    p.set_defaults(book=None)  # ...so a config file's `book` is not outranked
    p.set_defaults(fn=cmd_btc_offer_resign)

    p = sub.add_parser("btc-responder-status",
                       help="LENDER: what this responder has done, and what it "
                            "is waiting on. Read-only and safe on a live one")
    p.add_argument("--config", help="a responder config file")
    p.add_argument("--lender-key")
    p.add_argument("--state", help="the state file (default: beside the key)")
    p.add_argument("--waiting-hours", type=float, default=6.0, metavar="H",
                   help="how long a take may wait on ONE reason before this "
                        "reports it (default 6). Most reasons clear within a "
                        "block or two, so an older one is a reason that will "
                        "not clear on its own")
    # With a book and the key, this also checks that the offers the book serves
    # under that key still verify under it -- one that does not is one whose
    # takes the responder is silently skipping.
    add_book_arg(p)
    p.set_defaults(book=None)  # ...so a config file's `book` is not outranked
    p.set_defaults(fn=cmd_btc_responder_status)

    p = sub.add_parser("btc-responder-clear",
                       help="LENDER: tell a responder that a send it recorded "
                            "as in-flight never went out")
    p.add_argument("--config", help="a responder config file")
    p.add_argument("--lender-key")
    p.add_argument("--state", help="the state file (default: beside the key)")
    p.add_argument("--take", required=True, help="the take id to clear")
    p.add_argument("--write-off", metavar="REASON",
                   help="record that this key can do nothing more about the "
                        "take, with the reason; it is then reported as written "
                        "off rather than as needing a person, and the "
                        "responder acts on it no more")
    p.add_argument("--found", metavar="TXID:VOUT",
                   help="record the payment at this outpoint instead of "
                        "clearing: use this when the chain shows it DID go out")
    p.add_argument("--force", action="store_true",
                   help="clear without checking the chain. Only after checking "
                        "by hand: a payment that is on chain and cleared here "
                        "is a second principal about to go out")
    add_node_args(p)
    p.set_defaults(fn=cmd_btc_responder_clear)

    p = sub.add_parser("btc-respond",
                       help="LENDER responder: sign releases, pay principals, "
                            "start loans, claim repayments")
    p.add_argument("--config", help="a JSON file with the key, the node "
                                    "credentials and the state file; nothing "
                                    "secret then appears in `ps`")
    p.add_argument("--lender-key")
    p.add_argument("--sign-only", action="store_true",
                   help="run without both chains' nodes: draw secrets and sign "
                        "releases, and do nothing else. Every loan taken "
                        "against a key running like this ends at its timeout, "
                        "because no principal is ever paid and no repayment "
                        "ever claimed. For a deliberate test, not for lending")
    p.add_argument("--state", help="where this responder records what it has "
                                   "already done (default: beside the key)")
    p.add_argument("--watch", action="store_true")
    # These default to None so that "not supplied" is distinguishable from
    # "supplied with the default value". `_btc_cfg` fills them from the config
    # file and then applies RESPONDER_DEFAULTS to whatever is still unset --
    # without which a truthy argparse default silently outranks every one of
    # these keys in the file, and an operator who set `claim_depth: 12` would
    # get 6 with nothing said.
    p.add_argument("--interval", type=float, default=None,
                   help=f"seconds between passes (default {RESPONDER_DEFAULTS['interval']})")
    p.add_argument("--disburse-conf", type=int, default=None,
                   help="Bitcoin confirmations required before disbursing "
                        f"(default {RESPONDER_DEFAULTS['disburse_conf']})")
    p.add_argument("--scan-interval", type=float, default=None,
                   help="seconds between chain scans for a repayment whose "
                        "borrower never said where it landed (default "
                        f"{RESPONDER_DEFAULTS['scan_interval']})")
    p.add_argument("--claim-depth", type=int, default=None,
                   help="confirmations required on a borrower's claim before "
                        "moving their collateral into the loan (default "
                        f"{RESPONDER_DEFAULTS['claim_depth']})")
    p.add_argument("--fee-asset", help="fee asset for the Sequentia legs "
                                       "(default: the debt asset)")
    add_book_arg(p)
    p.set_defaults(book=None)  # ...so a config file's `book` is not outranked
    add_node_args(p)           # the lender's Sequentia wallet
    add_btc_node_args(p)       # to see the collateral, and to broadcast
    p.set_defaults(fn=cmd_btc_respond)


def main():
    ap = argparse.ArgumentParser(
        prog="pignus-cli", description=__doc__.split("\n")[0],
        formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__)
    ap.add_argument("--version", action="version",
                    version=f"pignus-cli {PIGNUS_VERSION}")
    ap.add_argument("--debug", action="store_true",
                    help="re-raise errors with their traceback")
    sub = ap.add_subparsers(dest="cmd", required=True)

    sub.add_parser("selftest", help="check the covenant builder against the "
                   "golden vectors and an oracle round trip"
                   ).set_defaults(fn=cmd_selftest)

    # --- Tier C ---------------------------------------------------------------
    def issuer_args(q):
        q.add_argument("--issuer", default=os.environ.get(
            "PIGNUS_ISSUER", "http://127.0.0.1:8722"),
            help="the issuer's openampd base url")
        q.add_argument("--token", default=os.environ.get("PIGNUS_ISSUER_TOKEN", ""),
                       help="the issuer operator's bearer token: the pledge "
                            "commands are the issuer's to run, never the "
                            "borrower's or the lender's")
        q.add_argument("--allow-insecure-issuer", action="store_true",
                       help="talk to a non-https issuer that is not on this "
                            "machine. The token goes on every call, so anything "
                            "in between could read and reuse it; this exists "
                            "for a deliberate test and nothing else")

    p = sub.add_parser("pledge-sign",
                       help="sign a Tier C release or seizure with your own key")
    p.add_argument("--action", choices=("release", "seize"), required=True)
    p.add_argument("--pledge", required=True)
    p.add_argument("--extra", default="",
                   help="the repayment txid for a release, the reason for a seizure")
    p.add_argument("--key", required=True,
                   help="the key whose x-only public key you registered with "
                        "the issuer for your AID (pubkeys[0]); nothing else "
                        "verifies. A file holding it, hex (0600); '-' reads "
                        "it from stdin")
    p.set_defaults(fn=cmd_pledge_sign)

    p = sub.add_parser("pledge-list",
                       help="Tier C pledges at an issuer, all or one by id")
    issuer_args(p)
    for name in ("asset", "holder", "lender", "state"):
        p.add_argument("--" + name, default=None)
    p.add_argument("--pledge", default=None,
                   help="one pledge by id, which is what a lender checks after "
                        "a seizure the issuer has accepted but not delivered")
    p.add_argument("--json", action="store_true",
                   help="print the records as the issuer returned them")
    p.set_defaults(fn=cmd_pledge_list)

    p = sub.add_parser("pledge-create", help="lock Tier C collateral in place")
    issuer_args(p)
    p.add_argument("--asset", required=True,
                   help="the asset's 64-hex id, as openampd knows it; it must "
                        "carry a clawback leaf")
    p.add_argument("--borrower", required=True,
                   help="the borrower's AID, registered with an enclave for the asset")
    p.add_argument("--lender", required=True,
                   help="the lender's AID, registered with an enclave for the asset")
    p.add_argument("--atoms", type=int, required=True)
    p.add_argument("--maturity", type=int, required=True,
                   help="block height, above the current tip")
    p.add_argument("--debt-asset", default="")
    p.add_argument("--debt-atoms", type=int, default=0)
    p.add_argument("--note", default="")
    p.set_defaults(fn=cmd_pledge_create)

    p = sub.add_parser("pledge-release",
                       help="Tier C: the debt is settled, unlock the collateral")
    issuer_args(p)
    p.add_argument("--pledge", required=True)
    p.add_argument("--key", default=None,
                   help="file holding the LENDER's private key, hex (0600); "
                        "'-' reads it from stdin")
    p.add_argument("--lender-sig", default=None,
                   help="the lender's signature from `pignus-cli pledge-sign "
                        "--action release`: hex, or the path of the JSON that "
                        "command printed. For a lender who signs elsewhere")
    p.add_argument("--lender-x", default=None,
                   help="the lender's x-only key, to check --lender-sig "
                        "against (the pledge-sign JSON already carries it)")
    p.add_argument("--repaid-txid", default="")
    p.add_argument("--force", action="store_true",
                   help="issuer override for a lender who cannot sign; needs --reason")
    p.add_argument("--reason", default="")
    p.set_defaults(fn=cmd_pledge_release)

    p = sub.add_parser("pledge-seize",
                       help="Tier C: deliver defaulted collateral to the lender")
    issuer_args(p)
    p.add_argument("--pledge", required=True)
    p.add_argument("--reason", required=True)
    p.add_argument("--key", default=None,
                   help="file holding the LENDER's private key, hex (0600); "
                        "'-' reads it from stdin")
    p.add_argument("--lender-sig", default=None,
                   help="the lender's signature from `pignus-cli pledge-sign "
                        "--action seize`: hex, or the JSON it printed")
    p.add_argument("--lender-x", default=None,
                   help="the lender's x-only key, to check --lender-sig against")
    p.add_argument("--holder-sig", default=None,
                   help="the BORROWER's countersignature, from `pignus-cli "
                        "pledge-sign --action seize --pledge <id> --extra "
                        "<reason>` on their own machine: hex, or the JSON it "
                        "printed. Required before maturity")
    p.add_argument("--holder-x", default=None,
                   help="the borrower's x-only key, to check --holder-sig "
                        "against (the pledge-sign JSON already carries it)")
    p.set_defaults(fn=cmd_pledge_seize)

    # --- Tier D ---------------------------------------------------------------
    p = sub.add_parser("repo-propose",
                       help="write a REPURCHASE document (Tier D; not a loan)")
    for name in ("collateral-asset", "debt-asset", "borrower-cu",
                 "borrower-prog", "lender-prog"):
        p.add_argument("--" + name, required=True)
    for name in ("collateral-amount", "principal", "debt", "collateral-value",
                 "forfeit-after"):
        p.add_argument("--" + name, type=int, required=True)
    p.add_argument("--borrower-ver", type=int, default=1)
    p.add_argument("--lender-ver", type=int, default=1)
    p.add_argument("--out", default=None)
    p.set_defaults(fn=cmd_repo_propose)

    p = sub.add_parser("repo-show", help="describe a repurchase and its settlement")
    p.add_argument("terms")
    p.set_defaults(fn=cmd_repo_show)

    from pignus.repurchase import BURIAL_DEPTH

    p = sub.add_parser("repo-verify",
                       help="THE check: both halves of a repurchase, on chain")
    p.add_argument("terms")
    p.add_argument("--txid", required=True,
                   help="the transaction that funded the bond")
    p.add_argument("--vout", type=int, default=None,
                   help="the bond's output index (default: find it in the "
                        "transaction)")
    p.add_argument("--leg-txid", help="the transaction that moved the "
                   "collateral to the lender, which is what the bond secures")
    p.add_argument("--leg-vout", type=int, default=None,
                   help="its output index (default: find it)")
    p.add_argument("--lender-cu", help="the lender's C_U program, 32 bytes of "
                   "hex: the address leg one must have paid")
    p.add_argument("--min-confirmations", type=int, default=BURIAL_DEPTH,
                   metavar="N", help=f"how deep both halves must be buried "
                   f"before this is live (default {BURIAL_DEPTH})")
    add_node_args(p)
    p.set_defaults(fn=cmd_repo_verify)

    p = sub.add_parser("repo-fund", help="pay the bond into the repurchase vault")
    p.add_argument("terms")
    add_node_args(p)
    add_fee_args(p, "the bond asset")
    p.add_argument("--dry-run", action="store_true",
                   help="print what would be funded, broadcast nothing")
    p.set_defaults(fn=cmd_repo_fund)

    p = sub.add_parser("repo-forfeit",
                       help="after the deadline, sweep the bond back to the "
                            "borrower")
    p.add_argument("terms")
    p.add_argument("--txid", required=True, help="the bond vault's coin")
    p.add_argument("--vout", type=int, default=None,
                   help="the bond vault's output index, as repo-fund reported it")
    add_node_args(p)
    add_fee_args(p, "the bond asset")
    p.add_argument("--dry-run", action="store_true",
                   help="compose and print, broadcast nothing -- no preparing "
                        "sends either")
    p.set_defaults(fn=cmd_repo_forfeit)

    p = sub.add_parser("repo-settle",
                       help="compose the settlement: the asset home to the "
                            "borrower, the bond to the lender")
    p.add_argument("terms")
    p.add_argument("--txid", required=True, help="the bond vault's coin")
    p.add_argument("--vout", type=int, default=None,
                   help="the bond vault's output index, as repo-fund reported it")
    p.add_argument("--verifier", metavar="TXID:VOUT",
                   help="the OpenDAMP verifier output every transfer of the "
                        "restricted asset spends; it goes at input 0")
    p.add_argument("--verifier-spk", metavar="HEX",
                   help="the verifier covenant's own scriptPubKey, which "
                        "output 0 returns its whole value to")
    p.add_argument("--cu-lender", metavar="TXID:VOUT",
                   help="the lender's C_U coin holding the collateral asset")
    p.add_argument("--debt-utxo", metavar="TXID:VOUT",
                   help="the borrower's coin paying the debt")
    p.add_argument("--change-spk", metavar="HEX",
                   help="where the borrower's change goes (default: this "
                        "wallet, which is where a borrower composing it wants it)")
    p.add_argument("--locktime", type=int, default=0, metavar="HEIGHT",
                   help="an nLockTime for the settlement. OpenDAMP's rules can "
                        "bind a transfer to a height window, and a settlement "
                        "with no locktime cannot satisfy one: it is simply "
                        "refused, with nothing able to say why. Sequences stay "
                        "final, so this enforces the locktime without opting "
                        "into replacement")
    p.add_argument("--skeleton", metavar="FILE",
                   help="write the settlement document here instead of "
                        "stdout: the transaction, with this wallet's debt "
                        "coin signed, and the four outputs it spends, which "
                        "is what `opendamp transfer-cosign` takes")
    p.add_argument("--inspect", metavar="FILE",
                   help="the lender's look before signing: what the skeleton "
                        "document pays whom, judged against the terms; exits "
                        "4 if anything is not where the terms say. Needs no "
                        "node")
    p.add_argument("--lender-cu", metavar="HEX", default=None,
                   help="with --inspect: the lender's own C_U program, to "
                        "check input 2 is theirs")
    p.add_argument("--attach", metavar="FILE|HEX",
                   help="the settlement once every other party has signed "
                        "it (the document transfer-cosign wrote, or hex): "
                        "puts the covenant's RETURN witness on, which must "
                        "be last")
    p.add_argument("--broadcast", action="store_true",
                   help="with --attach, send the finished transaction")
    add_node_args(p)
    add_fee_args(p, "the debt asset, which is the only asset a settlement's "
                    "fee may be paid in")
    p.set_defaults(fn=cmd_repo_settle)


    p = sub.add_parser("quote", help="reference prices -> the covenant's integer price")
    p.add_argument("--market", required=True, help="e.g. GOLD/USDX")
    p.add_argument("--collateral-ref", type=float, required=True)
    p.add_argument("--debt-ref", type=float, required=True)
    p.add_argument("--collateral-precision", type=int, default=8)
    p.add_argument("--debt-precision", type=int, default=8)
    p.add_argument("--price-scale", type=int, default=100_000)
    p.add_argument("--open-ltv", type=float, default=50.0,
                   help="the loan-to-value a loan opens at, which with each "
                        "liquidation ratio gives the strike (default 50)")
    p.add_argument("--interest", type=float, default=3.0,
                   help="%% over the whole term, the same figure offer-fund "
                        "takes (default 3). The liquidation ratio is measured "
                        "against the DEBT, so leaving this out quotes a strike "
                        "below the one that would be baked in")
    p.set_defaults(fn=cmd_quote)

    p = sub.add_parser("propose", help="write a loan terms document")
    for name in ("collateral-asset", "debt-asset", "borrower-x", "lender-x",
                 "market"):
        p.add_argument("--" + name, required=True)
    p.add_argument("--oracle-x", default="",
                   help="a single oracle key; or use --oracles for m-of-n")
    p.add_argument("--oracles", default="",
                   help="comma-separated oracle keys for an m-of-n loan")
    p.add_argument("--oracle-threshold", type=int, default=0)
    for name, default in (("collateral-amount", None), ("principal", None),
                          ("debt", None), ("strike", None), ("maturity", None),
                          ("recover-after", None), ("not-before", 0),
                          ("bonus-num", 105), ("bonus-den", 100),
                          ("price-scale", 100_000), ("max-price", 0)):
        p.add_argument("--" + name, type=int, default=default,
                       required=default is None)
    p.add_argument("--memo", default="")
    p.set_defaults(fn=cmd_propose)

    p = sub.add_parser("show", help="describe a loan, optionally at a price")
    p.add_argument("--terms", required=True)
    p.add_argument("--price", type=int)
    p.set_defaults(fn=cmd_show)

    p = sub.add_parser("address", help="the vault scriptPubKey these terms compile to")
    p.add_argument("--terms", required=True)
    p.set_defaults(fn=cmd_address)

    p = sub.add_parser("verify", help="THE check: do these terms match the funded vault")
    p.add_argument("--terms", required=True,
                   help="the terms document, or the record offer-take printed")
    p.add_argument("--spk", help="the funded scriptPubKey, if you have it directly")
    p.add_argument("--txid", help="the funding transaction; read from the "
                   "utxo set, so no transaction index is needed")
    p.add_argument("--vout", type=int, default=0)
    p.add_argument("--blockhash", help="the block the funding is in, needed "
                   "only to check a vault that has already been spent")
    g = p.add_mutually_exclusive_group()
    g.add_argument("--single-leaf", action="store_true",
                   help="demand the single-leaf vault an offer creates "
                        "(default: accept either and report which it is)")
    g.add_argument("--four-leaf", action="store_true",
                   help="demand the four-leaf vault of a directly originated loan")
    add_node_args(p)
    p.set_defaults(fn=cmd_verify)

    p = sub.add_parser("loans", help="list a book's loans, or only this "
                                     "wallet's (--mine); exits 4 when one of "
                                     "yours needs a person")
    p.add_argument("--state", help="LIVE, REPAID, LIQUIDATED, ...; default all")
    p.add_argument("--market")
    p.add_argument("--mine", action="store_true",
                   help="only loans whose borrower or lender program is one "
                        "this wallet's coins or payout address use")
    add_book_arg(p)
    add_node_args(p)
    p.set_defaults(fn=cmd_loans)

    p = sub.add_parser("offers", help="list a book's offers, or only this "
                                      "wallet's (--mine); exits 4 when one of "
                                      "yours has expired with lots untaken")
    p.add_argument("--status", default="open",
                   help="open, delisted, taken, withdrawn, gone, ghost, "
                        "expired or all (default open)")
    p.add_argument("--mine", action="store_true",
                   help="only offers whose lender program is one this "
                        "wallet's coins or payout address use")
    add_book_arg(p)
    add_node_args(p)
    p.set_defaults(fn=cmd_offers)

    p = sub.add_parser("status", help="reconcile a loan against the chain")
    p.add_argument("--terms", required=True,
                   help="the terms document, or the record offer-take printed")
    p.add_argument("--txid", help="the funding transaction (default: the one "
                   "the terms file carries)")
    p.add_argument("--vout", type=int, default=0)
    p.add_argument("--single-leaf", action="store_true",
                   help="the vault came from a funded offer; needed only once "
                        "it is spent, since until then the chain says which it is")
    p.add_argument("--price", type=int)
    p.add_argument("--min-depth", type=int, default=2)
    p.add_argument("--watch", action="store_true",
                   help="keep reconciling, printing a line whenever the state, "
                        "liquidatable, matured or recover_open changes; exit "
                        "status is the last pass's")
    p.add_argument("--interval", type=float, default=60,
                   help="seconds between passes with --watch (default 60)")
    p.add_argument("--verbose", action="store_true",
                   help="with --watch, print every pass rather than changes")
    add_book_arg(p)
    add_node_args(p)
    p.set_defaults(fn=cmd_status)

    p = sub.add_parser("check-attestation", help="verify a signed price")
    p.add_argument("--oracle-x", help="the oracle's x-only key; a row from a "
                   "book's /v1/attestations already names it")
    p.add_argument("--price-scale", type=int, default=None,
                   help="the scale the LOAN was built at, to check this price "
                        "against. The scale is not inside the signature -- it "
                        "is in the vault's leaf -- so the same number means a "
                        "different price at a different scale, and this is the "
                        "only place the two are compared")
    p.add_argument("--attestation", required=True)
    p.set_defaults(fn=cmd_check_attestation)

    add_wallet_commands(sub)
    add_btc_commands(sub)

    # Every handler already explains itself in its docstring, and `--help` for a
    # subcommand was printing its flags without it: a reader looking up
    # `btc-reclaim` got a list of options and no sentence saying what it does.
    # Fill in a description from the handler wherever the parser has none,
    # rather than repeating each one by hand and letting the two drift.
    helps = {a.dest: a.help for a in sub._choices_actions} \
        if hasattr(sub, "_choices_actions") else {}
    for name, parser in sub.choices.items():
        if parser.description:
            continue
        fn = parser.get_default("fn")
        text = inspect.cleandoc(fn.__doc__) if (fn and fn.__doc__) \
            else helps.get(name)
        if not text:
            continue
        parser.description = text
        parser.formatter_class = argparse.RawDescriptionHelpFormatter

    args = ap.parse_args()
    try:
        return args.fn(args)
    except compat.CovenantUnavailable as e:
        if args.debug:
            raise
        print(f"error: {e}", file=sys.stderr)
        return 3
    except PledgeError as e:
        # An issuer that SAID NO and an issuer that was not there call for
        # entirely different next steps, so they get different exit statuses:
        # 2 is a refusal, 1 is a command that could not run at all.
        if args.debug:
            raise
        if e.status:
            print(f"REFUSED: {e}", file=sys.stderr)
            return 2
        print(f"error: {e}", file=sys.stderr)
        return 1
    except urllib.error.HTTPError as e:
        # A service that ANSWERED, and said no. Telling somebody to check
        # whether the node is running when the node has just replied 403 sends
        # them to look at the one thing that is working.
        if args.debug:
            raise
        body = ""
        try:
            body = json.loads(e.read().decode() or "{}").get("error", "")
        except Exception:                               # noqa: BLE001
            pass
        print(f"error: that service refused the request "
              f"(HTTP {e.code} {e.reason})" + (f": {body}" if body else ""),
              file=sys.stderr)
        return 1
    except urllib.error.URLError as e:
        if args.debug:
            raise
        print(f"error: cannot reach that service ({e.reason}); check --rpc, "
              "--book and whether the node is running", file=sys.stderr)
        return 1
    except (OSError, RuntimeError, ValueError, TypeError, KeyError,
            IndexError) as e:
        # A message a person can act on, rather than a traceback. --debug is
        # there for the case where the traceback is the useful part.
        if args.debug:
            raise
        print(f"error: {type(e).__name__}: {e}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())
