#!/usr/bin/env python3
"""sf-coinbase-verify.py 1.0 — check by yourself who a SoloFury Fleet server pays in its next block.

Python 3 standard library only, no install, no share sent. MIT licence, SoloFury 2026.

  python3 sf-coinbase-verify.py HOST PORT ADDRESS[.WORKER] [options]

It connects in Stratum V1 like a miner (mining.subscribe + mining.authorize with your address),
waits for the first mining.notify, rebuilds the coinbase transaction of that job
(coinb1 + extranonce1 + extranonce2 set to zero + coinb2) and prints every output:
satoshi, percent of the whole block and address. This is the transaction the pool would
broadcast if a miner found the block on this job: the outputs are fixed by the pool, a miner
cannot change them. The job changes every few seconds (new transactions, new block): amounts
move with the network fees, the percentages stay the same.

Options
  --expect ADDR=PCT[,ADDR=PCT...]   check that ADDR receives PCT % of the block (tolerance --tol sat, default 200)
  --tol SAT                         tolerance in satoshi for --expect (rounding of the pool: bp for Split, ppm for Group)
  --json                            one JSON line instead of text
  --timeout SEC                     default 25

Examples
  Fleet Split: as username the address of your Split page (the one your machines mine with), any worker name:
    python3 sf-coinbase-verify.py 45.76.82.121 3333 bc1q...agreement.check
  Fleet Group member, home server Frankfurt, manager quota 3 % of the block:
    python3 sf-coinbase-verify.py 45.76.82.121 3333 bc1q...member.check --expect bc1q...manager=3

Fleet Group: when the round of that server is still empty (nobody of the group has sent a share
since the last block), the job pays the whole member part to the address that connects; the
manager output and the other members appear with the first shares of the round.

Fleet servers (port 3333 and 3343): Atlanta 144.202.22.193 · Frankfurt 45.76.82.121
Tokyo 202.182.101.87 · São Paulo 216.238.100.215. Status: https://solofury.com/fleet-status/
Exit code: 0 = outputs read (and every --expect matched), 1 = mismatch or no answer.
"""
import sys, json, socket, hashlib, time

VERSION = '1.0'

# ---------- addresses: bech32 / bech32m (BIP173, BIP350) and base58check ----------
CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
def _polymod(values):
    gen = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; chk = 1
    for v in values:
        b = chk >> 25; chk = ((chk & 0x1ffffff) << 5) ^ v
        for i in range(5): chk ^= gen[i] if ((b >> i) & 1) else 0
    return chk
def _hrp_expand(hrp): return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
def _convertbits(data, frombits, tobits):
    acc = bits = 0; ret = []; maxv = (1 << tobits) - 1
    for v in data:
        acc = (acc << frombits) | v; bits += frombits
        while bits >= tobits: bits -= tobits; ret.append((acc >> bits) & maxv)
    if bits: ret.append((acc << (tobits - bits)) & maxv)
    return ret
def segwit_addr(hrp, ver, prog):
    data = [ver] + _convertbits(prog, 8, 5)
    const = 1 if ver == 0 else 0x2bc830a3
    pm = _polymod(_hrp_expand(hrp) + data + [0] * 6) ^ const
    return hrp + '1' + ''.join(CHARSET[d] for d in data + [(pm >> 5 * (5 - i)) & 31 for i in range(6)])
B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
def b58check(b):
    b += hashlib.sha256(hashlib.sha256(b).digest()).digest()[:4]; n = int.from_bytes(b, 'big'); s = ''
    while n: n, r = divmod(n, 58); s = B58[r] + s
    return '1' * (len(b) - len(b.lstrip(b'\0'))) + s
def script_to_addr(spk):
    if len(spk) == 22 and spk[:2] == b'\x00\x14': return segwit_addr('bc', 0, spk[2:])
    if len(spk) == 34 and spk[:2] == b'\x00\x20': return segwit_addr('bc', 0, spk[2:])
    if len(spk) == 34 and spk[:2] == b'\x51\x20': return segwit_addr('bc', 1, spk[2:])
    if len(spk) == 25 and spk[:3] == b'\x76\xa9\x14' and spk[-2:] == b'\x88\xac': return b58check(b'\x00' + spk[3:23])
    if len(spk) == 23 and spk[:2] == b'\xa9\x14' and spk[-1:] == b'\x87': return b58check(b'\x05' + spk[2:22])
    if spk[:1] == b'\x6a': return 'OP_RETURN'
    return 'script:' + spk.hex()

# ---------- transaction ----------
def varint(b, i):
    v = b[i]
    if v < 0xfd: return v, i + 1
    if v == 0xfd: return int.from_bytes(b[i+1:i+3], 'little'), i + 3
    if v == 0xfe: return int.from_bytes(b[i+1:i+5], 'little'), i + 5
    return int.from_bytes(b[i+1:i+9], 'little'), i + 9
