#!/usr/bin/env python3
# Copyright (c) 2026 The Sequentia developers
# Distributed under the MIT software license.
"""A liquidator bot: watch loans, and close the ones that cross.

Nothing here is privileged. The covenant does not know or care which liquidator
wins, every racer must pay the lender in full and return the borrower's surplus,
and the bonus is what prices the race. So this is one participant among however
many choose to run one -- which is the point, because a lending platform whose
liquidations depend on the operator's bot is a platform with an operator.

    pignus-liquidator --book http://127.0.0.1:8741 \\
        --rpc-wallet liquidator --taker-address <addr> [--dry-run]
    pignus-liquidator --loans loans.json --oracle http://127.0.0.1:8740 ...

With `--book` the live loans are read from a pignusd, re-read every round, so
a loan that appears while this runs is watched too, and the book's aggregated
attestations reach every oracle it quotes against. `--oracle` names an oracle
directly and may be repeated, which is how loans baked to a key the book does
not aggregate are still closed. At least one of the two is needed.

`loans.json` is a list of {"terms": {...}, "txid": "...", "vout": 0,
"single_leaf": bool}, for a drill or a book of one's own.

Every attestation is verified here, against the key THIS LOAN names and at the
scale THIS LOAN computes with -- not against whichever oracle served it. A price
signed by another key, or quoted at another scale, is a number about a different
loan.

--dry-run reports what it would do and touches nothing, which is how this should
be run first against any real book.
"""

import argparse
import json
from decimal import Decimal
import os
import sys
import time
import urllib.request

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

from pignus import atoms, compat, locktime_open, oracle as O   # noqa: E402
from pignus.book import Book                         # noqa: E402
from pignus.node import Node, RpcError               # noqa: E402
from pignus.offers import offer_vault_address        # noqa: E402
from pignus.terms import LoanTerms                   # noqa: E402
from pignus import fees as F                         # noqa: E402
from pignus.vault import Outpoint, VaultSpender, select_funding   # noqa: E402
from pignus.watcher import VaultWatcher, State       # noqa: E402


def canon(market):
    """A market name in the one spelling everything here keys by. A feed id is
    case-insensitive, so an oracle, a book and a terms file may each write the
    same market differently and mean it."""
    return "/".join(p.strip().upper() for p in str(market).split("/"))

# What a single fetch or check is allowed to fail with and carry on. A bot meant
# to run for the life of a book must not end on a node restart or a book that
# blinked: urllib's URLError and socket.timeout are OSError subclasses, so HTTP
# and RPC transport failures are both covered here, and KeyError and TypeError
# catch a response that parsed but was not the shape it claims -- a reply from
# a third party is not a promise about types.
#
# The ROUND itself catches wider than this. Deciding in advance every way a
# stranger's JSON can go wrong is the mistake; not liquidating anything until
# somebody notices is the cost.
TRANSIENT = (RpcError, RuntimeError, ValueError, OSError, KeyError, TypeError,
             AttributeError)


def fetch_attestation(base, market):
    url = f"{base.rstrip('/')}/v1/attestation/{market.replace('/', '_')}"
    with urllib.request.urlopen(url, timeout=10) as r:
        return O.Attestation.from_dict(json.loads(r.read().decode()))


def fetch_attestation_set(base, market):
    """{oracle_x: Attestation} for a market, from the book."""
    url = f"{base.rstrip('/')}/v1/attestations/{market.replace('/', '_')}"
    with urllib.request.urlopen(url, timeout=10) as r:
        data = json.loads(r.read().decode())["attestations"]
    out = {}
    for d in data:
        ox = d.get("oracle_x")
        if ox:
            out[ox] = O.Attestation.from_dict(d)
    return out


def fetch_oracle_key(base):
    with urllib.request.urlopen(f"{base.rstrip('/')}/v1/pubkey", timeout=10) as r:
        return json.loads(r.read().decode())["oracle_x"]


def fetch_book_loans(base):
    url = f"{base.rstrip('/')}/v1/loans?state=LIVE"
    with urllib.request.urlopen(url, timeout=10) as r:
        return json.loads(r.read().decode())["loans"]


