#!/usr/bin/env python3
"""np-anchor-verify: check that a NexusPool receipt sits under a merkle root that Bitcoin timestamped.

The pool signs its receipts with its own key, so on its own a signature says only "the pool signed
this". Each hour the pool folds every receipt it sealed into a merkle root and sends that root to
OpenTimestamps, which commits it into a Bitcoin block. This script needs no key from the pool:

    python3 np-anchor-verify.py receipt.json anchor.json            # offline
    python3 np-anchor-verify.py receipt.json anchor.json --online   # also asks a block explorer

receipt.json  one receipt as /api/custody-receipts serves it: {"seq","type","payload","sig"}
anchor.json   what /api/custody-anchor?id=... serves: the leaves, the root and the .ots proof

It rebuilds the leaf from the receipt (SHA256 of 0x00, the signed message, the signature), finds it
among the anchor's leaves, rebuilds the root (SHA256 of 0x01, left, right; an odd node is carried up),
checks the .ots proof commits to that root, and prints the Bitcoin block height and the merkle root
that block's header must carry. With --online it fetches that header from a public explorer and
compares. Python 3.8 or newer, standard library only.

Exit code 0: the receipt is under a root Bitcoin timestamped (and the explorer agrees, with
--online). 2: the proof is still pending at the calendars (leaf and root verified, no block yet).
1: something does not match. 3: bad input or fetch error.
"""

import argparse
import hashlib
import json
import struct
import sys
import urllib.request

DOMAIN = b"NexusPool/Custody/v1"
MAGIC = bytes.fromhex("004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294")
PENDING, BITCOIN = bytes.fromhex("83dfe30d2ef90c8e"), bytes.fromhex("0588960d73d71901")
EXPLORER = "https://blockstream.info/api"


def msg32(seq, rtype, payload):
    t, p = rtype.encode(), payload.encode()
    pre = DOMAIN + struct.pack("<Q", seq) + struct.pack("<B", len(t)) + t + struct.pack("<H", len(p)) + p
    return hashlib.sha256(pre).digest()


def leaf_hash(receipt):
    return hashlib.sha256(b"\x00" + msg32(int(receipt["seq"]), receipt["type"], receipt["payload"]) + bytes.fromhex(receipt["sig"])).digest()


def merkle_root(leaves):
    level = list(leaves)
    while len(level) > 1:
        nxt = [hashlib.sha256(b"\x01" + level[i] + level[i + 1]).digest() for i in range(0, len(level) - 1, 2)]
        if len(level) % 2:
            nxt.append(level[-1])
        level = nxt
    return level[0]


class Reader:
    def __init__(self, data):
        self.d, self.i = data, 0

    def byte(self):
        if self.i >= len(self.d):
            raise ValueError("proof truncated")
        self.i += 1
        return self.d[self.i - 1]

    def take(self, n):
        if self.i + n > len(self.d):
            raise ValueError("proof truncated")
        self.i += n
        return self.d[self.i - n:self.i]

    def varuint(self):
        value, shift = 0, 0
        while True:
            b = self.byte()
            value |= (b & 0x7F) << shift
            if not b & 0x80:
                return value
            shift += 7
            if shift > 63:
                raise ValueError("varuint too large")

    def varbytes(self, limit=8192):
        n = self.varuint()
        if n > limit:
            raise ValueError("varbytes too long")
        return self.take(n)


def apply_op(tag, arg, msg):
    if tag == 0x02:
        return hashlib.sha1(msg).digest()
    if tag == 0x03:
        return hashlib.new("ripemd160", msg).digest()
    if tag == 0x08:
        return hashlib.sha256(msg).digest()
    if tag == 0xF0:
        return msg + arg
    if tag == 0xF1:
        return arg + msg
    if tag == 0xF2:
        return msg[::-1]
    if tag == 0xF3:
        return msg.hex().encode()
    raise ValueError("unsupported op 0x%02x" % tag)


def read_timestamp(r, msg, out, depth=256):
    """Walks one timestamp node; appends (message, tag, payload) for every attestation found."""
    if not depth:
        raise ValueError("proof nests too deep")

    def one(tag):
        if tag == 0x00:
            out.append((msg, r.take(8), r.varbytes()))
            return
        arg = r.varbytes(4096) if tag in (0xF0, 0xF1) else b""
        read_timestamp(r, apply_op(tag, arg, msg), out, depth - 1)

    tag = r.byte()
    while tag == 0xFF:
        one(r.byte())
        tag = r.byte()
    one(tag)


