#!/usr/bin/env python3
"""Independent SILNCE test deploy verifier. Python 3 standard library only.

Usage: python3 verify-silent-deploy.py proof.json --api https://mempool.space/signet/api

The chosen Esplora provider supplies chain membership and previous outputs.
Cryptography and inscription bytes are checked locally. A provider outage exits 2;
invalid evidence exits 1. Indexer recognition is separately reported, not inferred.
This verifier supports Taproot inputs; unsupported input classes exit 2.
"""
import argparse
import hashlib
import json
import sys
import urllib.error
import urllib.request

P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
     0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8)
NUMS = bytes.fromhex('50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0')
CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
VERIFIER_VERSION = '1.0.0'

class Invalid(Exception):
    pass

class Unavailable(Exception):
    pass

def sha(data): return hashlib.sha256(data).digest()

def tagged(tag, data):
    h = sha(tag.encode('ascii'))
    return sha(h + h + data)

def add(a, b):
    if a is None: return b
    if b is None: return a
    if a[0] == b[0] and (a[1] + b[1]) % P == 0: return None
    if a == b:
        if a[1] == 0: return None
        slope = 3 * a[0] * a[0] * pow(2 * a[1], -1, P)
    else:
        slope = (b[1] - a[1]) * pow(b[0] - a[0], -1, P)
    slope %= P
    x = (slope * slope - a[0] - b[0]) % P
    return x, (slope * (a[0] - x) - a[1]) % P

def mul(a, n):
    result = None
    n %= N
    while n:
        if n & 1: result = add(result, a)
        a = add(a, a)
        n >>= 1
    return result

def negate(a): return None if a is None else (a[0], (-a[1]) % P)

