#!/usr/bin/env python3
# Copyright (c) 2026 The Sequentia developers
# Distributed under the MIT software license.
"""The Pignus price oracle: sign prices, publish them, keep the log.

The oracle is trusted for one number and nothing else (docs/pignus-design.md
section 6.1), and the thing that keeps that trust honest is publication: every
attestation it has ever signed is served, so a fabricated price sits permanently
next to the real ones and a borrower liquidated on a bad one can point at the
exact signed bytes.

    pignus-oracle --config oracle.json

Config (JSON):

    {
      "keyfile":     "/var/lib/pignus/oracle.key",
      "logfile":     "/var/lib/pignus/attestations.log",
      "listen":      "127.0.0.1:8730",
      "interval":    60,
      "price_scale": 100000,
      "markets":     ["GOLD/USDX", "SILVR/USDX", "OILX/USDX", "SEQ/USDX"],
      "precisions":  {"GOLD": 8, "SILVR": 8, "OILX": 8,
                      "SEQ": 8, "USDX": 8},
      "symbols":     {"BTC": "tBTC"},
      "log_max_bytes": 0,
      "source":      {"type": "http_bulk", "url": "http://127.0.0.1:8088/prices"}
    }

`precisions` is the decimal count of each asset named in `markets`. Give every
one of them an entry: a missing or mis-keyed asset is assumed to have 8
decimals, and where that is wrong the signed price is wrong by a power of ten.
`log_max_bytes` turns on self-rotation of the attestation log (0, the default,
never rotates); `source` may take `max_age`, and for an HTTP feed
`feed_max_age`, which is how old the feed's own `_meta.updated` may be before
this oracle refuses to re-sign its numbers.

`source` may instead be `{"type": "static", "prices": {"GOLD": 3000, "USDX": 1}}`,
which is for tests and drills -- and is safe to offer precisely because every
attestation it produces is published like any other.

Endpoints:

    GET /v1/pubkey                 the x-only key vaults must bake in
    GET /v1/markets                the feeds this oracle signs, with feed ids
    GET /v1/attestation/{market}   the latest signed price
    GET /v1/attestation/{market}/at/{ts}   the one signed at exactly that second
    GET /v1/log?market=&n=&since=&until=&cursor=   attestations, recent or by time
    GET /v1/log/raw?file=          the log itself, as ndjson
    GET /v1/digest                 the running hash of the log, and its chain
    GET /v1/seizures               Tier B seizures this oracle has co-signed
    GET /v1/seizure/{sighash}      one of them, with the attestation behind it
    GET /healthz

Co-signing a Tier B (native BTC) seizure is deliberately not an endpoint: it
moves someone's bitcoin, so it is an operator's command, run against the same
config, and it refuses unless this oracle's own published price is under the
strike.

    pignus-oracle --config oracle.json --sign-seize --request seizure.json

The request names the loan, and the loan names its own market and strike, so
neither is given on the command line: `--market` and `--strike` are accepted
only to pin what a request should say, and one that disagrees with the request
is refused rather than followed.
"""

import argparse
import json
import os
import stat
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs

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

from pignus import oracle as O           # noqa: E402
from pignus.ratelimit import RateLimiter            # noqa: E402
from pignus.compat import CovenantUnavailable   # noqa: E402
from pignus.terms import feed_id         # noqa: E402


def load_or_create_key(path, create=True):
    """Read the oracle key, creating it 0600 on first run.

    The key is the whole of the oracle's authority, so it is never logged, never
    returned by an endpoint, and the file mode is checked on every start rather
    than only at creation -- a key that became world-readable at some point is
    exactly what nobody notices.

    Only the serving path creates. Everything else passes `create=False`: a
    mistyped config path, or a machine where the backup was never restored,
    must not answer "what is your key?" with a brand-new one that no vault
    bakes in and that the next start would then adopt.
    """
    if os.path.exists(path):
        # Contents as well as mode. A key file is edited, copied between
        # machines, restored from a backup and pasted into a config, and every
        # one of those can truncate it -- and a truncated file read without a
        # check does not fail here. It fails later, as signatures a borrower's
        # covenant rejects, or as an x-only key naming an oracle that no live
        # loan has ever heard of, with nothing pointing back at the file.
        try:
            return O.read_key(path, "the oracle key")
        except ValueError as e:
            raise SystemExit(
                f"{e}. Restore the backup of the key this oracle's live loans "
                f"were originated against; a new one signs for nothing.")
    if not create:
        raise SystemExit(
            f"no oracle key at {path}. On a fresh install, start the service "
            f"or pass --create-key to make one. If this oracle has signed "
            f"before, restore the backup of its key instead: a new key signs "
            f"for nothing the live loans under the old one will accept, and "
            f"nothing downstream can tell the two apart.")
    sec = O.generate_key()
    d = os.path.dirname(path)
    if d:
        os.makedirs(d, exist_ok=True)
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    with os.fdopen(fd, "w") as f:
        f.write(sec.hex() + "\n")
    print(f"pignus-oracle: CREATED a new key at {path}; back it up now. "
          f"Every vault baked to it can only ever be liquidated by this file.",
          file=sys.stderr)
    return sec


# What each kind of price source understands. A key outside its set is a key
# the operator believes is doing something and is not: `max_age` on an `http`
# source went nowhere at all, so a config asking for a cache got one request
# per market per round and said nothing about it.
SOURCE_KEYS = {
    "static": {"type", "prices"},
    "http": {"type", "url", "timeout", "field", "feed_max_age", "max_age",
             "insecure"},
    "http_bulk": {"type", "url", "timeout", "field", "max_age",
                  "feed_max_age", "insecure"},
}


def build_source(cfg):
    s = cfg.get("source", {})
    kind = s.get("type", "static")
    known = SOURCE_KEYS.get(kind)
    if known is not None:
        # Comments are welcome; anything else is a mistake worth naming.
        odd = sorted(k for k in s
                     if k not in known and not k.startswith("_"))
        if odd:
            raise SystemExit(
                f"the {kind!r} price source does not understand "
                f"{', '.join(odd)}. It takes: {', '.join(sorted(known))}. A "
                f"setting that goes nowhere is one you believe is protecting "
                f"you.")
    if kind == "static":
        return O.StaticPriceSource(s.get("prices", {}))
    if kind in ("http", "http_bulk"):
        # A feed over plain http to anything but this machine is a feed a
        # path in between can rewrite, and this oracle would sign the
        # rewrite. Saying so at start is cheaper than an attestation over it.
        O.check_feed_url(s["url"], insecure=bool(s.get("insecure", False)))
    if kind == "http":
        return O.HttpPriceSource(s["url"], timeout=float(s.get("timeout", 5)),
                                 field=s.get("field", "price"),
                                 feed_max_age=float(s.get("feed_max_age", 0)),
                                 max_age=float(s.get("max_age", 0)))
    if kind == "http_bulk":
        return O.BulkHttpPriceSource(s["url"], timeout=float(s.get("timeout", 8)),
                                     field=s.get("field", "price"),
                                     max_age=float(s.get("max_age", 300)),
                                     feed_max_age=float(s.get("feed_max_age", 0)))
    raise SystemExit(f"unknown price source type: {kind!r}")