def parse_coinbase(tx):
    i = 4
    if tx[4:6] == b'\x00\x01': i = 6   # never in a Stratum V1 coinbase, kept for safety
    nin, i = varint(tx, i)
    script = b''
    for _ in range(nin):
        i += 36; l, i = varint(tx, i); script = tx[i:i+l]; i += l + 4
    nout, i = varint(tx, i); outs = []
    for _ in range(nout):
        val = int.from_bytes(tx[i:i+8], 'little'); i += 8
        l, i = varint(tx, i); outs.append((val, tx[i:i+l])); i += l
    height = int.from_bytes(script[1:1 + script[0]], 'little') if script and 1 <= script[0] <= 4 else None   # BIP34
    return outs, height

# ---------- Stratum V1 ----------
def stratum_job(host, port, user, timeout):
    s = socket.create_connection((host, port), timeout=timeout); f = s.makefile('rwb', buffering=0)
    def send(o): f.write((json.dumps(o) + '\n').encode())
    send({'id': 1, 'method': 'mining.subscribe', 'params': ['sf-coinbase-verify/' + VERSION]})
    send({'id': 2, 'method': 'mining.authorize', 'params': [user, 'x']})
    ex1 = ex2len = auth = notify = None; deadline = time.time() + timeout
    while time.time() < deadline and (notify is None or ex1 is None or auth is None):
        line = f.readline()
        if not line: break
        m = json.loads(line)
        if m.get('id') == 1 and m.get('result'): ex1, ex2len = m['result'][1], m['result'][2]
        elif m.get('id') == 2: auth = (m.get('result'), m.get('error'))
        elif m.get('method') == 'mining.notify': notify = m['params']
    s.close()
    return ex1, ex2len, auth, notify

def main():
    a = sys.argv[1:]
    if len(a) < 3 or a[0] in ('-h', '--help'): print(__doc__); sys.exit(2)
    host, port, user = a[0], int(a[1]), a[2]
    opt = lambda k, d=None: a[a.index(k) + 1] if k in a and a.index(k) + 1 < len(a) else d
    expect = {}
    if opt('--expect'):
        for kv in opt('--expect').split(','): k, v = kv.split('='); expect[k.strip()] = float(v.replace(',', '.'))
    tol, timeout, as_json = int(opt('--tol', 200)), float(opt('--timeout', 25)), '--json' in a
    try: ex1, ex2len, auth, notify = stratum_job(host, port, user, timeout)
    except OSError as e:
        print(json.dumps({'ok': False, 'error': str(e)}) if as_json else f'ERROR: {host}:{port} not reachable ({e})'); sys.exit(1)
    if auth is None or notify is None or ex1 is None:
        print(json.dumps({'ok': False, 'error': 'no-response'}) if as_json else 'ERROR: incomplete answer from the pool'); sys.exit(1)
    if auth[0] is not True:
        print(json.dumps({'ok': False, 'error': 'authorize-refused', 'detail': auth[1]}) if as_json else f'ERROR: authorize refused ({auth[1]})'); sys.exit(1)
    tx = bytes.fromhex(notify[2] + ex1 + '00' * ex2len + notify[3])
    outs, height = parse_coinbase(tx)
    total = sum(v for v, spk in outs if not spk.startswith(b'\x6a'))
    txid = hashlib.sha256(hashlib.sha256(tx).digest()).digest()[::-1].hex()
    rows = [{'sats': v, 'pct': round(100.0 * v / total, 4) if total else 0, 'address': script_to_addr(spk)} for v, spk in outs]
    checks = []
    for addr, pct in expect.items():
        want = round(total * pct / 100); got = sum(r['sats'] for r in rows if r['address'] == addr)
        checks.append({'address': addr, 'pct': pct, 'expectedSats': want, 'gotSats': got, 'ok': abs(got - want) <= tol})
    ok = all(c['ok'] for c in checks)
    if as_json:
        print(json.dumps({'ok': ok, 'host': host, 'port': port, 'job': notify[0], 'height': height, 'totalSats': total, 'coinbaseTxidWithExtranonce2Zero': txid, 'outputs': rows, 'checks': checks, 'checkedAt': int(time.time())}))
    else:
        you = user.split('.')[0]
        print(f'{host}:{port}  job {notify[0]}  height {height}  coinbase total {total} sat ({total / 1e8:.8f} BTC)')
        for r in rows:
            mark = '  <- you' if r['address'] == you else ''
            print(f"  {r['sats']:>12} sat  {r['pct']:8.4f} %  {r['address']}{mark}")
        if not any(r['address'] == you for r in rows):
            print(f'  ({you} has no output in this job: no shares in the current round, part below the threshold, or not a member of this server)')
        for c in checks:
            print(f"  check {c['address'][:14]}… {c['pct']} % = {c['expectedSats']} sat → {c['gotSats']} sat  {'OK' if c['ok'] else 'MISMATCH'}")
        if checks: print('RESULT: ' + ('every check matched' if ok else 'at least one check did NOT match'))
    sys.exit(0 if ok else 1)

if __name__ == '__main__': main()
