"""Bounded, one-shot, read-only Litecoin Core snapshot. Standard library only.""" from pathlib import Path from decimal import Decimal import base64,datetime,hashlib,json,sys,time,urllib.parse,urllib.request STATS=['height','blockhash','totalfee','subsidy','txs','avgfee','medianfee','minfee','maxfee','total_size','total_weight','feerate_percentiles','avgfeerate','minfeerate','maxfeerate'] def encoded(obj):return (json.dumps(obj,default=str,separators=(',',':'))+'\n').encode() class RPC: def __init__(self,data,url): parsed=urllib.parse.urlsplit(url) if parsed.scheme!='http' or parsed.hostname not in ['localhost','127.0.0.1','::1'] or parsed.username or parsed.password:raise ValueError('RPC must be loopback HTTP without URL credentials') self.cookie=Path(data)/'.cookie';self.url=url;self.opener=urllib.request.build_opener(urllib.request.ProxyHandler({})) def __call__(self,method,args=()): if method not in ['getblockchaininfo','getnetworkinfo','getblockhash','getblockheader','getblockstats','getblock','getnetworkhashps']:raise ValueError('Read-only allowlist') auth=base64.b64encode(self.cookie.read_bytes().strip()).decode() request=urllib.request.Request(self.url,encoded({'jsonrpc':'1.0','id':'bounded-study','method':method,'params':list(args)}),{'Authorization':'Basic '+auth,'Content-Type':'application/json'}) with self.opener.open(request,timeout=20) as response:raw=response.read(24*1024*1024+1) if len(raw)>24*1024*1024:raise RuntimeError('RPC response exceeds bound') body=json.loads(raw,parse_float=Decimal) if body.get('error'):raise RuntimeError('RPC rejected read-only method '+method) return body['result'] def capture(data,url,out): out=Path(out) if out.exists():raise FileExistsError('Frozen output already exists; choose a new directory') rpc=RPC(data,url);started=int(time.time());chain=rpc('getblockchaininfo');net=rpc('getnetworkinfo') assert chain['chain']=='main' and not chain['initialblockdownload'] and chain['blocks']==chain['headers'] and net['connections']>0 height=chain['blocks'];anchor=chain['bestblockhash'];headers=[];blocks=[] for h in range(height-600,height+1): blockhash=rpc('getblockhash',[h]);b=rpc('getblockheader',[blockhash]) headers.append({key:b[key] for key in ['height','hash','previousblockhash','time','mediantime','bits','difficulty','chainwork']}) assert b['height']==h and b['hash']==blockhash time.sleep(.015) assert int(time.time())-headers[-1]['time']<7200 and headers[-1]['hash']==anchor for header in headers[-144:]: b=rpc('getblock',[header['hash'],2]);stats=rpc('getblockstats',[header['hash'],STATS]) coinbase=b['tx'][0];assert 'coinbase' in coinbase['vin'][0] values=[int(v['value']*100000000) for v in coinbase['vout']] assert all(Decimal(v['value'])*100000000==n for v,n in zip(coinbase['vout'],values)) hogex=sum(any(v.get('scriptPubKey',{}).get('type')=='witness_mweb_hogaddr' for v in tx['vout']) for tx in b['tx'][1:]) kernels=b.get('mweb',{}).get('kernels',[]);kernel_fees=[k['fee'] for k in kernels] assert all(isinstance(n,int) and n>=0 for n in kernel_fees) blocks.append({'height':b['height'],'hash':b['hash'],'time':b['time'],'stats':stats,'coinbase_txid':coinbase['txid'],'coinbase_output_litoshis':values,'hogex_count':hogex,'mweb_kernel_fee_litoshis':kernel_fees}) time.sleep(.035) estimates={str(n):rpc('getnetworkhashps',[n,height]) for n in [30,120,576]} for h in headers:assert rpc('getblockhash',[h['height']])==h['hash'],'Captured height changed' end=rpc('getblockchaininfo');assert rpc('getblockhash',[height])==anchor result={'captured_started_at':started,'captured_finished_at':int(time.time()),'chain':'main','software':net['subversion'],'pruned':chain['pruned'],'peers_at_start':net['connections'],'anchor_height':height,'anchor_hash':anchor,'tip_at_end':end['blocks'],'header_range':[height-600,height],'header_count':601,'fee_block_range':[height-143,height],'fee_block_count':144,'rpc_hashrate_estimates_hps':estimates,'local_timezone_note':'Release date is 4 October 2026 Europe/Warsaw; Unix capture times are UTC.','scope':'One read-only capture from one synced pruned node. Fixed height boundary rechecked. Header times are miner-declared, not node receipt times. No peer identities, wallet access, new collector or mining hardware measurement.'} out.mkdir(mode=0o700,parents=True) for name,rows in [('headers.json',headers),('blocks.json',blocks)]: raw=encoded(rows);(out/name).write_bytes(raw) result['input_sha256']={name:hashlib.sha256((out/name).read_bytes()).hexdigest() for name in ['headers.json','blocks.json']} (out/'capture.json').write_bytes(encoded(result));return result if __name__=='__main__': if len(sys.argv)!=4:raise SystemExit('Usage: python capture-node.py NODE_DATA_DIRECTORY LOOPBACK_RPC_URL NEW_OUTPUT_DIRECTORY') result=capture(*sys.argv[1:]);print(json.dumps({k:result[k] for k in ['anchor_height','header_count','fee_block_count','captured_started_at','captured_finished_at']}))