class SeizureLog:
    """What this oracle has co-signed on the Bitcoin side, and why.

    Tier B collateral sits under a plain 2-of-2, so a seizure is not enforced by
    a covenant that could refuse it -- the oracle's signature is the decision. A
    lender and a captured oracle together could therefore take collateral that
    was never liquidatable, and the only thing standing against that is this
    file: every co-signature is published next to the attestation that justified
    it, so `seize_is_justified` can be asked afterwards by anyone.
    """

    # Seizures are rare -- a handful over a book's life -- so the whole file is
    # small; this bound is only so that a corrupted or padded one cannot be read
    # into memory by a public request.
    MAX_READ = 256 * 1024

    def __init__(self, path):
        self.path = str(path)

    def append(self, rec: dict) -> None:
        with open(self.path, "a") as f:
            f.write(json.dumps(rec, sort_keys=True, separators=(",", ":")) + "\n")

    def all(self):
        try:
            size = os.path.getsize(self.path)
            with open(self.path, "rb") as f:
                if size > self.MAX_READ:
                    f.seek(size - self.MAX_READ)
                    f.readline()
                lines = f.read().decode(errors="replace").splitlines()
        except OSError:
            return []
        out = []
        for line in lines:
            line = line.strip()
            if not line:
                continue
            try:
                out.append(json.loads(line))
            except json.JSONDecodeError:
                continue
        return out

    def get(self, sighash):
        """One seizure by its sighash, from the WHOLE log.

        Not through `all()`. That reads the last MAX_READ bytes, which is right
        for a listing and wrong here: a 404 from this lookup means "this oracle
        never co-signed that", and the whole of the tier's accountability rests
        on that answer being true. A seizure older than the tail would be
        denied, and the record size is not this oracle's to control -- a
        requester's `loan` object is part of every line.

        One pass, and nothing is parsed until the sighash matches, so the cost
        is reading the file rather than decoding it.
        """
        want = f'"sighash":"{sighash.lower()}"'
        want_spaced = f'"sighash": "{sighash.lower()}"'
        try:
            with open(self.path, "r", errors="replace") as f:
                for line in f:
                    if want not in line and want_spaced not in line:
                        continue
                    try:
                        rec = json.loads(line)
                    except ValueError:
                        continue
                    if str(rec.get("sighash", "")).lower() == sighash.lower():
                        return rec
        except OSError:
            return None
        return None