def parse_point(data):
    if len(data) != 33 or data[0] not in (2, 3): raise Invalid('invalid compressed point')
    x = int.from_bytes(data[1:], 'big')
    if x >= P: raise Invalid('point x out of range')
    y2 = (pow(x, 3, P) + 7) % P
    y = pow(y2, (P + 1) // 4, P)
    if y * y % P != y2: raise Invalid('point is not on curve')
    if y & 1 != data[0] & 1: y = P - y
    return x, y

def compressed(a):
    if a is None: raise Invalid('point at infinity')
    return bytes([2 + (a[1] & 1)]) + a[0].to_bytes(32, 'big')

def exact_hex(value, size, name):
    if not isinstance(value, str) or len(value) != size * 2:
        raise Invalid(f'{name} must be {size} bytes of hex')
    try: return bytes.fromhex(value)
    except ValueError: raise Invalid(f'{name} is not hex') from None

def decode_address(address, network):
    if not isinstance(address, str) or address != address.lower(): raise Invalid('mixed-case recipient')
    hrp = 'sp' if network == 'mainnet' else 'tsp'
    if not address.startswith(hrp + '1'): raise Invalid('recipient network mismatch')
    values = [CHARSET.find(c) for c in address[len(hrp) + 1:]]
    if len(values) < 6 or -1 in values: raise Invalid('invalid recipient encoding')
    polymod = 1
    for value in [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp] + values:
        top = polymod >> 25
        polymod = ((polymod & 0x1ffffff) << 5) ^ value
        for i, generator in enumerate((0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)):
            if top >> i & 1: polymod ^= generator
    if polymod != 0x2bc830a3 or values[0] != 0: raise Invalid('invalid recipient checksum or version')
    acc = bits = 0
    payload = bytearray()
    for value in values[1:-6]:
        acc = (acc << 5) | value
        bits += 5
        while bits >= 8:
            bits -= 8
            payload.append((acc >> bits) & 255)
    if bits >= 5 or ((acc << (8 - bits)) & 255): raise Invalid('invalid recipient padding')
    if len(payload) != 66: raise Invalid('invalid recipient length')
    return parse_point(payload[:33]), parse_point(payload[33:])

def statement(proof):
    txid = proof['txid']
    if len(exact_hex(txid, 32, 'txid')) != 32 or txid != txid.lower() or proof['inscriptionId'] != txid + 'i0':
        raise Invalid('inscription identity mismatch')
    if proof['network'] not in ('mainnet', 'signet', 'testnet', 'regtest'):
        raise Invalid('unsupported network')
    if proof['proofVersion'] != 1 or proof['bip374Version'] != '0.3.0':
        raise Invalid('unsupported proof version')
    if type(proof['outputIndex']) is not int or proof['outputIndex'] != 0:
        raise Invalid('deploy inscription must use output 0')
    if type(proof['counter']) is not int or not 0 <= proof['counter'] <= 2322:
        raise Invalid('invalid output counter')
    ticker = proof['ticker']
    if not isinstance(ticker, str) or len(ticker) != 6 or not all(c in 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-' for c in ticker):
        raise Invalid('invalid six-character ticker')
    address = proof['recipient']
    decode_address(address, proof['network'])
    return (f'SILNCE Silent Deploy proof v1\nnetwork={proof["network"]}\ntxid={txid}\n'
            f'output_index={proof["outputIndex"]}\nrecipient={address}\n'
            f'inscription_id={proof["inscriptionId"]}\nticker={ticker}\n'
            f'max=21000000\nlim=1000\ndec=8\nself_mint=true\n'
            f'counter={proof["counter"]}\n').encode('utf8')

def dleq_verify(A, B, C, proof_bytes, message):
    e = int.from_bytes(proof_bytes[:32], 'big')
    s = int.from_bytes(proof_bytes[32:], 'big')
    if e >= N or s >= N: return False
    r1 = add(mul(G, s), negate(mul(A, e)))
    r2 = add(mul(B, s), negate(mul(C, e)))
    if r1 is None or r2 is None: return False
    challenge = tagged('BIP0374/challenge', b''.join(map(compressed,
        (A, B, C, G, r1, r2))) + message)
    return e == int.from_bytes(challenge, 'big') % N

class Reader:
    def __init__(self, data): self.data, self.pos = data, 0
    def take(self, count):
        if count < 0 or self.pos + count > len(self.data): raise Invalid('truncated transaction')
        value = self.data[self.pos:self.pos + count]
        self.pos += count
        return value
    def varint(self):
        first = self.take(1)[0]
        if first < 253: return first
        size = {253: 2, 254: 4, 255: 8}[first]
        value = int.from_bytes(self.take(size), 'little')
        if value < (253 if size == 2 else 65536 if size == 4 else 4294967296):
            raise Invalid('noncanonical varint')
        return value

def compact(value):
    if value < 253: return bytes([value])
    if value < 65536: return b'\xfd' + value.to_bytes(2, 'little')
    if value < 4294967296: return b'\xfe' + value.to_bytes(4, 'little')
    return b'\xff' + value.to_bytes(8, 'little')

def parse_tx(raw):
    reader = Reader(raw)
    version = reader.take(4)
    segwit = reader.data[reader.pos:reader.pos + 2] == b'\x00\x01'
    if segwit: reader.take(2)
    count = reader.varint()
    if not 1 <= count <= 100000: raise Invalid('invalid input count')
    inputs = []
    for _ in range(count):
        prev = reader.take(32)
        vout = int.from_bytes(reader.take(4), 'little')
        script = reader.take(reader.varint())
        sequence = reader.take(4)
        inputs.append({'txid': prev[::-1].hex(), 'vout': vout, 'outpoint': prev + vout.to_bytes(4, 'little'),
                       'base': prev + vout.to_bytes(4, 'little') + compact(len(script)) + script + sequence})
    count = reader.varint()
    if not 1 <= count <= 100000: raise Invalid('invalid output count')
    outputs = []
    output_bytes = []
    for _ in range(count):
        amount = reader.take(8)
        script = reader.take(reader.varint())
        outputs.append({'amount': int.from_bytes(amount, 'little'), 'script': script})
        output_bytes.append(amount + compact(len(script)) + script)
    for txin in inputs:
        witness_count = reader.varint() if segwit else 0
        if witness_count > 100000: raise Invalid('invalid witness item count')
        txin['witness'] = [reader.take(reader.varint()) for _ in range(witness_count)]
    locktime = reader.take(4)
    if reader.pos != len(raw): raise Invalid('trailing transaction bytes')
    base = version + compact(len(inputs)) + b''.join(v['base'] for v in inputs)
    base += compact(len(outputs)) + b''.join(output_bytes) + locktime
    txid = sha(sha(base))[::-1].hex()
    return txid, inputs, outputs

def fetch(api, path, as_json=False):
    request = urllib.request.Request(api.rstrip('/') + path,
        headers={'User-Agent': 'SILNCE-standalone-verifier/1'})
    try:
        with urllib.request.urlopen(request, timeout=25) as response:
            body = response.read(2_000_001)
    except (urllib.error.URLError, TimeoutError) as error:
        raise Unavailable(f'provider request failed: {error}') from error
    if len(body) > 2_000_000: raise Unavailable('provider response too large')
    try: return json.loads(body) if as_json else body
    except (ValueError, UnicodeDecodeError) as error: raise Unavailable('invalid provider response') from error

def chain_tx(api, expected):
    txid = exact_hex(expected, 32, 'transaction ID').hex()
    raw = bytes.fromhex(fetch(api, f'/tx/{txid}/hex').decode('ascii').strip())
    actual, inputs, outputs = parse_tx(raw)
    if actual != txid: raise Invalid('raw transaction ID mismatch')
    status = fetch(api, f'/tx/{txid}', True).get('status', {})
    if not status.get('confirmed') or not isinstance(status.get('block_height'), int):
        raise Unavailable(f'transaction {txid} is unconfirmed')
    return inputs, outputs, status['block_height']

def push(reader):
    op = reader.take(1)[0]
    if op == 0: return b''
    if 1 <= op <= 75: return reader.take(op)
    if op == 76: return reader.take(reader.take(1)[0])
    if op == 77: return reader.take(int.from_bytes(reader.take(2), 'little'))
    if op in (0x51, 0x52, 0x53): return bytes([op - 0x50])
    raise Invalid('unsupported inscription push opcode')

def inscription_body(txin, require_parent):
    witness = txin['witness']
    if len(witness) < 2: raise Invalid('inscription witness missing script')
    if witness[-1][:1] == b'\x50': witness = witness[:-1]
    script = witness[-2]
    start = script.find(b'\x00\x63\x03ord')
    if start < 0: raise Invalid('ord inscription envelope missing from input 0')
    reader = Reader(script[start + 6:])
    fields = {}
    while True:
        tag = push(reader)
        if tag == b'':
            body = push(reader)
            if reader.take(1) != b'\x68': raise Invalid('inscription envelope does not end')
            break
        if len(tag) != 1 or tag[0] in fields: raise Invalid('invalid inscription tag')
        fields[tag[0]] = push(reader)
    if fields.get(1) != b'text/plain;charset=utf-8' or 2 in fields:
        raise Invalid('content type or pointer is invalid')
    if require_parent and 3 not in fields: raise Invalid('deploy parent field missing')
    if not require_parent and 3 in fields: raise Invalid('predeploy cannot have a parent')
    try: return json.loads(body), fields
    except (ValueError, UnicodeDecodeError): raise Invalid('invalid inscription JSON') from None

def check(bundle, api):
    if bundle.get('verifierVersion') != VERIFIER_VERSION:
        raise Invalid('unsupported verifier version')
    proof = bundle['proof']
    message = sha(statement(proof))
    if exact_hex(proof['messageHex'], 32, 'message') != message: raise Invalid('proof message mismatch')
    inputs, outputs, deploy_height = chain_tx(api, proof['txid'])
    if len(outputs) < 1 or outputs[0]['amount'] < 1: raise Invalid('deploy output is empty')
    B, spend = decode_address(proof['recipient'], proof['network'])
    A = None
    for txin in inputs:
        prev_raw = bytes.fromhex(fetch(api, f'/tx/{txin["txid"]}/hex').decode('ascii').strip())
        prev_id, _, prev_outputs = parse_tx(prev_raw)
        if prev_id != txin['txid']: raise Invalid('previous transaction ID mismatch')
        try: script = prev_outputs[txin['vout']]['script']
        except IndexError: raise Invalid('previous output index does not exist') from None
        if len(script) != 34 or script[:2] != b'\x51\x20':
            raise Unavailable('unsupported non-Taproot input')
        witness = txin['witness']
        if not witness: raise Invalid('Taproot witness missing')
        if len(witness) > 1:
            stack = witness[:-1] if witness[-1][:1] == b'\x50' else witness
            if len(stack) >= 2:
                control = stack[-1]
                if len(control) < 33 or (len(control) - 33) % 32: raise Invalid('malformed control block')
                if control[1:33] == NUMS: continue
        A = add(A, parse_point(b'\x02' + script[2:]))
    if A is None or compressed(A).hex() != proof['inputPubkeySum']:
        raise Invalid('input public key sum mismatch')
    C = parse_point(exact_hex(proof['ecdhShare'], 33, 'ECDH share'))
    if not dleq_verify(A, B, C, exact_hex(proof['proofHex'], 64, 'DLEQ proof'), message):
        raise Invalid('BIP374 proof failed')
    input_hash = int.from_bytes(tagged('BIP0352/Inputs', min(v['outpoint'] for v in inputs) + compressed(A)), 'big')
    if not 0 < input_hash < N: raise Invalid('invalid BIP352 input hash')
    shared = mul(C, input_hash)
    tweak = int.from_bytes(tagged('BIP0352/SharedSecret', compressed(shared)
        + proof['counter'].to_bytes(4, 'big')), 'big')
    if not 0 < tweak < N: raise Invalid('invalid BIP352 shared secret')
    output = add(spend, mul(G, tweak))
    if output is None or outputs[0]['script'] != b'\x51\x20' + output[0].to_bytes(32, 'big'):
        raise Invalid('output is not derived for recipient')
    print('VALID: transaction identity, BIP374 proof and BIP352 output derivation')

    pre = bundle['predeploy']
    pre_inputs, pre_outputs, pre_height = chain_tx(api, pre['txid'])
    if deploy_height - pre_height < 3: raise Invalid('predeploy delay is shorter than three blocks')
    pre_script = bytes.fromhex(pre['predeployerScriptHex'])
    if pre_outputs[0]['script'] != pre_script: raise Invalid('predeployer output script mismatch')
    pre_body, _ = inscription_body(pre_inputs[0], False)
    salt = bytes.fromhex(pre['saltHex'])
    commitment = sha(sha(proof['ticker'].encode('utf8') + salt + pre_script)).hex()
    if pre_body != {'p': 'brc-20', 'op': 'predeploy', 'hash': commitment}:
        raise Invalid('predeploy commitment content mismatch')
    if len(inputs) < 2 or inputs[1]['txid'] != pre['txid'] or inputs[1]['vout'] != 0:
        raise Invalid('deploy does not spend predeploy parent in input 1')
    body, fields = inscription_body(inputs[0], True)
    parent = bytes.fromhex(pre['txid'])[::-1]
    if fields[3] != parent: raise Invalid('deploy parent field mismatch')
    expected = {'p': 'brc-20', 'op': 'deploy', 'tick': proof['ticker'], 'max': '21000000',
                'lim': '1000', 'dec': '8', 'self_mint': 'true', 'salt': pre['saltHex']}
    if body != expected: raise Invalid('deploy inscription content mismatch')
    print('VALID: predeploy commitment, parent input, deploy inscription content and output 0 location')
    observation = bundle.get('indexerObservation')
    if observation is None:
        raise Unavailable('indexer recognition has not been supplied')
    if observation.get('inscriptionId') != proof['inscriptionId'] or observation.get('ticker') != proof['ticker']:
        raise Invalid('indexer observation identity mismatch')
    print(f'REPORTED: indexer {observation["source"]} at height {observation["height"]}; verify with that indexer separately')
    return True

def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('proof_json')
    parser.add_argument('--api', required=True, help='Trusted Esplora API base URL')
    args = parser.parse_args()
    if not args.api.startswith('https://'):
        parser.error('--api must use HTTPS')
    try:
        with open(args.proof_json, encoding='utf8') as stream: bundle = json.load(stream)
        check(bundle, args.api)
    except (Invalid, KeyError, TypeError, IndexError, ValueError) as error:
        print(f'FAILED: {error}', file=sys.stderr)
        return 1
    except (Unavailable, OSError) as error:
        print(f'UNAVAILABLE: {error}', file=sys.stderr)
        return 2
    return 0

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