#!/usr/bin/env python3 """sha256D dataset builder for SN job 510563. For a given timestamp x (late 2017..now), produce a CSV row: x, B (next BTC block reward in sats), b (next BCH block reward in sats), T (BTC nBits target at x), t (BCH nBits target at x), p (BCH price in BTC sats) Sources (public, no key): BTC: mempool.space (block height by time via /api/blocks, header via /api/block/{hash}) BCH: Blockchair bitcoin-cash (blocks by time range; includes reward + bits) Price: CoinGecko daily history (BCH/USD, BTC/USD) -> BCH price in sats Usage: python3 scripts/sha256d_dataset.py --ts 1600000000 [--ts ...] [--out csv] """ import json, os, sys, time, csv, argparse, datetime, urllib.request UA = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) hermmon-dataset/1.0"} def http_json(url): req = urllib.request.Request(url, headers=UA) with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read().decode()) def http_text(url): req = urllib.request.Request(url, headers=UA) with urllib.request.urlopen(req, timeout=30) as r: return r.read().decode() def _q(url): """GET a URL, url-encoding any spaces (Blockchair rejects raw spaces).""" return http_json(url.replace(" ", "%20")) # ---------- BTC side ---------- _URL_CACHE = {} import time as _time def http_json_cached(url, retries=3): if url in _URL_CACHE: return _URL_CACHE[url] for attempt in range(retries): try: req = urllib.request.Request(url, headers=UA) with urllib.request.urlopen(req, timeout=30) as r: body = r.read().decode() _URL_CACHE[url] = json.loads(body) _time.sleep(0.25) # pace requests: mempool/haskoin rate-limit bursts return _URL_CACHE[url] except Exception: if attempt == retries - 1: raise _time.sleep(2 ** attempt) raise RuntimeError("unreachable") def btc_next_block_at(ts, anchor_hint=None): """Return (height, hash, nbits, subsidy_sats) of the first BTC block with timestamp >= ts. Binary search over mempool.space 15-block windows, anchored near a hint height (e.g. the BCH height at ts) to cut requests.""" def window(h): url = f"https://mempool.space/api/v1/blocks/{max(0, h)}" arr = http_json_cached(url) arr.sort(key=lambda b: b["height"]) return arr # anchor: use hint if provided, else probe from chain tip tip = int(http_json_cached("https://mempool.space/api/blocks/tip/height")) if anchor_hint is not None: lo = max(0, anchor_hint - 3000) hi = min(anchor_hint + 3000, tip) else: lo, hi = 0, tip # expand bounds until the window start crosses ts while True: w = window(lo) if not w: # lo beyond tip (can't happen with lo=0, but guard anyway) lo = max(0, lo - 3000) continue if w[0]["timestamp"] >= ts: if lo == 0: break lo = max(0, lo - 3000) hi = lo + 3000 continue w_hi = window(hi) if not w_hi: # hi beyond tip: pull the upper bound back hi = tip lo = max(0, hi - 3000) continue if w_hi[0]["timestamp"] < ts: if hi >= tip: break # ts newer than chain tip — return tip block lo, hi = hi, min(hi + 3000, tip) continue break while lo < hi: mid = (lo + hi) // 2 w = window(mid) if w[0]["timestamp"] >= ts: hi = mid else: lo = mid + 1 w = window(lo) for b in w: if b["timestamp"] >= ts: return b["height"], b["id"], b["bits"], subsidy(b["height"]) # no block >= ts in this window: try the next one, else return the tip block w = window(lo + 1) if not w: # lo+1 beyond tip — return the last block we have return (None, None, None, None) return w[0]["height"], w[0]["id"], w[0]["bits"], subsidy(w[0]["height"]) def subsidy(height): halvings = height // 210000 if halvings >= 64: return 0 return (50 * 10**8) >> halvings # ---------- BCH side ---------- def bch_next_block_at(ts): """First BCH block with timestamp >= ts via haskoin (api.haskoin.com). Returns (height, hash, bits, reward_sats). Reward follows the same 210k-block halving schedule as BTC (BCH forked from BTC).""" def blk(h): arr = http_json_cached(f"https://api.haskoin.com/bch/block/height/{max(0, h)}") if isinstance(arr, list) and arr: return arr[0] return None # height doesn't exist yet # find the tip height by exponential search upward h = 1000 while blk(h) is not None: h *= 2 if h > 20000000: return None lo, hi = 0, h while lo < hi: mid = (lo + hi) // 2 if blk(mid) is not None: lo = mid + 1 else: hi = mid tip = lo - 1 # largest existing height lo, hi = 0, tip while lo < hi: mid = (lo + hi) // 2 b = blk(mid) if b is not None and b["time"] >= ts: hi = mid else: lo = mid + 1 b = blk(lo) if b is None: return None return b["height"], b["hash"], b["bits"], subsidy(b["height"]) # ---------- price ---------- def bch_price_sats_at(ts): """BCH price in USD and BTC sats on the date of ts. Provider chain (same provider for both pairs to keep the ratio honest): 1. Binance BCHUSDT/BTCUSDT (2019-11-28 -> now) 2. Coinbase BCH-USD/BTC-USD (2017-12-19 -> now) 3. Bitfinex tBCHUSD/tBTCUSD (2017-08 -> 2018-11) """ day_start = (ts // 86400) * 86400 day_end = day_start + 86400 def binance_close(pair): url = (f"https://api.binance.com/api/v3/klines?symbol={pair}&interval=1d" f"&startTime={day_start * 1000}&limit=2") rows = http_json(url) for r in rows: if day_start * 1000 <= r[0] < day_end * 1000: return float(r[4]) return None def coinbase_close(pair): s = datetime.datetime.fromtimestamp(day_start, datetime.UTC).strftime("%Y-%m-%dT00:00:00Z") e = datetime.datetime.fromtimestamp(day_end, datetime.UTC).strftime("%Y-%m-%dT00:00:00Z") url = (f"https://api.exchange.coinbase.com/products/{pair}/candles" f"?granularity=86400&start={s}&end={e}") rows = http_json(url) # [time, low, high, open, close, volume], newest first for r in rows: if day_start <= r[0] < day_end: return float(r[4]) return None def bitfinex_close(pair): url = (f"https://api-pub.bitfinex.com/v2/candles/trade:1D:{pair}/hist" f"?start={day_start * 1000}&end={day_end * 1000}&limit=5") rows = http_json(url) for r in rows: if day_start * 1000 <= r[0] < day_end * 1000: return float(r[2]) # close return None providers = [ ("binance", binance_close, "BCHUSDT", "BTCUSDT"), ("coinbase", coinbase_close, "BCH-USD", "BTC-USD"), ("bitfinex", bitfinex_close, "tBCHUSD", "tBTCUSD"), ] for name, fn, bch_pair, btc_pair in providers: try: bch_usd = fn(bch_pair) btc_usd = fn(btc_pair) if bch_usd and btc_usd: print(f" [price] via {name}") return round(bch_usd / btc_usd * 1e8, 2), bch_usd except Exception as e: print(f" [price:{name}] {e}", file=sys.stderr) return None, None def row_for(ts): print(f"-- timestamp {ts} ({datetime.datetime.fromtimestamp(ts, datetime.UTC).isoformat()}Z)") c = bch_next_block_at(ts) # BCH first: its height anchors the BTC search b = btc_next_block_at(ts, anchor_hint=c[0] if c else None) p, p_usd = bch_price_sats_at(ts) print(f" BTC: height={b[0] if b else '?'} bits={b[2] if b else '?'} subsidy={b[3] if b else '?'}") print(f" BCH: height={c[0] if c else '?'} bits={c[2] if c else '?'} reward={c[3] if c else '?'}") print(f" BCH price: {p} sats / {p_usd} USD") return { "x": ts, "B_sats": b[3] if b else None, "b_sats": c[3] if c else None, "T_nbits_btc": b[2] if b else None, "t_nbits_bch": c[2] if c else None, "p_bch_sats": p, "p_bch_usd": p_usd, "btc_height": b[0] if b else None, "bch_height": c[0] if c else None, } if __name__ == "__main__": ap = argparse.ArgumentParser() ap.add_argument("--ts", type=int, action="append") ap.add_argument("--out", default="") args = ap.parse_args() tss = args.ts or [1509494400, 1559347200, 1600000000, 1640995200, 1680000000, 1720000000, 1750000000] rows = [] for ts in tss: try: rows.append(row_for(ts)) except Exception as e: print(f" ERROR {e}") if args.out: with open(args.out, "w", newline="") as f: w = csv.DictWriter(f, fieldnames=list(rows[0].keys())) w.writeheader() w.writerows(rows) print(f"\nwrote {len(rows)} rows -> {args.out}") else: print("\n" + json.dumps(rows, indent=1))