class Oracle:
    def __init__(self, cfg, create_key=True):
        self.cfg = cfg
        # The config is judged BEFORE a key is touched: a config refused for
        # its precisions must not leave a freshly minted key behind it, and
        # the refusal must be the message, not "no key".
        self.precisions = {k.upper(): int(v)
                           for k, v in cfg.get("precisions", {}).items()}
        self.markets = list(cfg.get("markets", []))
        self._check_precisions()
        self.sec = load_or_create_key(cfg["keyfile"], create=create_key)
        self.xonly = O.xonly_pubkey(self.sec)
        self.log = O.AttestationLog(cfg["logfile"],
                                    max_bytes=int(cfg.get("log_max_bytes", 0)))
        self.seizures = SeizureLog(cfg.get("seizures")
                                   or cfg["logfile"] + ".seizures")
        self.source = build_source(cfg)
        # The peers whose `X-Forwarded-For` this oracle believes. Behind a
        # reverse proxy every request arrives from loopback, and keying a rate
        # limit on that gives the whole internet one bucket -- so one flooder
        # locks every auditor out of the log that exists to be audited.
        self.trusted_proxies = tuple(
            cfg.get("trusted_proxies", ["127.0.0.1", "::1"]))
        # market asset name -> the ticker the feed knows it by
        self.aliases = dict(cfg.get("symbols", {}))
        self.price_scale = int(cfg.get("price_scale", 100_000))
        self.interval = float(cfg.get("interval", 60))
        # How many rounds every market may come back byte-identical before the
        # feed is called frozen. A feed whose upstream died keeps answering 200
        # with last week's numbers, and an oracle that re-signs them with fresh
        # timestamps makes every health figure, every liquidation decision and
        # every check downstream read green on prices nobody observed. Real
        # prices move; a whole board that has not moved in half an hour is a
        # feed that has stopped. A static source is flat by construction and
        # is exempt; anything else defaults to thirty rounds, and 0 turns the
        # guard off for a feed that is genuinely flat.
        static = (cfg.get("source") or {}).get("type") == "static"
        self.flat_rounds = int(cfg.get("flat_rounds", 0 if static else 30))
        self._flat = {}                 # market -> (price, consecutive rounds)
        # An x-only key this oracle used to sign with. Live vaults bake the key
        # they were originated against, so a rotation cannot be a swap: naming
        # the old keys lets a borrower's page tell a rotation from a stranger.
        self.previous_keys = [str(k) for k in cfg.get("previous_keys", [])]
        # Keys this operator declares COMPROMISED, published at /v1/pubkey so
        # a book refuses attestations under them and flags the loans that
        # bake them. A rotation keeps the old key signing; a compromise must
        # not.
        self.compromised_keys = [str(k).lower()
                                 for k in cfg.get("compromised_keys", [])]
        own = {self.xonly.hex().lower()} | {k.lower() for k in self.previous_keys}
        for k in self.compromised_keys:
            if k not in own:
                raise SystemExit(
                    f"compromised_keys names {k[:16]}…, which is neither this "
                    f"oracle's key nor one of its previous_keys. A book honours "
                    f"a declaration only for the declarer's own keys, so this "
                    f"one would be ignored; list the key under previous_keys "
                    f"as well, or drop it.")
        if self.xonly.hex().lower() in self.compromised_keys:
            raise SystemExit(
                "this oracle's own key is listed in its compromised_keys. A "
                "compromised key signs nothing: retire this instance, and "
                "publish the declaration from its successor.")
        # A price that jumps is signed only once it has stayed jumped. A feed
        # that switched units, a column that points at a percentage, an
        # upstream that started quoting grams: each is a perfectly good
        # signature over a number a thousandfold off, and every loan on the
        # market moves on it. `max_jump` is the fraction a price may move
        # from the last signed one before it is held back; `jump_rounds` is
        # how many consecutive rounds it must hold there before it is
        # believed. 0 turns the guard off.
        self.max_jump = float(cfg.get("max_jump", 0.5))
        self.jump_rounds = max(1, int(cfg.get("jump_rounds", 3)))
        self._jump = {}                 # market -> (held price, rounds held)
        # What the journal has been told, so a transition prints once.
        self._said = {"errors": {}, "source": None, "frozen": False,
                      "signed": None}
        self.listen = str(cfg.get("listen", "127.0.0.1:8730"))
        # A book to ask about a take before co-signing its seizure. Optional;
        # without it a seizure of a loan the borrower already repaid can be
        # co-signed, and the record will read as justified.
        self.book = str(cfg.get("book") or "").rstrip("/")
        self.latest = {}
        self.errors = {}
        self.signed_at = {}
        # When each market was last OBSERVED by a round, whether or not that
        # round signed anything new: an attestation carries the observation
        # time, so a feed that is answering and unchanged must not read as a
        # feed that stopped.
        self.seen_at = {}
        self.last_round = 0.0
        # Two different failures, kept apart. `source_error` is the price feed
        # not answering, which is degraded but not yet wrong: the source serves
        # its snapshot until `max_age`, and only then do the markets error.
        # `round_error` is the signing loop itself raising, which is broken.
        self.source_error = None
        self.round_error = None
        self._lock = threading.Lock()

    def _check_precisions(self):
        """Every asset in `markets` needs an explicit decimal count.

        A missing entry is assumed to be 8 decimals, and where that is wrong the
        signed price is wrong by a power of ten -- which no downstream check
        catches, because the signature over it is perfectly good. A config that
        names some assets and not others has made a claim and contradicted it,
        so that is refused; one that names none has made no claim, and is told
        loudly what it is being given.
        """
        need = sorted({s for m in self.markets
                       for s in (p.strip().upper() for p in m.split("/"))})
        self.assumed = [s for s in need if s not in self.precisions]
        if self.assumed and self.precisions:
            raise SystemExit(
                "precisions missing for " + ", ".join(self.assumed) + ": every "
                "asset named in `markets` needs an entry, because a wrong "
                "decimal count signs a price wrong by a power of ten.")

    def market_precisions(self, market):
        c, d = [p.strip().upper() for p in market.split("/")]
        return self.precisions.get(c, 8), self.precisions.get(d, 8)

    def canonical_market(self, name):
        """The configured spelling of a market named in a request, or None.

        Everything the oracle stores is keyed by the string in the config, and
        a feed id is case-insensitive, so a request must be matched to that
        string rather than compared to it.
        """
        want = "/".join(p.strip().upper() for p in str(name).split("/"))
        for m in self.markets:
            if "/".join(p.strip().upper() for p in m.split("/")) == want:
                return m
        return None

    def tick(self):
        """Sign one round of prices. A market that fails is recorded and skipped
        rather than taking the round down: an oracle that stops signing every
        feed because one price source hiccuped is an oracle whose outage reaches
        the RECOVER backstop."""
        try:
            # One snapshot for the whole round, so two markets cannot be signed
            # against feeds that moved between them. A failure here is not fatal
            # to the round: the source decides how long its last snapshot may go
            # on being used, and says so per market below.
            self.source.refresh()
            source_error = None
        except Exception as e:                    # noqa: BLE001 - reported, not raised
            source_error = f"{type(e).__name__}: {e}"
        frozen = False
        if self.flat_rounds:
            # Judged across the WHOLE board, not per market: one market can
            # legitimately sit still, a stablecoin pair most of all, but every
            # market holding the exact same number for thirty rounds is a feed
            # that has stopped, whatever its status code says.
            prices = {}
            for market in self.markets:
                try:
                    prices[market] = self.source.price_for(
                        market, self.precisions, self.price_scale, self.aliases)
                except Exception:                       # noqa: BLE001
                    pass                # the loop below reports it properly
            for market, price in prices.items():
                last, n = self._flat.get(market, (None, 0))
                self._flat[market] = (price, n + 1 if price == last else 1)
            frozen = bool(prices) and all(
                self._flat.get(m, (None, 0))[1] >= self.flat_rounds
                for m in prices)
        for market in self.markets:
            try:
                if frozen:
                    raise RuntimeError(
                        f"the feed has returned the same price for every "
                        f"market for {self.flat_rounds} rounds; it has stopped "
                        f"moving, and a price nobody observed is not signed")
                price = self.source.price_for(market, self.precisions,
                                              self.price_scale, self.aliases)
                self._jump_guard(market, price)
                # Stamped with the OBSERVATION, not the signing. A snapshot
                # re-signed after the feed stopped answering carried a fresh
                # timestamp, so every age check downstream read a number
                # nobody had observed for minutes as fresh. Nothing new
                # observed means nothing new to sign: the last attestation
                # stands, and its age says what it is.
                observed = getattr(self.source, "observed_at", lambda: 0)()
                ts = int(observed or time.time())
                prev = self.latest.get(market)
                if prev is not None and prev.timestamp == ts \
                        and prev.price == price:
                    with self._lock:
                        self.errors.pop(market, None)
                        self.seen_at[market] = time.time()
                    continue
                att = O.sign(self.sec, market, price, self.price_scale,
                             timestamp=ts)
                # Publish only what is on record. An attestation served but not
                # logged is exactly the thing the log exists to make impossible,
                # and a log that cannot be written is an outage, not a detail.
                self.log.append(att)
            except Exception as e:                # noqa: BLE001 - reported, not raised
                with self._lock:
                    self.errors[market] = f"{type(e).__name__}: {e}"
                continue
            with self._lock:
                self.latest[market] = att
                self.signed_at[market] = att.timestamp
                self.seen_at[market] = time.time()
                self.errors.pop(market, None)
        with self._lock:
            self.last_round = time.time()
            self.source_error = source_error
        self._say(frozen, source_error)

    def _jump_guard(self, market, price):
        """Hold a price that jumped until it has stayed jumped."""
        if not self.max_jump:
            return
        ref = self.latest.get(market) or self.log.latest(market)
        ref = int(ref.price) if ref is not None else 0
        if ref <= 0 or abs(price - ref) <= self.max_jump * ref:
            self._jump.pop(market, None)
            return
        # Still on the far side of the jump? A real feed ticks by a few atoms
        # every round; a count that demanded byte-identical prices never
        # reached jump_rounds for a genuine move, and the market stayed
        # unsigned until the price held still.
        held, n = self._jump.get(market, (None, 0))
        n = n + 1 if (held is not None
                      and abs(price - held) <= self.max_jump * max(held, 1)) else 1
        self._jump[market] = (price, n)
        if n < self.jump_rounds:
            raise RuntimeError(
                f"the price moved from {ref} to {price} "
                f"({100.0 * (price - ref) / ref:+.0f}%), further than "
                f"max_jump allows in one step; held {n} of {self.jump_rounds} "
                f"rounds before it is believed. A feed that changed units "
                f"looks exactly like this")
        self._jump.pop(market, None)

    def _say(self, frozen, source_error):
        """Print each TRANSITION to the journal, once. `/healthz` knows
        everything; an operator reading `journalctl` after a feed change saw
        request lines and nothing else while the oracle had stopped signing."""
        with self._lock:
            errs = dict(self.errors)
            signed = sum(1 for m in self.markets if m in self.latest
                         and m not in errs)
        said = self._said
        for m, e in errs.items():
            if said["errors"].get(m) != e:
                print(f"pignus-oracle: {m}: not signing -- {e}", file=sys.stderr)
        for m in said["errors"]:
            if m not in errs:
                print(f"pignus-oracle: {m}: signing again", file=sys.stderr)
        if source_error != said["source"]:
            print(f"pignus-oracle: feed: "
                  f"{source_error or 'answering again'}", file=sys.stderr)
        skew = getattr(self.source, "clock_skew", None)
        far = skew is not None and abs(skew) > O.CLOCK_TOLERANCE
        if far != said.get("skew", False):
            print(f"pignus-oracle: this machine's clock is {skew:+.0f}s from "
                  f"the feed's Date header" + (
                      "; if the feed's clock is right, attestations stamped "
                      "here will be refused downstream as from the future or "
                      "already stale -- run NTP" if far else "; back in range")
                  if skew is not None else "", file=sys.stderr)
        if frozen != said["frozen"]:
            print("pignus-oracle: the feed is frozen; signing has stopped"
                  if frozen else "pignus-oracle: the feed is moving again",
                  file=sys.stderr)
        if signed != said["signed"] and (said["signed"] is not None
                                          or signed < len(self.markets)):
            print(f"pignus-oracle: signed {signed} of {len(self.markets)} "
                  f"markets", file=sys.stderr)
        self._said = {"errors": errs, "source": source_error,
                      "frozen": frozen, "signed": signed, "skew": far}

    def run_forever(self, interval):
        """The signing loop.

        It must not be able to end quietly. If this thread dies the process
        lives on serving its last attestation as though it were current, which
        is the failure nobody notices; so a round that raises is reported, and a
        run of them exits the process for the supervisor to restart.
        """
        failures = 0
        while True:
            started = time.time()
            try:
                self.tick()
                failures = 0
                with self._lock:
                    self.round_error = None
            except Exception as e:                # noqa: BLE001
                failures += 1
                with self._lock:
                    self.round_error = f"{type(e).__name__}: {e}"
                print(f"pignus-oracle: signing round failed ({failures}): "
                      f"{type(e).__name__}: {e}", file=sys.stderr)
                if failures >= 5:
                    print("pignus-oracle: five rounds in a row failed; exiting "
                          "so the supervisor restarts a clean process",
                          file=sys.stderr)
                    sys.stderr.flush()
                    os._exit(1)
            time.sleep(max(1.0, interval - (time.time() - started)))

    # ---------------------------------------------------------- Tier B seizure

    def _daemon_agrees(self, market):
        import urllib.request
        try:
            with urllib.request.urlopen(f"http://{self.listen}/healthz",
                                        timeout=5) as r:
                h = json.loads(r.read().decode())
        except Exception as e:                          # noqa: BLE001
            if getattr(e, "code", None) == 503:
                try:
                    h = json.loads(e.read().decode())
                except Exception:                       # noqa: BLE001
                    h = {"ok": False}
            else:
                raise SystemExit(
                    f"REFUSED: the running oracle at {self.listen} could not "
                    f"be asked whether it still signs {market} "
                    f"({type(e).__name__}: {e}). A seizure is co-signed "
                    f"against a price the daemon stands behind now, not one "
                    f"it may have stopped signing; start it, or pass "
                    f"--allow-stale if the last attestation is genuinely "
                    f"intended.")
        # THIS market. Another market erroring is the daemon's problem with
        # that market, not a reason this one's price is not stood behind.
        why = (h.get("errors") or {}).get(market)
        if why or market in (h.get("stale") or []):
            raise SystemExit(
                f"REFUSED: the running oracle has stopped signing {market}"
                + (f": {why}" if why else " (its last round did not sign it)")
                + ". Its last attestation may still be under --max-age, but "
                "a price the daemon itself no longer stands behind justifies "
                "nothing. Wait for it to sign again, or pass --allow-stale.")

    def _take_is_live(self, loan):
        """Refuse a seizure of a loan the book says is over."""
        import urllib.request
        me = self.xonly.hex()
        try:
            with urllib.request.urlopen(
                    f"{self.book}/v1/btc/takes?oracle_x={me}", timeout=10) as r:
                takes = json.loads(r.read().decode()).get("takes") or []
        except Exception as e:                          # noqa: BLE001
            raise SystemExit(
                f"REFUSED: the book at {self.book} could not be asked whether "
                f"this loan is still open ({type(e).__name__}: {e}). Pass "
                f"--allow-unlisted to co-sign without asking.")
        bx = str(loan.get("borrower_x", "")).lower()
        hw = str(loan.get("h_w", "")).lower()
        hit = [t for t in takes
               if str((t.get("loan") or {}).get("borrower_x", "")).lower() == bx
               and str((t.get("loan") or {}).get("h_w", "")).lower() == hw]
        if not hit:
            raise SystemExit(
                f"REFUSED: the book at {self.book} serves no take for this "
                f"loan under this oracle's key. A loan arranged by hand never "
                f"passed a relay; pass --allow-unlisted if that is the case.")
        status = str(hit[0].get("status", ""))
        if status not in ("signed", "disbursed", "live"):
            raise SystemExit(
                f"REFUSED: the book says this loan is '{status}', not live. "
                f"A borrower who has repaid, or a take that ended, is not one "
                f"whose collateral a seizure may take; the published record "
                f"would read as justified.")

    def sign_seizure(self, sighash: bytes, market: str, strike: int,
                     loan=None, offer_sig="", offer_lots=1, loan_scale=None,
                     max_age=600, allow_stale=False, allow_unlisted=False):
        """Co-sign a native-BTC seizure, and publish what justified it.

        Tier B has no covenant: the Bitcoin script asks only for the lender's
        and this oracle's signatures, so this signature IS the liquidation
        decision. It is made against this oracle's own last published price for
        the market, and both the signature and that attestation are appended to
        the seizure log, so `seize_is_justified` can be asked by anyone
        afterwards -- which is the whole of the accountability this tier has.
        """
        if len(sighash) != 32:
            raise SystemExit(f"a seize sighash is 32 bytes, got {len(sighash)}")
        known = self.canonical_market(market)
        if known is None:
            raise SystemExit(f"this oracle does not sign {market}; it signs "
                             + ", ".join(self.markets))
        market = known
        # THIS ORACLE'S OWN KEY, in the loan being judged. A seizure of a loan
        # that names somebody else's oracle is one this signature cannot
        # authorise -- the Bitcoin script asks for the key the loan baked in --
        # so co-signing it produces a signature nothing accepts while the
        # published record says this oracle approved a seizure it had no part
        # in. Only checkable when a loan is given; the bare path is refused
        # outright elsewhere for the same reason.
        if loan:
            keys = {str(loan.get("oracle_x", "")).lower()}
            keys |= {str(k).lower() for k in (loan.get("oracles") or [])}
            keys.discard("")
            if keys and self.xonly.hex().lower() not in keys:
                raise SystemExit(
                    "REFUSED: that loan names oracle "
                    + ", ".join(sorted(k[:16] + "…" for k in keys))
                    + f" and this oracle's key is {self.xonly.hex()[:16]}…. "
                    "The Bitcoin script asks for the key the loan baked in, so "
                    "this signature would authorise nothing -- while the "
                    "published record said this oracle had approved a seizure "
                    "it has no part in.")
        # The running daemon's own verdict on this market. This is a fresh
        # process reading the log; the daemon may have stopped signing the
        # market minutes ago -- a frozen feed, an erroring one -- while the
        # last attestation is still under --max-age. What it stopped signing
        # it does not co-sign against.
        if self.xonly.hex().lower() in self.compromised_keys:
            raise SystemExit("REFUSED: this oracle's own key is listed in its "
                             "compromised_keys. A key declared compromised "
                             "co-signs nothing; retire it.")
        if not allow_stale:
            self._daemon_agrees(market)
        if loan and self.book and not allow_unlisted:
            self._take_is_live(loan)
        att = self.latest.get(market) or self.log.latest(market)
        if att is None:
            raise SystemExit(f"this oracle has signed no price for {market}; "
                             "there is nothing to justify a seizure with")
        # TWO-SIDED. A one-sided age test treats every timestamp in the future
        # as infinitely fresh, so a clock that ran fast when the price was
        # signed keeps that price acceptable for as long as the drift lasts --
        # and what it would be acceptable FOR is taking somebody's collateral.
        age = O.age_of(att)
        if not O.current(att, max_age) and not allow_stale:
            when = (f"signed {age}s ago (max {int(max_age)})" if age >= 0 else
                    f"dated {-age}s in the FUTURE, which no honest clock is")
            raise SystemExit(
                f"REFUSED: the last price for {market} was {when}. Nothing on "
                "the Bitcoin side can check when a price was signed, so a "
                "seizure justified by one nobody stands behind is justified by "
                "nothing. Wait for a fresh attestation, or pass --allow-stale "
                "if the age is genuinely intended.")
        if not O.verify(self.xonly, att, self.price_scale):
            raise SystemExit("the last attestation for that market does not "
                             "verify against this oracle's own key and scale; "
                             "refusing to co-sign against it")
        # THE SAME SCALE, first. A price and a strike are both integers scaled
        # by `price_scale`, and the scale is a property of the LOAN, not of
        # this oracle. Comparing a price quoted at 1e5 against a strike written
        # at 1e8 compares two numbers that mean the same thing and differ by a
        # thousand: the price looks far below the strike, and this oracle
        # co-signs the seizure of a perfectly healthy loan. Nothing downstream
        # can catch it -- the scale is in no Bitcoin script, and the covenant
        # on the Sequentia side is not involved in a Tier B seizure at all.
        if loan_scale is None:
            raise SystemExit(
                "REFUSED: this seizure does not say what price scale the loan "
                "is written at. A strike is an integer scaled by it and this "
                "oracle signs at its own, so comparing them without knowing "
                "both would decide the seizure by a power of ten. Use "
                "--request, which carries the loan and its scale, or give "
                "--price-scale.")
        if int(loan_scale) != int(self.price_scale):
            raise SystemExit(
                f"REFUSED: this loan is written at price scale {loan_scale} "
                f"and this oracle signs at {self.price_scale}. The same real "
                f"price is a different NUMBER at each, so comparing them would "
                f"decide the seizure on a factor of "
                f"{int(loan_scale) / int(self.price_scale):g}. An oracle "
                f"signing at another scale cannot judge this loan; the loan "
                f"needs one that signs at its own.")
        if att.price >= int(strike):
            raise SystemExit(
                f"REFUSED: the last price for {market} is {att.price}, at or "
                f"above the strike {strike} (both at price scale "
                f"{self.price_scale}). This seizure is not justified, and "
                "co-signing it would be taking collateral that was never "
                "liquidatable.")
        from pignus import adaptor as A
        sig = A.sign(self.sec, sighash)
        rec = {"sighash": sighash.hex(), "signature": sig.hex(),
               "market": market, "strike": int(strike),
               "attestation": att.to_dict(), "ts": int(time.time()),
               "oracle_x": self.xonly.hex(),
               # Enough for a third party to re-check the whole judgement, not
               # just the price. The published record used to carry the strike
               # and no way to tell whose strike it was -- so a borrower
               # disputing a seizure could see the number the oracle judged by
               # and had nothing to hold it to. The loan says which loan, and
               # the lender's offer signature says the strike is the one they
               # published rather than one they typed on the day.
               "loan": loan or {},
               "offer_sig": str(offer_sig or ""),
               "offer_lots": int(offer_lots or 1)}
        self.seizures.append(rec)
        return rec