def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0],
                                 formatter_class=argparse.RawDescriptionHelpFormatter,
                                 epilog=__doc__)
    ap.add_argument("--loans", help="a JSON list of loans to watch")
    ap.add_argument("--book", help="a pignusd to read LIVE loans from instead")
    ap.add_argument("--oracle", action="append", metavar="URL",
                    help="an oracle to read prices from; repeatable. Loans are "
                         "judged against the key each one bakes, so name every "
                         "oracle whose loans this bot should close")
    ap.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")
    ap.add_argument("--rpc-user", default=os.environ.get("PIGNUS_RPC_USER"),
                    help="RPC user, or PIGNUS_RPC_USER")
    ap.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 -- "
                         "and this one runs for the life of the book")
    ap.add_argument("--rpc-wallet", default=os.environ.get("PIGNUS_RPC_WALLET"),
                    help="the node wallet to spend and sign with")
    ap.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")
    ap.add_argument("--taker-spk", help="hex scriptPubKey to receive seized collateral")
    ap.add_argument("--taker-address", help="address to receive seized collateral")
    ap.add_argument("--fee-asset", help="label or hex; default: the debt asset "
                    "being spent, else anything held with a published rate")
    ap.add_argument("--fee-amount", type=int, help="atoms; default: priced "
                    "from the node's exchange rates")
    ap.add_argument("--min-profit", type=int, default=0, metavar="ATOMS",
                    help="skip a seizure worth less than this much more than "
                         "the debt it pays AND the fee it costs, in debt-asset "
                         "atoms (default 0: never take one at a loss). "
                         "Negative to allow it. A fee paid in another asset is "
                         "restated at the node's rate for the debt asset; when "
                         "the node publishes none, the loan is skipped rather "
                         "than priced")
    ap.add_argument("--allow-foreign-taker", action="store_true",
                    help="accept a --taker-address this wallet does not own")
    ap.add_argument("--max-attestation-age", type=int, default=600, metavar="S",
                    help="ignore a price signed longer ago than this (default "
                         "600 s); the covenant cannot check recency, so this is "
                         "the only place it is checked")
    ap.add_argument("--allow-stale", action="store_true",
                    help="act on attestations older than --max-attestation-age")
    ap.add_argument("--interval", type=float, default=30.0)
    ap.add_argument("--once", action="store_true")
    ap.add_argument("--dry-run", action="store_true")
    ap.add_argument("--call-due", action="store_true",
                    help="also call loans past maturity via DEFAULT")
    args = ap.parse_args()

    if not args.loans and not args.book:
        ap.error("need --loans or --book")
    if not args.book and not args.oracle:
        ap.error("need --oracle when there is no --book to read prices from")
    if args.fee_amount and not args.fee_asset:
        # Mirrors pignus-cli: an amount without an asset is an instruction this
        # tool cannot carry out, and quietly pricing its own fee instead is how
        # an operator's number disappears.
        ap.error("--fee-amount needs --fee-asset")
    if not args.taker_spk and not args.taker_address:
        ap.error("need --taker-spk or --taker-address")

    # Before the node, the book or a wallet: a builder that has drifted from the
    # golden vectors derives vault addresses this bot would then compose real
    # money against. One line and exit 3, rather than a traceback per round.
    try:
        compat.load_covenant()
    except compat.CovenantUnavailable as e:
        print(f"pignus-liquidator: {e}", file=sys.stderr)
        return 3

    node = Node(url=args.rpc, user=args.rpc_user, password=args.rpc_password,
                wallet=args.rpc_wallet, cookie_path=args.rpc_cookie)

    watcher = VaultWatcher(node)
    loans = {}
    oracle_keys = {}          # oracle URL -> its x-only key
    warned = {}               # loan id -> the last reason it was skipped for

    def resolve_asset(label_or_hex):
        if len(label_or_hex) == 64:
            try:
                int(label_or_hex, 16)
            except ValueError:
                raise SystemExit(f"--fee-asset {label_or_hex!r} is 64 "
                                 "characters but not an asset id") from None
            return label_or_hex
        labels = node.dumpassetlabels()
        if label_or_hex in labels:
            return labels[label_or_hex]
        raise SystemExit(f"unknown asset {label_or_hex!r}: give a 64-hex id or "
                         "a label the node knows")

    def note(who, reason):
        """Say why a loan was passed over, once, rather than every round."""
        if warned.get(who) != reason:
            warned[who] = reason
            print(f"  skip: {reason}", file=sys.stderr)

    def track(entries):
        """Watch the loans in `entries` whose coin is really what they claim.

        A book entry is a claim about a coin, and this bot is about to spend
        real fees composing against it. So the outpoint is fetched and compared
        with the address the terms compile to before anything is tracked -- the
        same check pignus-cli makes, for the same reason.
        """
        for e in entries:
            try:
                t = LoanTerms.from_json(e["terms"])
                txid, vout = e["txid"], int(e.get("vout", 0))
            except Exception as ex:                     # noqa: BLE001
                # One entry this bot cannot read must not stop it reading the
                # rest: a bad record, or an RPC hiccup on one coin, blinded it
                # to every loan after it in the list, and at start-up ended it.
                print(f"skipping an entry it cannot read ({type(ex).__name__}: "
                      f"{ex})", file=sys.stderr)
                continue
            key = f"{txid}:{vout}"
            if key in loans:
                continue
            single = bool(e.get("single_leaf"))
            try:
                got = node.gettxout(txid, vout, True)
            except TRANSIENT as ex:
                print(f"{key}: the node would not say whether the vault is "
                      f"there ({type(ex).__name__}: {ex}); asking again next "
                      f"round", file=sys.stderr)
                continue
            if got is None:
                continue                   # spent already, or never funded
            want = (offer_vault_address(t) if single else t.script_pubkey()).hex()
            if got["scriptPubKey"]["hex"] != want:
                print(f"ignoring {key}: the coin there is not what these terms "
                      "compile to", file=sys.stderr)
                continue
            if got.get("asset") is None or "value" not in got:
                print(f"ignoring {key}: the vault coin is blinded, and the "
                      "covenant cannot read a blinded amount", file=sys.stderr)
                continue
            # From the chain, never from the terms: every leaf reads the INPUT's
            # own value, so an over-funded vault is exited by paying out what it
            # holds. Less than the terms lock is the only impossible one.
            value = atoms(got["value"])
            if got["asset"] != t.collateral_asset:
                print(f"ignoring {key}: the coin holds {got['asset'][:8]}…, "
                      f"not the collateral asset {t.collateral_asset[:8]}…",
                      file=sys.stderr)
                continue
            if value < t.collateral_amount:
                print(f"ignoring {key}: the coin holds {value} atoms and the "
                      f"terms lock {t.collateral_amount}; no leaf would accept "
                      "an exit from it", file=sys.stderr)
                continue
            watcher.track(key, t, txid, vout, single_leaf=single)
            loans[key] = (t, Outpoint(txid, vout, value, t.collateral_asset),
                          single)

    def fee_for(t, flow):
        """(asset, atoms, rate) for this exit, mirroring pignus-cli's choice.

        The rate comes back too: 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"]
        if args.fee_amount:
            return fee_asset, int(args.fee_amount), rates.get(fee_asset)
        if fee_asset:
            rate = rates.get(fee_asset)
            if not rate:
                # No silent second choice: the operator named the asset this
                # bot pays in, and paying in another one is not a smaller
                # version of that instruction.
                raise ValueError(f"the node publishes no fee rate for "
                                 f"{args.fee_asset}")
            return fee_asset, F.fee_atoms(rate, F.VSIZE[flow],
                                          table["feerate_rfa_per_kvb"]), rate
        have = {}
        for u in node.listunspent():
            if u.get("spendable") and u.get("asset"):
                have[u["asset"]] = have.get(u["asset"], 0) + atoms(u["amount"])
        # The seizure pays the lender `debt` out of this wallet before it keeps
        # any collateral, so the fee asset is chosen from what is left after
        # that -- otherwise a wallet holding exactly the debt picks the debt
        # asset for the fee too, and the funding fails every round with an
        # error that names no asset.
        asset, cost = F.pick_fee(table, have, flow, [t.debt_asset],
                                 committed={t.debt_asset: int(t.debt)})
        return asset, cost, rates.get(asset)

    def oracle_key(url, refetch=False):
        """Fetched once, and again when a verification fails: an oracle that
        rotated its key is otherwise a bot that runs green and closes nothing,
        every attestation "failing verification" against a key it no longer
        signs with."""
        ox = oracle_keys.get(url)
        if ox is None or refetch:
            try:
                fresh = fetch_oracle_key(url)
            except Exception as e:                  # noqa: BLE001
                if ox is None:
                    raise
                print(f"oracle: {url}: could not re-read its key "
                      f"({type(e).__name__}: {e}); keeping the last one",
                      file=sys.stderr)
                return ox
            if ox is not None and fresh != ox:
                print(f"oracle: {url}: its key changed from {ox[:16]}… to "
                      f"{fresh[:16]}…; loans baked to the old key are no "
                      f"longer priced by it", file=sys.stderr)
            ox = oracle_keys[url] = fresh
        return ox

    def holdings():
        """{asset hex: atoms} the wallet can spend, for saying before a spend
        whether the debt can be paid at all."""
        from pignus.vault import _holdings
        return {str(k).lower(): int(v) for k, v in _holdings(node).items()}

    def gather(markets):
        """{(oracle_x, market): Attestation}, each verified against the key that
        signed it.

        Never act on a price this bot has not verified itself: an oracle is
        trusted for the number, not for the transport that carried it, and a
        book that aggregates several oracles is not trusted to say which key
        signed which price.
        """
        now = int(time.time())
        ev = {}

        def keep(ox, att, where):
            if not O.verify(ox, att):
                print(f"oracle: {att.market}: an attestation from {where} "
                      "FAILED verification, ignoring", file=sys.stderr)
                # A key that stopped verifying may have rotated; re-read
                # every oracle's key once so the next round has the new one.
                for u in list(oracle_keys):
                    oracle_key(u, refetch=True)
                return
            # Two-sided. A one-sided test reads a price dated AHEAD of this
            # machine's clock as infinitely fresh, so an oracle whose host
            # clock runs fast can have a dead price acted on for as long as
            # the drift lasts -- and what is acted on here is a liquidation.
            age = O.age_of(att, now)
            if not O.current(att, args.max_attestation_age, now) \
                    and not args.allow_stale:
                when = (f"{age}s ago" if age >= 0
                        else f"{-age}s in the FUTURE, which no honest clock is")
                print(f"oracle: {att.market}: {where}'s price was signed {when} "
                      f"(max {args.max_attestation_age}); not acting on it",
                      file=sys.stderr)
                return
            ev[(ox.lower(), canon(att.market))] = att

        for market in markets:
            if args.book:
                try:
                    for ox, att in fetch_attestation_set(args.book, market).items():
                        keep(ox, att, "the book")
                except TRANSIENT as e:
                    print(f"book: {market}: {type(e).__name__}: {e}",
                          file=sys.stderr)
            for url in args.oracle or []:
                try:
                    keep(oracle_key(url), fetch_attestation(url, market), url)
                except TRANSIENT as e:
                    print(f"oracle: {market}: {type(e).__name__}: {e}",
                          file=sys.stderr)
        return ev

    def evidence_for(who, t, ev):
        """The attestations from `ev` that THIS loan can actually be closed
        with: signed by a key it names, at the scale it computes with."""
        out, unwatched = {}, []
        for k in t.oracle_keys:
            att = ev.get((k.lower(), canon(t.market)))
            if att is None:
                unwatched.append(k)
                continue
            if not O.verify(k, att, t.price_scale):
                # A good signature at another price_scale is a good signature
                # over a different number: at a tenth of this loan's scale the
                # attested integer is ten times too small, which opens
                # LIQUIDATE on a healthy loan and seizes ten times the
                # collateral. The covenant sees only the integer, so this is
                # the only place it can be caught.
                note(who,
                     f"an attestation for {t.market} is at price scale "
                     f"{att.price_scale}, but this loan computes at "
                     f"{t.price_scale}; the covenant would misread it")
                continue
            if att.timestamp < t.not_before:
                continue
            out[k] = att
        if not out and unwatched:
            # A loan is judged on the keys IT names. One this bot never asked
            # for is not a loan that is healthy, it is a loan nobody here can
            # price -- and silence would read as the first.
            note(who, f"loan names oracle {unwatched[0][:16]}…, which this bot "
                      "does not watch: add --oracle <url> for it, or point "
                      "--book at a book that aggregates it")
        return out

    def decide(v, ev, height, now=None):
        """(kind, price, evidence) for a live vault, or None.

        The decision is made from the loan's OWN oracles before anything is
        spent: a loan judged on the wrong key is a fee paid to compose a spend
        the covenant will refuse.
        """
        t = v.terms
        usable = evidence_for(v.loan_id, t, ev)
        if not usable:
            return None
        if t.oracles:
            slots, price = O.liquidatable_slots(t, usable)
            if slots is not None:
                return "liquidate", price, usable
            if args.call_due and height is not None \
                    and locktime_open(t.maturity, height, now):
                slots, price = O.select_threshold(t, usable)
                if slots is None:
                    note(v.loan_id, f"cannot reach the {t.threshold}-of-"
                                    f"{len(t.oracle_keys)} oracle threshold")
                    return None
                return "default", price, usable
            return None
        att = usable[t.oracle_keys[0]]
        if t.is_liquidatable(att.price):
            return "liquidate", att.price, att
        # In the deadline's own units: a maturity at or above the locktime
        # threshold is a Unix TIME, and comparing it to a height says the loan
        # matures thousands of years from now, so DEFAULT would never be called.
        if args.call_due and height is not None \
                and locktime_open(t.maturity, height, now):
            return "default", att.price, att
        return None

    def check_coin(v, t, single):
        """The vault coin, re-read from the chain, or None.

        Nothing is composed against a remembered outpoint: a coin already seized
        by another racer, or a book entry that never described the coin it
        named, would otherwise cost a preparing transaction and a rejected
        broadcast every round. The AMOUNT comes from here too, because every
        leaf reads the input's own value: an over-funded vault is exited by
        paying out what it holds, and only a coin holding less than the terms
        lock is one no leaf would accept.
        """
        got = node.gettxout(v.txid, v.vout, True)
        if got is None:
            return None
        want = (offer_vault_address(t) if single else t.script_pubkey()).hex()
        if got.get("asset") is None or "value" not in got:
            note(v.loan_id, "the vault coin is blinded, and the covenant cannot "
                            "read a blinded amount")
            loans.pop(v.loan_id, None)
            return None
        value = atoms(got["value"])
        if (got["scriptPubKey"]["hex"] != want
                or got["asset"] != t.collateral_asset
                or value < t.collateral_amount):
            note(v.loan_id, "the vault coin is not what these terms compile to")
            loans.pop(v.loan_id, None)
            return None
        return Outpoint(v.txid, v.vout, value, t.collateral_asset)

    def round_once():
        if args.book:
            try:
                track(fetch_book_loans(args.book))
            except TRANSIENT as e:
                print(f"book: {type(e).__name__}: {e}", file=sys.stderr)
        watcher.poll()
        ev = gather({canon(t.market) for t, _o, _s in loans.values()})
        height = node.getblockcount() if args.call_due else None
        # The chain's median time as well, because a maturity may be written as
        # a Unix time and only that is what consensus compares such a one to.
        mtp = None
        if args.call_due and height is not None:
            try:
                mtp = int(node.getblockheader(
                    node.getblockhash(height))["mediantime"])
            except TRANSIENT as e:
                print(f"median time: {type(e).__name__}: {e}", file=sys.stderr)

        for v in list(watcher.vaults.values()):
            if v.state is not State.LIVE or v.loan_id not in loans:
                continue
            t = v.terms
            got = decide(v, ev, height, mtp)
            if got is None:
                continue
            kind, price, evidence = got
            seize = min(t.collateral_amount, t.seizure_at(price))
            surplus = t.surplus_at(price)
            # The FEE is part of the arithmetic. It is paid out of this bot's
            # own coins, so a seizure whose margin is thinner than the fee is a
            # seizure taken at a loss -- and `--min-profit 0`, whose whole
            # promise is "never take one at a loss", used to take exactly
            # those. Priced with the same call the spend will use, so the two
            # cannot drift.
            gross = seize * price // t.price_scale - t.debt
            # The FEE is part of the arithmetic. It is paid out of this bot's
            # own coins, so a seizure whose margin is thinner than the fee is
            # one taken at a loss -- and `--min-profit 0`, whose whole promise
            # is "never take one at a loss", used to take exactly those.
            # Priced with the same call the spend will use, so the two cannot
            # drift.
            #
            # Atoms of one asset are not atoms of another, so a fee paid in
            # something other than the debt asset cannot simply be subtracted
            # from a gross figure denominated in the debt asset. Nor is it
            # zero, which is what leaving it out amounts to -- and the bot
            # prefers the debt asset but will pay in whatever the wallet
            # holds, so that case is ordinary rather than exotic. What makes
            # the two comparable is that the node's table preserves fee VALUE
            # across assets: one flow costs the same in policy terms whichever
            # asset pays it. Re-pricing this exit at the DEBT asset's own
            # published rate therefore states the same fee in the units the
            # profit is measured in, without inventing a rate here.
            #
            # A debt asset the node does not price leaves no honest
            # conversion, so the cost is reported unknown rather than as zero,
            # and the comparison against --min-profit is refused rather than
            # decided on a figure that is missing a term. A wallet that cannot
            # pay a fee at all is not a reason to stop DECIDING; the attempt
            # that matters fails later, loudly.
            flow = kind if v.single_leaf else kind + "4"
            fee_atoms, fee_paid_in, fee_cost, fee_why = 0, None, None, ""
            try:
                fee_paid_in, fee_atoms, _r = fee_for(t, flow)
                if fee_paid_in == t.debt_asset:
                    fee_cost = fee_atoms
                else:
                    tbl = F.fee_table(node)
                    debt_rate = tbl["rates"].get(t.debt_asset)
                    if debt_rate:
                        fee_cost = F.fee_atoms(debt_rate, F.VSIZE[flow],
                                               tbl["feerate_rfa_per_kvb"])
                    else:
                        fee_why = ("the node publishes no fee rate for the"
                                   f" debt asset {t.debt_asset[:8]}, so a fee"
                                   f" of {fee_atoms} atoms of"
                                   f" {fee_paid_in[:8]} cannot be restated in"
                                   " the units this profit is measured in")
            except Exception as e:                      # noqa: BLE001
                fee_why = f"the fee for this exit cannot be priced: {e}"
            profit = gross - (fee_cost or 0)
            # BOTH names. The terms id is what a person recognises -- two
            # fundings of one set of terms share it -- and the book's own key
            # is the terms id hashed with the funding outpoint, which is what
            # every book record and every URL uses. Printing only the first
            # leaves an operator with an id no lookup accepts.
            print(f"{kind}: {t.loan_id()[:16]} (book "
                  f"{Book.loan_key(t, v.txid, v.vout)[:16]}) "
                  f"{t.market} price={price} "
                  f"health={t.health(price):.3f} seize={seize} surplus={surplus} "
                  f"gross={gross} "
                  + ("fee=? " if fee_cost is None else
                     f"fee={fee_cost} " if fee_paid_in == t.debt_asset else
                     f"fee={fee_cost} (paid as {fee_atoms} of "
                     f"{fee_paid_in[:8]}) ")
                  + ("profit=?" if fee_cost is None else f"profit={profit}"),
                  file=sys.stderr)
            # An unknown fee only ever makes the profit SMALLER, so a gross
            # that already fails the threshold fails it whatever the fee turns
            # out to be, and that refusal is stated plainly. It is only the
            # other direction -- a gross that clears it -- that the missing
            # term could reverse, and there the honest answer is that this is
            # not known rather than a seizure decided on an incomplete sum.
            if fee_cost is None and gross >= args.min_profit:
                note(v.loan_id,
                     f"{gross} atoms gross would clear --min-profit"
                     f" {args.min_profit}, but {fee_why}; seizing on a profit"
                     " figure that is missing the fee is the loss"
                     " --min-profit exists to prevent")
                continue
            if profit < args.min_profit:
                note(v.loan_id,
                     (f"{gross} atoms gross, before a fee that is only ever a"
                      " further cost," if fee_cost is None else
                      f"{profit} atoms of profit after a {fee_cost}-atom fee"
                      + ("" if fee_paid_in == t.debt_asset else
                         f" (paid as {fee_atoms} atoms of {fee_paid_in[:8]},"
                         " repriced at the node's rate for the debt asset)"))
                     + f" is under --min-profit {args.min_profit}")
                continue
            # Can this wallet PAY? A seizure pays the lender the whole debt
            # first, and a wallet without it failed every round with
            # "Insufficient funds", naming neither asset nor amount, while a
            # dry run printed the seizure as if it could be made.
            try:
                have = holdings()
            except Exception as e:                      # noqa: BLE001
                have = None
                note(v.loan_id, f"the wallet's balances could not be read "
                                f"({type(e).__name__}: {e})")
            if have is not None:
                need = int(t.debt) + (fee_atoms if fee_paid_in == t.debt_asset else 0)
                got = have.get(str(t.debt_asset).lower(), 0)
                if got < need:
                    note(v.loan_id,
                         f"the wallet holds {got} atoms of the debt asset "
                         f"{t.debt_asset[:8]}; this seizure pays the lender "
                         f"{t.debt}" + (f" plus a {fee_atoms}-atom fee"
                                        if fee_paid_in == t.debt_asset else "")
                         + ", so it cannot be made from here")
                    continue
            if args.dry_run:
                continue
            try:
                _t, _outpoint, single = loans[v.loan_id]
                outpoint = check_coin(v, t, single)
                if outpoint is None:
                    continue
                fee_asset_, fee_amount, rate = fee_for(
                    t, (kind if single else kind + "4"))
                spender = VaultSpender(
                    node, t, fee_asset_, fee_amount, single_leaf=single,
                    dust_fold=F.dust_atoms(rate) if rate else None)
                wants = {t.debt_asset: t.debt}
                wants[fee_asset_] = wants.get(fee_asset_, 0) + fee_amount
                # The preparing sends pay their fee in the SAME asset the
                # exit does. Left unset, `pick_fee` chooses for itself from
                # whatever the wallet holds, so an unattended bot could spend
                # an asset its operator never meant it to touch.
                funding = select_funding(node, wants,
                                         exclude=[(outpoint.txid, outpoint.vout)],
                                         prep_fee_asset=fee_asset_)
                if kind == "liquidate":
                    raw = spender.liquidate(outpoint, funding, evidence, taker_spk)
                else:
                    raw = spender.call_default(outpoint, funding, evidence, taker_spk)
                txid = node.sendrawtransaction(raw)
                warned.pop(v.loan_id, None)
                print(f"  broadcast {txid}", file=sys.stderr)
            except TRANSIENT as e:
                # Losing a race is normal and is not an error worth stopping for:
                # another liquidator got there first and the borrower and lender
                # are no worse off for it.
                print(f"  skipped: {type(e).__name__}: {e}", file=sys.stderr)

        for v in watcher.vaults.values():
            if v.state not in (State.LIVE, State.UNCONFIRMED):
                loans.pop(v.loan_id, None)

    # Start-up talks to the node, the book and the oracles, all of which can be
    # unreachable or misconfigured. One line each, rather than a traceback.
    try:
        if args.taker_spk:
            taker_spk = bytes.fromhex(args.taker_spk)
        else:
            info = node.getaddressinfo(args.taker_address)
            unconf = info.get("unconfidential", args.taker_address)
            info = node.getaddressinfo(unconf)
            taker_spk = bytes.fromhex(info["scriptPubKey"])
            # The wallet's own, unless said otherwise. Every seizure this bot
            # makes pays the surplus there for good; a pasted stranger's
            # address, or a typo, is every seizure given away.
            if not info.get("ismine") and not args.allow_foreign_taker:
                raise SystemExit(
                    f"--taker-address {args.taker_address} is not in this "
                    f"wallet. Every seizure pays its surplus there for good; "
                    f"pass --allow-foreign-taker if that is deliberate.")
        fee_asset = resolve_asset(args.fee_asset) if args.fee_asset else None
        for url in args.oracle or []:
            oracle_key(url)
        if args.loans:
            with open(args.loans) as f:
                track(json.load(f))
        if args.book:
            track(fetch_book_loans(args.book))
    except TRANSIENT as e:
        sys.exit(f"pignus-liquidator: {type(e).__name__}: {e}")

    print(f"watching {len(loans)} loan(s); "
          f"{len(oracle_keys)} oracle(s) named directly"
          + (f", prices also from the book at {args.book}" if args.book else ""),
          file=sys.stderr)

    while True:
        failed = False
        try:
            round_once()
        except Exception as e:                          # noqa: BLE001
            # A round that could not be completed is a round SKIPPED, not the
            # end of the bot -- and the catch is broad here on purpose. The
            # narrow tuple below is right where a particular failure is
            # expected; here the inputs are third-party oracles and a book,
            # and a reply this code did not anticipate raises whatever it
            # raises. A bot that exits on one stops liquidating every loan it
            # was watching, silently, for as long as nobody notices.
            failed = True
            print(f"round: {type(e).__name__}: {e}", file=sys.stderr)
        if args.once:
            return 1 if failed else 0
        time.sleep(args.interval)


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