git / moonbite-network / swap_verifier.py · raw

"""Phase 2b — on-chain HTLC atomic-swap verifier (trustless observer).The exchange server COORDINATES a cross-chain HTLC swap (see exchange.py) butnever custodies coins or keys. This module is the read-only verifier that turnsa self-reported ``both_locked`` swap into a genuinely on-chain-verified``settled`` trade — the single gate that lets last_price move.It does exactly one thing: READ two chains (via a small adapter interface) anddecide, from what is actually confirmed on-chain, how far a swap has progressed:    both_locked ── quote redeemed (preimage now public) ──▶ quote_redeemed                ── base redeemed with that same preimage  ──▶ base_redeemed                ── both redemptions confirmed to depth    ──▶ settled                ── a refund branch confirmed / timed out   ──▶ expiredInvariants this file upholds:  * The server never learns the preimage before it is PUBLIC on-chain. We only    ever read it out of a confirmed spend of the quote HTLC.  * Nothing is taken on the caller's word: funding/redeem txids are only HINTS    telling the verifier where to look; every fact is re-derived from chain data    (script match, amount, confirmations, SHA256(preimage) == hashlock).  * No transaction is ever built, signed, or broadcast here. Read-only.The pure script helpers (redeemscript assembly, scriptPubKey derivation,preimage extraction) are deterministic and unit-tested without a node. The nodepath (MoonNodeAdapter) wraps explorer.rpc.RpcClient and is exercised end-to-endon regtest with the real moonbited binary."""from __future__ import annotationsimport hashlibfrom decimal import Decimalfrom typing import Iterable, Optional# States added by Phase 2b, beyond exchange.SWAP_STATES (2a). Kept here so the# verifier owns the on-chain half of the machine; exchange.py imports these.VERIFIER_STATES = (    "quote_redeemed",  # quote HTLC spent via hashlock branch; preimage is public    "base_redeemed",   # base (MBITE) HTLC spent with that same preimage    "settled",         # both redemptions confirmed to depth; feeds last_price    "expired",         # a refund branch confirmed / timelock elapsed — never settles)# Confirmation depth required before a fact is trusted (reorg safety). The quote# chain (BTC especially) is slower/heavier, so it can be tuned independently.DEFAULT_MIN_CONFS_BASE = 6DEFAULT_MIN_CONFS_QUOTE = 6# --------------------------------------------------------------------------- ## Bitcoin Script primitives — just enough to SERIALIZE an HTLC redeemscript and# derive its funding scriptPubKey(s). No interpreter, no signing. MoonBite is a# Litecoin fork, so this is standard Bitcoin Script.# --------------------------------------------------------------------------- #OP_0 = 0x00OP_IF = 0x63OP_ELSE = 0x67OP_ENDIF = 0x68OP_DROP = 0x75OP_EQUAL = 0x87OP_EQUALVERIFY = 0x88OP_SHA256 = 0xA8OP_HASH160 = 0xA9OP_CHECKSIG = 0xACOP_CHECKLOCKTIMEVERIFY = 0xB1def sha256(data: bytes) -> bytes:    return hashlib.sha256(data).digest()def _has_ripemd160() -> bool:    try:        hashlib.new("ripemd160")        return True    except (ValueError, TypeError):        return Falsedef hash160(data: bytes) -> bytes:    """RIPEMD160(SHA256(x)). Only needed for legacy P2SH funding addresses."""    return hashlib.new("ripemd160", hashlib.sha256(data).digest()).digest()def _pushdata(data: bytes) -> bytes:    """Minimal script push of ``data``. HTLC pushes are all < 76 bytes."""    n = len(data)    if n < 0x4C:        return bytes([n]) + data    if n <= 0xFF:        return bytes([0x4C, n]) + data    raise ValueError("push too large for an HTLC redeemscript")def _encode_scriptnum(n: int) -> bytes:    """CScriptNum minimal little-endian encoding (used for the CLTV locktime)."""    if n == 0:        return b""    negative = n < 0    abs_n = -n if negative else n    out = bytearray()    while abs_n:        out.append(abs_n & 0xFF)        abs_n >>= 8    if out[-1] & 0x80:        out.append(0x80 if negative else 0x00)    elif negative:        out[-1] |= 0x80    return bytes(out)def htlc_redeemscript(    hashlock_hex: str,    recipient_pk_hex: str,    refund_pk_hex: str,    locktime: int,) -> bytes:    """Assemble the canonical hash-timelock redeemscript for one swap leg.        OP_IF            OP_SHA256 <hashlock> OP_EQUALVERIFY <recipient_pk> OP_CHECKSIG        OP_ELSE            <locktime> OP_CHECKLOCKTIMEVERIFY OP_DROP <refund_pk> OP_CHECKSIG        OP_ENDIF    The recipient claims by revealing the preimage (top branch); after the    timelock the funder refunds themselves (bottom branch).    """    h = bytes.fromhex(hashlock_hex)    recip = bytes.fromhex(recipient_pk_hex)    refund = bytes.fromhex(refund_pk_hex)    if len(h) != 32:        raise ValueError("hashlock must be 32 bytes")    return b"".join(        [            bytes([OP_IF, OP_SHA256]),            _pushdata(h),            bytes([OP_EQUALVERIFY]),            _pushdata(recip),            bytes([OP_CHECKSIG, OP_ELSE]),            _pushdata(_encode_scriptnum(int(locktime))),            bytes([OP_CHECKLOCKTIMEVERIFY, OP_DROP]),            _pushdata(refund),            bytes([OP_CHECKSIG, OP_ENDIF]),        ]    )def p2wsh_script_pubkey(redeemscript: bytes) -> bytes:    """Native SegWit v0 witness program: OP_0 <sha256(redeemscript)>."""    return bytes([OP_0, 0x20]) + sha256(redeemscript)def p2sh_script_pubkey(redeemscript: bytes) -> bytes:    """Legacy P2SH: OP_HASH160 <hash160(redeemscript)> OP_EQUAL."""    return bytes([OP_HASH160, 0x14]) + hash160(redeemscript) + bytes([OP_EQUAL])def funding_script_pubkeys(redeemscript: bytes) -> set:    """Every scriptPubKey hex an HTLC of this redeemscript could be funded to.    MoonBite is bech32-native so P2WSH is the expected form; we also accept    wrapped P2SH when the platform's RIPEMD160 is available.    """    spks = {p2wsh_script_pubkey(redeemscript).hex()}    if _has_ripemd160():        spks.add(p2sh_script_pubkey(redeemscript).hex())    return spksdef find_preimage(items_hex: Iterable[str], hashlock_hex: str) -> Optional[str]:    """Return the witness/scriptSig item whose SHA256 equals the hashlock.    Position-independent: a redemption pushes the preimage somewhere in its    input; we identify it purely by SHA256(item) == hashlock, so we cannot be    fooled by argument ordering across wallet implementations.    """    target = hashlock_hex.lower()    for item in items_hex:        if not item:            continue        try:            raw = bytes.fromhex(item)        except ValueError:            continue        if sha256(raw).hex() == target:            return item.lower()    return Nonedef _parse_scriptsig_pushes(script_sig_hex: str) -> list:    """Extract pushed data items (hex) from a legacy scriptSig hex string."""    try:        blob = bytes.fromhex(script_sig_hex)    except ValueError:        return []    items, i, n = [], 0, len(blob)    while i < n:        op = blob[i]        i += 1        if op < 0x4C:            size = op        elif op == 0x4C and i < n:            size = blob[i]            i += 1        elif op == 0x4D and i + 1 < n:            size = blob[i] | (blob[i + 1] << 8)            i += 2        else:            break  # non-push opcode; HTLC spends only push before the redeemscript        items.append(blob[i : i + size].hex())        i += size    return itemsdef spend_input_items(vin: dict) -> list:    """Normalize a decoded spend input into a flat list of pushed data (hex).    Handles both SegWit (``txinwitness``) and legacy (``scriptSig.hex``) so the    preimage can be located regardless of the funding address type.    """    items = list(vin.get("txinwitness") or [])    script_sig = vin.get("scriptSig") or {}    if script_sig.get("hex"):        items.extend(_parse_scriptsig_pushes(script_sig["hex"]))    return items# --------------------------------------------------------------------------- ## Chain adapter interface (duck-typed). A verifier is driven by two of these —# one per leg. Real implementation: MoonNodeAdapter (RPC). Test implementation:# a fake in tests. Every method is READ-ONLY.##   confirmations(txid)            -> int   (0 if unknown/unconfirmed)#   find_output(txid, spk_hexset)  -> {"vout":int,"value":Decimal,"confirmations":int} | None#   find_spend(txid, vout)         -> {"txid":str,"items":[hex,...],"confirmations":int} | None# --------------------------------------------------------------------------- #class NullAdapter:    """A leg whose chain the operator has not wired up yet.    Returns "nothing confirmed" for every query, so a swap depending on this leg    can never advance — the honest default. Used for the quote (LTC/BTC) leg    until a quote-chain node or explorer-agreement adapter exists (Phase 2c):    the verifier still runs, verifies what it can, and simply never settles a    trade whose settlement it cannot independently prove.    """    def confirmations(self, txid):        return 0    def find_output(self, txid, spk_hexset):        return None    def find_spend(self, txid, vout):        return Noneclass MoonNodeAdapter:    """Read-only chain adapter backed by a moonbited/bigcoind JSON-RPC node.    Fully trustless for the MBITE leg (queries the operator's own node). Can    also serve a quote leg when the operator runs an LTC/BTC node. Discovering    the spender of a specific output needs either txindex or a bounded block    scan; we use a scan from the funding height forward (2c will index this).    """    def __init__(self, client, scan_window: int = 400):        self._client = client        self._scan_window = scan_window    def _get_tx(self, txid: str) -> Optional[dict]:        try:            return self._client.getrawtransaction(txid, True)        except Exception:  # noqa: BLE001 - unknown tx / node hiccup => treat as absent            return None    def confirmations(self, txid: str) -> int:        tx = self._get_tx(txid)        return int(tx.get("confirmations", 0)) if tx else 0    def find_output(self, txid: str, spk_hexset: set) -> Optional[dict]:        tx = self._get_tx(txid)        if not tx:            return None        confs = int(tx.get("confirmations", 0))        for vout in tx.get("vout", []):            spk = (vout.get("scriptPubKey") or {}).get("hex")            if spk and spk in spk_hexset:                return {                    "vout": int(vout["n"]),                    "value": Decimal(str(vout["value"])),                    "confirmations": confs,                }        return None    def _tx_height(self, txid: str) -> Optional[int]:        tx = self._get_tx(txid)        if not tx or "blockhash" not in tx:            return None        try:            return int(self._client.getblock(tx["blockhash"], 1)["height"])        except Exception:  # noqa: BLE001            return None    def find_spend(self, txid: str, vout: int) -> Optional[dict]:        """Locate the tx that spends output (txid, vout), if any is confirmed.        Bounded forward scan from the funding block; adequate for regtest and        low volume. Phase 2c replaces this with a spent-output index.        """        start = self._tx_height(txid)        if start is None:            return None        try:            tip = int(self._client.getblockcount())        except Exception:  # noqa: BLE001            return None        end = min(tip, start + self._scan_window)        for height in range(start, end + 1):            try:                block = self._client.getblock(self._client.getblockhash(height), 2)            except Exception:  # noqa: BLE001                continue            for tx in block.get("tx", []):                for vin in tx.get("vin", []):                    if vin.get("txid") == txid and int(vin.get("vout", -1)) == vout:                        return {                            "txid": tx["txid"],                            "items": spend_input_items(vin),                            "confirmations": tip - height + 1,                        }        return None# --------------------------------------------------------------------------- ## The verifier: a pure function of (swap row, two adapters) -> state updates.# Idempotent and poll-safe. It advances a swap as far as the confirmed on-chain# evidence allows, and never further.# --------------------------------------------------------------------------- #_ACTIONABLE = {"both_locked", "quote_redeemed", "base_redeemed"}def _expected_amounts(swap: dict) -> tuple:    """(base MBITE amount, quote amount) that each HTLC must be funded with."""    amount = Decimal(str(swap["amount"]))    price = Decimal(str(swap["price"]))    return amount, amount * pricedef verify_swap(    swap: dict,    base_adapter,    quote_adapter,    *,    min_confs_base: int = DEFAULT_MIN_CONFS_BASE,    min_confs_quote: int = DEFAULT_MIN_CONFS_QUOTE,    now: Optional[int] = None,) -> dict:    """Inspect the chains and return the swap's updated fields.    Returns a dict of columns to persist (possibly empty if nothing confirmed    changed). Callers apply it via exchange.apply_swap_verification. The returned    ``status`` is always a legal forward transition or unchanged — never a    regression, so a reorg that removes evidence simply yields no update until    depth is re-reached (2c adds active rollback).    """    import time as _time    if swap.get("status") not in _ACTIONABLE:        return {}    now = int(_time.time()) if now is None else int(now)    updates: dict = {}    base_amount, quote_amount = _expected_amounts(swap)    base_redeem = htlc_redeemscript(        swap["hashlock"], swap["base_recipient_pk"], swap["base_refund_pk"],        swap["base_locktime"],    )    quote_redeem = htlc_redeemscript(        swap["hashlock"], swap["quote_recipient_pk"], swap["quote_refund_pk"],        swap["quote_locktime"],    )    base_spks = funding_script_pubkeys(base_redeem)    quote_spks = funding_script_pubkeys(quote_redeem)    # 1) Both fundings must be real: pay the derived HTLC address, for at least    #    the agreed amount, confirmed to depth. A liar's txid fails this and the    #    swap simply parks — it never advances on an unverifiable claim.    base_fund = base_adapter.find_output(swap.get("base_htlc_txid") or "", base_spks)    quote_fund = quote_adapter.find_output(swap.get("quote_htlc_txid") or "", quote_spks)    if not base_fund or not quote_fund:        return {}    if base_fund["value"] < base_amount or quote_fund["value"] < quote_amount:        return {}  # underfunded HTLC — cannot back the trade; park.    if (        base_fund["confirmations"] < min_confs_base        or quote_fund["confirmations"] < min_confs_quote    ):        return {}  # funding not yet buried deep enough to trust.    # 2) Quote-leg redemption reveals the preimage publicly. Read it out; do not    #    accept it from anyone. A refund-branch spend => the swap expired.    preimage = swap.get("preimage")    quote_redeem_txid = swap.get("quote_redeem_txid")    if not preimage:        spend = quote_adapter.find_spend(swap["quote_htlc_txid"], quote_fund["vout"])        if spend:            found = find_preimage(spend["items"], swap["hashlock"])            if found is None:                # Output was spent, but not via the hashlock branch => refund.                updates["status"] = "expired"                return updates            if spend["confirmations"] >= min_confs_quote:                preimage = found                quote_redeem_txid = spend["txid"]                updates.update(                    preimage=preimage,                    quote_redeem_txid=quote_redeem_txid,                    quote_confs=spend["confirmations"],                    status="quote_redeemed",                )        else:            # Not redeemed yet; expire only once the quote timelock has elapsed.            if now >= int(swap["quote_locktime"]):                updates["status"] = "expired"            return updates    if not preimage:  # revealed but not yet buried to depth        return updates    # 3) Base-leg redemption must use that SAME preimage. Confirm it and settle.    base_redeem_txid = swap.get("base_redeem_txid")    if not base_redeem_txid:        spend = base_adapter.find_spend(swap["base_htlc_txid"], base_fund["vout"])        if not spend:            return updates  # counterparty hasn't swept the MBITE leg yet.        if find_preimage(spend["items"], swap["hashlock"]) != preimage:            # Spent by the refund branch (or a different preimage — impossible            # given the hashlock, so this is a refund) => expired.            updates["status"] = "expired"            return updates        if spend["confirmations"] < min_confs_base:            return updates        base_redeem_txid = spend["txid"]        updates.update(            base_redeem_txid=base_redeem_txid,            base_confs=spend["confirmations"],            status="base_redeemed",        )    # 4) Both legs redeemed and confirmed to depth => the trade truly settled.    updates["status"] = "settled"    updates["settled_at"] = now    return updatesdef run_verification_pass(    exchange,    base_adapter,    quote_adapter,    *,    min_confs_base: int = DEFAULT_MIN_CONFS_BASE,    min_confs_quote: int = DEFAULT_MIN_CONFS_QUOTE,    now: Optional[int] = None,) -> list:    """Verify every watchable swap once and persist any confirmed progress.    Poll-driven and idempotent: safe to call on a timer. Returns the list of    (swap_id, updates) actually applied, so a scheduler can log progress. The    ``exchange`` module is injected to keep this file free of DB/import cycles.    """    applied = []    for swap in exchange.list_swaps_for_verification():        updates = verify_swap(            swap, base_adapter, quote_adapter,            min_confs_base=min_confs_base, min_confs_quote=min_confs_quote, now=now,        )        if updates:            exchange.apply_swap_verification(swap["swap_id"], updates)            applied.append((swap["swap_id"], updates))    return applied