ARCHIVE_LIMIT = RateLimiter(rate=1.0, burst=10)
# And one for everyone together. A per-address bucket alone is walked around by
# spreading the flood over many source addresses -- free for an attacker, and
# the ordinary shape of one -- which would leave this process reading the log
# off disk in a loop instead of signing. The book has had both since it was
# written; this had one, and a copy of a limiter is how that happens.
GLOBAL_ARCHIVE_LIMIT = RateLimiter(rate=20.0, burst=200)


class Handler(BaseHTTPRequestHandler):
    oracle = None
    server_version = "pignus-oracle"
    # A thread per connection with no cap is fine only while no connection can
    # hold one open indefinitely.
    timeout = 30

    # A signing service serves JSON and one log; none of it is for framing,
    # sniffing into HTML, or telling the next site where a reader came from.
    SECURITY_HEADERS = (
        ("Content-Security-Policy", "frame-ancestors 'none'"),
        ("X-Frame-Options", "DENY"),
        ("X-Content-Type-Options", "nosniff"),
        ("Referrer-Policy", "no-referrer"),
    )

    def _security_headers(self):
        for k, v in self.SECURITY_HEADERS:
            self.send_header(k, v)

    def version_string(self):
        return "pignus-oracle"    # not the Python version behind it

    def _send(self, code, payload):
        body = json.dumps(payload, indent=2).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self._security_headers()
        # Attestations are public by design; a liquidator, a borrower and a
        # curious third party should all be able to read them from anywhere.
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self.wfile.write(body)

    def _send_file(self, fh, size, name):
        """Stream a log file out in fixed-size blocks, so serving a file this
        process must never hold in memory does not put it there."""
        try:
            self.send_response(200)
            self.send_header("Content-Type", "application/x-ndjson")
            self.send_header("Content-Length", str(size))
            self._security_headers()
            self.send_header("Content-Disposition",
                             f'attachment; filename="{name}"')
            self.send_header("Access-Control-Allow-Origin", "*")
            self.end_headers()
            left = size
            while left > 0:
                chunk = fh.read(min(65536, left))
                if not chunk:
                    break
                self.wfile.write(chunk)
                left -= len(chunk)
        except (BrokenPipeError, ConnectionResetError):
            pass          # the client stopped the download; nothing to report
        finally:
            fh.close()

    def _client_key(self):
        """Who to charge a log query to.

        Behind a reverse proxy every request arrives from loopback, so keying
        on the socket peer gives the WHOLE INTERNET one bucket: one flooder
        then locks every auditor out of the log that exists to be audited. The
        forwarded-for header is believed only from a peer configured as a
        proxy, and then only its LAST hop -- earlier entries are whatever the
        client claimed. A trusted peer with no such header is the box's own
        tooling, which is not a public client and is not limited.

        The same rule `pignusd` uses, deliberately: two answers to this
        question is one of them being wrong.
        """
        peer = self.client_address[0] if self.client_address else ""
        if peer in getattr(self.oracle, "trusted_proxies",
                           ("127.0.0.1", "::1")):
            fwd = (self.headers.get("X-Forwarded-For")
                   or self.headers.get("X-Real-IP") or "")
            hop = fwd.split(",")[-1].strip()
            return hop or None
        return peer

    def _archive_ok(self):
        key = self._client_key()
        if key is None:
            return True                 # the box's own tooling
        now = time.time()
        if ARCHIVE_LIMIT.allow(key, now) and GLOBAL_ARCHIVE_LIMIT.allow("", now):
            return True
        self._send(429, {"error": "too many log queries; the log is "
                                  "append-only, so a page at a time reads the "
                                  "same bytes a flood would"})
        return False

    def log_message(self, fmt, *args):
        sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))

    def _health(self, o):
        """`ok` means "this oracle is signing", not "this process is running".

        A dead signing thread, an unwritable log or a feed that stopped
        answering all leave the process happily serving its last attestation;
        saying ok to that is how an outage stays invisible until it reaches the
        RECOVER backstop. So a round that has not completed within two intervals
        is not ok, and the answer carries 503 for anything that only reads the
        status code.
        """
        now = time.time()
        with o._lock:
            errs = dict(o.errors)
            signed_at = dict(o.seen_at)
            last_round = o.last_round
            round_error, source_error = o.round_error, o.source_error
        # Two intervals, and never less than half a minute: a one-second
        # interval is a test's, and health that flaps on ordinary scheduling
        # jitter tells an operator nothing.
        window = max(2 * o.interval, 30.0)
        stale = sorted(m for m in o.markets
                       if now - signed_at.get(m, 0) > window)
        age = None if not last_round else round(now - last_round, 1)
        ok = (not errs and not stale and round_error is None
              and last_round > 0 and now - last_round <= window)
        skew = getattr(o.source, "clock_skew", None)
        signed = sum(1 for m in o.markets if m not in errs and m not in stale)
        with o._lock:
            last_signed = max(o.signed_at.values(), default=None)
        return ok, {"ok": ok, "markets": len(o.markets), "signed": signed,
                    "last_signed": last_signed,
                    "errors": errs, "stale": stale, "round_error": round_error,
                    "source_error": source_error,
                    "clock_skew": None if skew is None else round(skew, 1),
                    "last_round": int(last_round) or None, "age": age}

    def do_GET(self):
        o = self.oracle
        u = urlparse(self.path)
        path = u.path.rstrip("/") or "/"
        q = parse_qs(u.query)
        try:
            if path == "/healthz":
                ok, body = self._health(o)
                return self._send(200 if ok else 503, body)
            if path == "/v1/pubkey":
                return self._send(200, {"oracle_x": o.xonly.hex(),
                                        "price_scale": o.price_scale,
                                        "previous": list(o.previous_keys),
                                        "compromised": list(o.compromised_keys)})
            if path == "/v1/markets":
                with o._lock:
                    errs = dict(o.errors)
                out = []
                for m in o.markets:
                    cp, dp = o.market_precisions(m)
                    out.append({"market": m, "feed_id": feed_id(m).hex(),
                                "price_scale": o.price_scale,
                                "collateral_precision": cp,
                                "debt_precision": dp,
                                "error": errs.get(m)})
                return self._send(200, {"markets": out})
            if path == "/v1/digest":
                return self._send(200, {
                    "digest": o.log.digest(),
                    "chained_from": o.log.chained_from,
                    "file": os.path.basename(o.log.path),
                    "files": o.log.files()})
            if path == "/v1/seizures":
                if not self._archive_ok():
                    return None
                return self._send(200, {"seizures": o.seizures.all()})
            if path.startswith("/v1/seizure/"):
                if not self._archive_ok():
                    return None
                rec = o.seizures.get(path[len("/v1/seizure/"):])
                if rec is None:
                    return self._send(404, {"error": "this oracle has co-signed "
                                                     "no seizure with that sighash"})
                return self._send(200, rec)
            if path.startswith("/v1/attestation/"):
                return self._attestation(o, path[len("/v1/attestation/"):])
            if path == "/v1/log/raw":
                if not self._archive_ok():
                    return None
                name = q.get("file", [None])[0]
                try:
                    fh, size = o.log.open_file(name)
                except (FileNotFoundError, OSError):
                    return self._send(404, {
                        "error": "no such log file",
                        "files": [r["file"] for r in o.log.files()]})
                return self._send_file(fh, size, name or
                                       os.path.basename(o.log.path))
            if path == "/v1/log":
                return self._log(o, q)
            return self._send(404, {"error": "no such endpoint", "path": path})
        except Exception as e:                    # noqa: BLE001
            return self._send(500, {"error": f"{type(e).__name__}: {e}"})

    def _attestation(self, o, rest):
        parts = [p for p in rest.split("/") if p]
        asked = parts[0].replace("_", "/") if parts else ""
        # An unknown market is answered before the log is consulted: naming
        # anything at all must not cost a disk read.
        market = o.canonical_market(asked)
        if market is None:
            return self._send(404, {
                "error": f"this oracle does not sign {asked.upper()}",
                "markets": o.markets})
        if len(parts) == 3 and parts[1] == "at":
            # The auditor's question: the exact attestation a liquidation was
            # built on, whatever its age.
            if not self._archive_ok():
                return None
            try:
                ts = int(parts[2])
            except ValueError:
                return self._send(400, {"error": "a timestamp is unix seconds"})
            att = o.log.at(market, ts)
            if att is None:
                return self._send(404, {
                    "error": f"no attestation for {market} at {ts}",
                    "detail": "the timestamp must be the one in the attestation, "
                              "to the second"})
            return self._send(200, att.to_dict())
        if len(parts) != 1:
            return self._send(404, {"error": "no such endpoint", "path": rest})
        with o._lock:
            att = o.latest.get(market)
            error = o.errors.get(market)
        if att is None:
            # The fallback reads the log files END TO END, every one of them,
            # and this endpoint is public. A market whose feed is down has no
            # attestation in memory, so a client asking for it in a loop had
            # this process reading its whole archive off disk for each request
            # while the signing thread waited for the disk. So the fallback is
            # rationed like the other archive reads, and a miss is remembered
            # for a minute rather than searched for again on the next request.
            if not self._archive_ok():
                return
            miss = getattr(o, "_disk_miss", None)
            if miss is None:
                miss = o._disk_miss = {}
            now = time.time()
            if now - miss.get(market, 0) < 60:
                att = None
            else:
                att = o.log.latest(market)
                if att is None:
                    miss[market] = now
        if att is None:
            return self._send(404, {"error": f"no attestation for {market}",
                                    "detail": error, "markets": o.markets})
        # The signed fields are untouched; `age` and `stale` are this server
        # describing what it is serving, because a consumer cannot see from a
        # valid signature that the feed behind it stopped moving an hour ago.
        return self._send(200, dict(att.to_dict(),
                                    age=int(time.time()) - att.timestamp,
                                    stale=bool(error), error=error))

    def _log(self, o, q):
        market = (q.get("market", [None])[0] or None)
        if market:
            asked = market.replace("_", "/")
            market = o.canonical_market(asked) or asked.upper()
        try:
            n = min(int(q.get("n", ["50"])[0]), 1000)
            since = q.get("since", [None])[0]
            until = q.get("until", [None])[0]
            cursor = q.get("cursor", [None])[0]
            since = None if since is None else int(since)
            until = None if until is None else int(until)
            cursor = None if cursor is None else int(cursor)
        except ValueError:
            return self._send(400, {"error": "n, since, until and cursor are "
                                             "integers"})
        if n < 1:
            return self._send(400, {"error": "n must be at least 1"})
        if since is None and until is None and cursor is None:
            # The common case, and the one the page links to: the recent view,
            # held in memory.
            return self._send(200, {"attestations": [
                a.to_dict() for a in o.log.tail(n, market)]})
        if not self._archive_ok():
            return None
        rows, next_cursor = o.log.scan(market=market, since=since, until=until,
                                       limit=n, cursor=cursor)
        return self._send(200, {"attestations": [a.to_dict() for a in rows],
                                "cursor": next_cursor})