def parse_proof(data):
    """Returns (digest, attestations); attestations are (commitment, tag, payload)."""
    r = Reader(data)
    if r.take(len(MAGIC)) != MAGIC:
        raise ValueError("not an OpenTimestamps proof (bad magic)")
    if r.varuint() != 1:
        raise ValueError("unsupported proof version")
    if r.byte() != 0x08:
        raise ValueError("proof is not over a sha256 digest")
    digest = r.take(32)
    out = []
    read_timestamp(r, digest, out)
    if r.i != len(r.d):
        raise ValueError("trailing bytes after the proof")
    return digest, out


def attestations(parsed):
    """Bitcoin attestations as (height, merkle root as explorers print it); pending ones as uris."""
    bitcoin, pending = [], []
    for commitment, tag, payload in parsed:
        if tag == BITCOIN:
            bitcoin.append((Reader(payload).varuint(), commitment[::-1].hex()))
        elif tag == PENDING:
            pending.append(Reader(payload).varbytes(1000).decode("latin1"))
    return bitcoin, pending


def fetch_json(url):
    req = urllib.request.Request(url, headers={"User-Agent": "np-anchor-verify"})
    with urllib.request.urlopen(req, timeout=30) as resp:
        return json.loads(resp.read(1 << 20).decode())


def fetch_text(url):
    req = urllib.request.Request(url, headers={"User-Agent": "np-anchor-verify"})
    with urllib.request.urlopen(req, timeout=30) as resp:
        return resp.read(1 << 16).decode().strip()


def check(receipt, anchor, online=False, explorer=EXPLORER, out=sys.stdout):
    """Returns the exit code; prints one line per step."""
    try:
        leaf = leaf_hash(receipt)
        leaves = [bytes.fromhex(h) for h in anchor["leaves"]]
        root = bytes.fromhex(anchor["root"])
        proof = __import__("base64").b64decode(anchor["ots"])
    except (KeyError, TypeError, ValueError) as exc:
        print("bad input: %s" % exc, file=out)
        return 3
    if leaf not in leaves:
        print("FAIL leaf %s is not among the anchor's %d leaves" % (leaf.hex(), len(leaves)), file=out)
        return 1
    print("ok  leaf %s is leaf %d of %d in anchor %s" % (leaf.hex()[:16], leaves.index(leaf), len(leaves), anchor.get("id", "?")), file=out)
    if merkle_root(leaves) != root:
        print("FAIL the anchor's leaves do not rebuild its root", file=out)
        return 1
    print("ok  root %s rebuilt from the leaves" % root.hex()[:16], file=out)
    try:
        digest, parsed = parse_proof(proof)
    except ValueError as exc:
        print("FAIL proof: %s" % exc, file=out)
        return 1
    if digest != root:
        print("FAIL the .ots proof is over %s, not this root" % digest.hex()[:16], file=out)
        return 1
    print("ok  the .ots proof commits to this root", file=out)
    bitcoin, pending = attestations(parsed)
    if not bitcoin:
        print("PENDING no Bitcoin attestation yet; calendars: %s" % ", ".join(pending), file=out)
        return 2
    for height, merkle in bitcoin:
        print("ok  Bitcoin block %d must carry merkle root %s" % (height, merkle), file=out)
    if not online:
        print("    check it: bitcoin-cli getblockheader $(bitcoin-cli getblockhash %d), or any explorer" % bitcoin[0][0], file=out)
        return 0
    try:
        h = fetch_text("%s/block-height/%d" % (explorer, bitcoin[0][0]))
        header = fetch_json("%s/block/%s" % (explorer, h))
    except Exception as exc:
        print("fetch error: %s" % exc, file=out)
        return 3
    if header.get("merkle_root") != bitcoin[0][1]:
        print("FAIL explorer says block %d has merkle root %s" % (bitcoin[0][0], header.get("merkle_root")), file=out)
        return 1
    print("ok  explorer agrees: block %d, merkle root matches, timestamp %s" % (bitcoin[0][0], header.get("timestamp")), file=out)
    return 0


def main(argv=None):
    ap = argparse.ArgumentParser(description="Check a NexusPool receipt against a Bitcoin-timestamped anchor.")
    ap.add_argument("receipt", help="JSON file with one receipt (seq, type, payload, sig)")
    ap.add_argument("anchor", help="JSON file from /api/custody-anchor?id=...")
    ap.add_argument("--online", action="store_true", help="fetch the block header from a public explorer and compare")
    ap.add_argument("--explorer", default=EXPLORER, help="esplora-style API base (default %(default)s)")
    a = ap.parse_args(argv)
    try:
        receipt = json.load(open(a.receipt))
        anchor = json.load(open(a.anchor))
    except (OSError, ValueError) as exc:
        print("bad input: %s" % exc)
        return 3
    return check(receipt, anchor, a.online, a.explorer)


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