def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--config", required=True)
    ap.add_argument("--once", action="store_true",
                    help="sign one round and exit, without serving")
    ap.add_argument("--print-pubkey", action="store_true",
                    help="print the oracle x-only key and exit")
    ap.add_argument("--sign-seize", action="store_true",
                    help="co-sign a native-BTC seizure sighash, and publish "
                         "the attestation that justifies it")
    ap.add_argument("--sighash", help="with --sign-seize: the 32-byte sighash "
                                      "from `pignus-cli btc-seize-sighash`")
    ap.add_argument("--request", help="with --sign-seize: the seizure request "
                                      "file `pignus-cli btc-seize-sighash "
                                      "--out` writes. Preferred over --sighash: "
                                      "it carries the loan, so this oracle "
                                      "rebuilds the sighash from the terms "
                                      "rather than signing a number somebody "
                                      "else computed")
    ap.add_argument("--allow-unpinned-strike", action="store_true",
                    help="with --sign-seize --request: co-sign a request that "
                         "carries no lender-signed offer. The strike is in no "
                         "Bitcoin script, so without that signature nothing "
                         "stops the lender naming any strike they like and "
                         "having a healthy loan judged against it. Only for a "
                         "loan arranged entirely by hand, where the operator "
                         "is the one vouching for the terms")
    ap.add_argument("--market", help="with --sign-seize: the market the loan "
                                     "is priced in")
    ap.add_argument("--strike", type=int,
                    help="with --sign-seize: the loan's strike; the seizure is "
                         "refused unless the published price is under it")
    ap.add_argument("--price-scale", type=int, default=None,
                    help="with --sign-seize and --strike: the LOAN's price "
                         "scale. A strike is an integer scaled by it, and this "
                         "oracle signs at its own -- comparing two numbers "
                         "written at different scales decides the seizure by a "
                         "power of ten. --request carries it and is preferred")
    ap.add_argument("--max-age", type=int, default=600, metavar="S",
                    help="with --sign-seize: refuse to co-sign against a price "
                         "signed longer ago than this (default 600 s)")
    ap.add_argument("--create-key", action="store_true",
                    help="make a new key at `keyfile` when there is none. The "
                         "serving path creates one on its own only on a fresh "
                         "install, where no attestation log exists yet")
    ap.add_argument("--allow-unlisted", action="store_true",
                    help="with --sign-seize: co-sign without asking the "
                         "configured book whether the loan is still open")
    ap.add_argument("--allow-stale", action="store_true",
                    help="with --sign-seize: co-sign against a price older "
                         "than --max-age anyway")
    args = ap.parse_args()

    with open(args.config) as f:
        cfg = json.load(f)
    # Only the SERVING path may create a key. Asking what the key is must never
    # answer with a new one -- and neither must signing a seizure: an oracle
    # whose keyfile has gone missing and signs with a fresh key produces a
    # signature no loan on any chain accepts, quietly, where the operator
    # believes a seizure has been authorised.
    # When a key may be MADE. Asking what the key is, or asking it to sign
    # anything, must never answer with a new one -- and neither may a service
    # start on a machine that holds this oracle's attestation log but not its
    # key: that is a lost key, and a fresh one would sign for nothing the live
    # loans accept while every check downstream stayed green.
    fresh = not os.path.exists(cfg["logfile"])
    create = args.create_key or (
        not (args.print_pubkey or args.sign_seize or args.once) and fresh)
    if os.path.exists(cfg["keyfile"]) and fresh \
            and not (args.print_pubkey or args.sign_seize):
        print(f"pignus-oracle: the key at {cfg['keyfile']} exists but no "
              f"attestation log does at {cfg['logfile']}: the log was lost, or "
              f"this is a restore that left it behind. Every earlier "
              f"liquidation under this key will read as one this oracle never "
              f"co-signed. Restore the log, its .chain and .seizures from the "
              f"same backup as the key, or publish the gap.", file=sys.stderr)
    o = Oracle(cfg, create_key=create)

    if args.print_pubkey:
        print(o.xonly.hex())
        return
    if args.sign_seize:
        if args.request:
            # Rebuild the sighash from the loan in the request. An oracle that
            # signs a number it was handed has read nothing: the number could
            # seize a healthy loan, or somebody else's.
            import json as _json
            from pignus import btc_collateral as _BC
            with open(args.request) as fh:
                req = _json.load(fh)
            loan, want = _BC.check_seize_request(
                req, require_offer=not args.allow_unpinned_strike)
            args.sighash = want
            # The LOAN's own market and strike, always. These are what the
            # signature is logged against and what a borrower later holds the
            # oracle to; letting a command line override them would let the
            # party asking for a seizure choose the terms it is judged by.
            for name, mine, theirs in (("--market", args.market, loan.market),
                                       ("--strike", args.strike,
                                        int(loan.strike))):
                if mine and str(mine) != str(theirs):
                    raise SystemExit(
                        f"{name} says {mine} and the loan in that request says "
                        f"{theirs}. The loan decides: remove {name}.")
            args.market = loan.market
            args.strike = int(loan.strike)
            # The loan's OWN price scale. A price and a strike are integers
            # scaled by it, and comparing two written at different scales
            # decides a seizure by a factor of ten to some power.
            seize_scale = int(loan.price_scale)
            # Kept for the published record, so a third party can re-check the
            # judgement rather than only the price.
            # The loan this oracle actually JUDGED, not the requester's raw
            # object. Every key it does not use is dropped before the sighash
            # is rebuilt, so keeping them would put an unbounded amount of
            # somebody else's filler into a log every reader of this tier's
            # accountability has to page through.
            seize_loan = _BC.loan_to_dict(loan)
            seize_offer_sig = str(req.get("offer_sig") or "")
            seize_offer_lots = int(req.get("offer_lots", 1) or 1)
        else:
            # NOTHING is pinned on this path. Without a request there is no
            # loan, so the sighash is 32 bytes somebody else computed and this
            # oracle cannot rebuild; and no lender-signed offer, so the strike
            # it is judged against is one the seizing party typed. The strike
            # is in no Bitcoin script, so a lender naming any figure above
            # today's price passes the price check below and takes the
            # collateral of a loan that was never liquidatable -- which is the
            # exact act this oracle's own documentation says it refuses.
            #
            # The same explicit opt-in as a request that carries no offer
            # signature, and for the same reason.
            if not args.allow_unpinned_strike:
                raise SystemExit(
                    "--sign-seize with a bare --sighash pins nothing: there is "
                    "no loan for this oracle to rebuild the sighash from, and "
                    "no lender-signed offer pinning the strike, so the number "
                    "it would be judged against is one the party asking for "
                    "the seizure chose. A strike above today's price passes "
                    "every check here and takes collateral from a healthy "
                    "loan.\n\nUse --request <file>, which carries the loan and "
                    "the lender's own signature over its terms. "
                    "--allow-unpinned-strike says you have checked this "
                    "seizure by hand and accept that the published record "
                    "cannot show anyone else why it was justified.")
        missing = [f for f, v in (("--sighash", args.sighash),
                                  ("--market", args.market),
                                  ("--strike", args.strike)) if not v]
        if "seize_scale" not in locals():
            # Not stated: `sign_seizure` refuses at the point the scale
            # matters, after the simpler faults it can name first.
            seize_scale = (int(args.price_scale)
                           if args.price_scale is not None else None)
        if missing:
            raise SystemExit("--sign-seize needs " + ", ".join(missing))
        try:
            sighash = bytes.fromhex(args.sighash)
        except ValueError:
            raise SystemExit("--sighash is hex") from None
        rec = o.sign_seizure(sighash, args.market, args.strike,
                             loan=locals().get("seize_loan"),
                             offer_sig=locals().get("seize_offer_sig", ""),
                             offer_lots=locals().get("seize_offer_lots", 1),
                             loan_scale=locals().get("seize_scale"),
                             max_age=args.max_age,
                             allow_stale=args.allow_stale,
                             allow_unlisted=args.allow_unlisted)
        print(json.dumps(rec, indent=2))
        print(f"pignus-oracle: co-signed, and published in {o.seizures.path}",
              file=sys.stderr)
        return
    if o.assumed:
        # Said once, on the paths that actually sign.
        print("pignus-oracle: no `precisions` in the config; assuming 8 decimals "
              "for " + ", ".join(o.assumed) + ". Set them: an asset with other "
              "decimals would be signed wrong by a power of ten.",
              file=sys.stderr)
    if args.once:
        o.tick()
        signed = 0
        for m in o.markets:
            att = o.latest.get(m)
            if att:
                signed += 1
            print(f"{m:16s} {att.price if att else '-':>14}  "
                  f"{o.errors.get(m, 'ok')}")
        # An exit status that says what happened. `--once` is what a cron job
        # and an operator checking a new install both run, and exiting 0 with
        # a column of dashes tells neither of them that the price feed is
        # unreachable -- which is the whole thing they were checking.
        if not signed:
            print(f"pignus-oracle: signed NOTHING. {len(o.markets)} market(s) "
                  f"configured and no price could be fetched for any of them. "
                  f"The feed is unreachable, or the markets are named "
                  f"differently there.", file=sys.stderr)
            return 1
        if signed < len(o.markets):
            print(f"pignus-oracle: signed {signed} of {len(o.markets)} "
                  f"markets; the rest are named above with their reasons.",
                  file=sys.stderr)
            return 1
        return 0

    host, _, port = cfg.get("listen", "127.0.0.1:8730").rpartition(":")
    # One round, in the signing thread, which starts at once. A round here
    # AND one in the thread signed every market twice in the same second,
    # and when the feed moved between them `/at/{ts}` served the first.
    threading.Thread(target=o.run_forever, args=(o.interval,),
                     daemon=True).start()
    Handler.oracle = o
    srv = ThreadingHTTPServer((host or "127.0.0.1", int(port)), Handler)
    srv.daemon_threads = True
    print(f"pignus-oracle: key {o.xonly.hex()}", file=sys.stderr)
    print(f"pignus-oracle: signing {len(o.markets)} market(s) every "
          f"{o.interval:g}s, serving on {host}:{port}", file=sys.stderr)
    srv.serve_forever()


if __name__ == "__main__":
    # `main` returns a status on the paths that have one to report -- `--once`
    # says whether it actually signed anything -- so a cron job and an operator
    # checking a fresh install both learn what happened.
    try:
        sys.exit(main() or 0)
    except CovenantUnavailable as e:
        # A sentence and status 3, the same as `pignus-cli` and `pignusd`. This
        # was the one binary that answered a missing covenant with a traceback,
        # which reads as a crash rather than as "point SEQUENTIA_SRC at a
        # checkout" -- and it is the first thing a new operator hits.
        print(f"error: {e}", file=sys.stderr)
        sys.exit(3)
    except KeyboardInterrupt:
        sys.exit(130)
