"""One fresh two-node Litecoin Core0.21.5.8 regtest reorg experiment. Python3.10+ standard library; no existing datadir, wallet, seed or RPC endpoint. Usage: python reorg-test.py.txt DAEMON CLI NEW_OUTPUT_DIRECTORY Pinned Windows binaries are copied to a disposable local lab. Peers and RPC are loopback-only. Never fund these synthetic addresses. MIT original code, CC0 data. """ from pathlib import Path from datetime import datetime,timezone from decimal import Decimal import argparse,base64,csv,hashlib,json,platform,shutil,socket,subprocess,tempfile,time,urllib.request,urllib.error DAEMON_SHA='7430c6d0d4cb59d355361a0b90ffbfc4ac725609483e04c242bf95372cc9b8fe' CLI_SHA='6f835854576a473599f89b9842bf7855dabda1183f5a707ac5d5e15aa48761d8' COMMIT='ec1b6489a900d09cf5991e220dce089c77a232a2' def utc():return datetime.now(timezone.utc).isoformat() def sha(b):return hashlib.sha256(b).hexdigest() def write(p,v):p.write_text(json.dumps(v,ensure_ascii=False,indent=2)+'\n',encoding='utf-8') def port(): with socket.socket() as s:s.bind(('127.0.0.1',0));return s.getsockname()[1] class RPCError(RuntimeError): def __init__(self,error):self.error=error;super().__init__(str(error.get('message','RPC error'))) class Node: def __init__(self,lab,name,daemon,study): self.name=name;self.root=lab/name;self.root.mkdir();self.rpcport=port();self.p2pport=port();self.study=study;self.process=None;self.log=None;self.daemon=daemon def start(self): flags=['-regtest','-server=1','-datadir='+str(self.root),'-rpcport='+str(self.rpcport),'-rpcbind=127.0.0.1','-rpcallowip=127.0.0.1','-port='+str(self.p2pport),'-bind=127.0.0.1:'+str(self.p2pport),'-listen=1','-connect=0','-dnsseed=0','-dns=0','-discover=0','-upnp=0','-listenonion=0','-onlynet=ipv4','-maxconnections=16','-txindex=1','-dbcache=32','-par=1','-rpcthreads=2','-persistmempool=0','-acceptnonstdtxn=0','-fallbackfee=0.00001000','-keypool=10','-printtoconsole=0'] self.log=(self.root/'process.log').open('wb');self.process=subprocess.Popen([str(self.daemon),*flags],stdout=self.log,stderr=self.log,creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0)) end=time.monotonic()+40 while True: if self.process.poll() is not None:raise RuntimeError('Fresh node '+self.name+' exited before readiness') try: if (self.root/'regtest'/'.cookie').is_file():self.rpc('getblockchaininfo',quiet=True);break except (OSError,urllib.error.URLError,RPCError):pass if time.monotonic()>end:raise RuntimeError('Fresh regtest RPC readiness timeout') time.sleep(.1) c=self.rpc('getblockchaininfo');n=self.rpc('getnetworkinfo') self.study.check(self.name+' fresh regtest zeroheight',c['chain']=='regtest' and c['blocks']==0) self.study.check(self.name+' pinnedCore',n['subversion']=='/LitecoinCore:0.21.5.8/') self.study.check(self.name+' startspeerfree',self.rpc('getpeerinfo')==[]) self.rpc('setmocktime',1500000000) def rpc(self,method,*params,wallet=None,allow_error=False,quiet=False): cookie=(self.root/'regtest'/'.cookie').read_text(encoding='utf-8').strip();index=len(self.study.trace) payload=json.dumps({'jsonrpc':'1.0','id':index,'method':method,'params':list(params)}).encode() url='http://127.0.0.1:'+str(self.rpcport)+('/wallet/'+wallet if wallet else '/') request=urllib.request.Request(url,data=payload,headers={'Content-Type':'application/json','Authorization':'Basic '+base64.b64encode(cookie.encode()).decode()}) try:response=self.study.opener.open(request,timeout=35) except urllib.error.HTTPError as e:response=e with response:value=json.loads(response.read(8*1024*1024)) rec={'index':index,'node':self.name,'wallet':wallet,'method':method,'params':list(params)} if value.get('error'):rec['error']=value['error'] else:rec['result']=value.get('result') if not quiet:self.study.trace.append(rec) if value.get('error'): if allow_error:return {'error':value['error']} raise RPCError(value['error']) return value.get('result') def stop(self): if self.process and self.process.poll() is None: try:self.rpc('stop');self.process.wait(timeout=30) except Exception: self.process.terminate() try:self.process.wait(timeout=10) except subprocess.TimeoutExpired:self.process.kill();self.process.wait(timeout=10) if self.log:self.log.close() return self.process is None or self.process.poll() is not None class Study: def __init__(self,daemon,cli,output): self.output=Path(output).resolve();self.output.mkdir(parents=True,exist_ok=False);self.trace=[];self.checks=[];self.candidates={};self.blocks={};self.snapshots=[];self.events=[];self.checkpoints={};self.cleanup={} self.metadata={'protocol':'litecoin-watch-reorg-v1','source_commit':COMMIT,'core_version':'/LitecoinCore:0.21.5.8/','started_at_utc':utc(),'platform':platform.system(),'runner_sha256':sha(Path(__file__).read_bytes()),'scope':'One completed fresh two-node transparent legacy-P2PKH regtest run; local private loopback connections only, no mainnet, MWEB or probability inference.'} self.scratch=Path(tempfile.gettempdir()).resolve();self.lab=Path(tempfile.mkdtemp(prefix='lw-reorg-regtest-',dir=self.scratch)).resolve();self.nodes={};self.opener=urllib.request.build_opener(urllib.request.ProxyHandler({})) for source,expected in [(Path(daemon).resolve(),DAEMON_SHA),(Path(cli).resolve(),CLI_SHA)]: self.check(source.name+' originalpinnedhash',sha(source.read_bytes())==expected) target=self.lab/source.name;shutil.copyfile(source,target);self.check(source.name+' copiedpinnedhash',sha(target.read_bytes())==expected) self.daemon=self.lab/Path(daemon).name;self.cli=self.lab/Path(cli).name self.metadata.update(daemon_sha256=DAEMON_SHA,cli_sha256=CLI_SHA) def check(self,label,value,details=None): self.checks.append({'label':label,'passed':bool(value),'details':details}) if not value:raise AssertionError(label) def wait(self,label,condition,timeout=40): end=time.monotonic()+timeout while True: try: if condition():return except (RPCError,OSError,urllib.error.URLError):pass if time.monotonic()>end:raise RuntimeError('Wait timeout: '+label) time.sleep(.1) def peersafe(self): for name,n in self.nodes.items(): peers=n.rpc('getpeerinfo') self.check(name+' allpeersloopback',all(p['addr'].split(':')[0]=='127.0.0.1' for p in peers)) def connect(self): a,b=self.nodes['A'],self.nodes['B'];a.rpc('addnode','127.0.0.1:'+str(b.p2pport),'onetry') self.wait('loopbackconnection',lambda:len(a.rpc('getpeerinfo',quiet=True))==1 and len(b.rpc('getpeerinfo',quiet=True))==1) self.wait('sameactivebranch',lambda:a.rpc('getbestblockhash',quiet=True)==b.rpc('getbestblockhash',quiet=True));self.peersafe() def disconnect(self): for n in self.nodes.values(): for peer in n.rpc('getpeerinfo'):n.rpc('disconnectnode',peer['addr'],allow_error=True) self.wait('privateforknopeers',lambda:all(n.rpc('getpeerinfo',quiet=True)==[] for n in self.nodes.values())) for name,n in self.nodes.items():self.check(name+' disconnected',n.rpc('getpeerinfo')==[]) def record_block(self,node,h): b=node.rpc('getblock',h,1);raw=node.rpc('getblockheader',h,False) self.blocks[h]={'node':node.name,'hash':h,'height':b['height'],'previousblockhash':b.get('previousblockhash'),'chainwork':b['chainwork'],'time':b['time'],'mediantime':b['mediantime'],'version':b['version'],'merkleroot':b['merkleroot'],'bits':b['bits'],'nonce':b['nonce'],'raw_header':raw,'txids':b['tx']} def mine(self,node,count): hashes=node.rpc('generatetoaddress',count,self.miner_addresses[node.name]) self.check(node.name+' requestedminingcount '+str(len(self.blocks)),len(hashes)==count) for h in hashes:self.record_block(node,h) return hashes def make(self,label,coin,recipient,amount): a=self.nodes['A'];change=a.rpc('getnewaddress',label+'-change','legacy',wallet='sender');input_value=Decimal(str(coin['amount']));fee=Decimal('0.00010000');value=Decimal(amount) raw=a.rpc('createrawtransaction',[{'txid':coin['txid'],'vout':coin['vout'],'sequence':4294967295}],{recipient:format(value,'.8f'),change:format(input_value-value-fee,'.8f')},0) signed=a.rpc('signrawtransactionwithwallet',raw,wallet='sender');self.check(label+' signedcomplete',signed['complete']) decoded=a.rpc('decoderawtransaction',signed['hex']);funding_wallet=a.rpc('gettransaction',coin['txid'],wallet='sender') funding_raw=a.rpc('getrawtransaction',coin['txid'],False,funding_wallet['blockhash']);funding_header=a.rpc('getblockheader',funding_wallet['blockhash']) self.candidates[label]={'txid':decoded['txid'],'raw_hex':signed['hex'],'decoded':decoded,'funding':{'txid':coin['txid'],'vout':coin['vout'],'value_litoshis':int(input_value*100000000),'raw_hex':funding_raw,'blockhash':funding_wallet['blockhash'],'height':funding_header['height']},'recipient_address':recipient,'change_address':change,'amount_litoshis':int(value*100000000),'fee_litoshis':10000} return decoded['txid'] def snapshot(self,ident,extra_cursor=False): first=len(self.trace);nodes={} for name,n in self.nodes.items(): data={'chain':n.rpc('getblockchaininfo'),'peers':n.rpc('getpeerinfo'),'mempool':n.rpc('getrawmempool'),'wallets':{},'raw':{},'headers':{}} self.check(ident+' '+name+' onlyregtest',data['chain']['chain']=='regtest') self.check(ident+' '+name+' peersloopback',all(p['addr'].split(':')[0]=='127.0.0.1' for p in data['peers'])) for role in (['sender','recipient'] if name=='A' else ['miner','alternate']): wallet={'balances':n.rpc('getbalances',wallet=role),'transactions':{},'since':{}} for label,c in self.candidates.items():wallet['transactions'][label]=n.rpc('gettransaction',c['txid'],wallet=role,allow_error=True) if name=='A': for label,checkpoint in self.checkpoints.items(): wallet['since'][label]={'target1':n.rpc('listsinceblock',checkpoint,1,False,True,wallet=role)} if extra_cursor and label=='P': wallet['since'][label]['target6']=n.rpc('listsinceblock',checkpoint,6,False,True,wallet=role) wallet['since'][label]['no_removed']=n.rpc('listsinceblock',checkpoint,1,False,False,wallet=role) data['wallets'][role]=wallet for label,c in self.candidates.items(): data['raw'][label]={'lookup':n.rpc('getrawtransaction',c['txid'],True,allow_error=True)} if label in self.checkpoints: checkpoint=self.checkpoints[label];data['headers'][label]=n.rpc('getblockheader',checkpoint,allow_error=True) data['raw'][label]['stale_block_lookup']=n.rpc('getrawtransaction',c['txid'],True,checkpoint,allow_error=True) nodes[name]=data row={'id':ident,'trace_from':first,'trace_to':len(self.trace)-1,'nodes':nodes};self.snapshots.append(row) write(self.output/'progress.json',{'state':'running','snapshots':len(self.snapshots),'latest_snapshot':ident,'checks':len(self.checks),'updated_at_utc':utc()}) return row def execute(self): for name in ['A','B']: node=Node(self.lab,name,self.daemon,self);self.nodes[name]=node;node.start() a,b=self.nodes['A'],self.nodes['B'] for n,roles in [(a,['sender','recipient']),(b,['miner','alternate'])]: for role in roles:n.rpc('createwallet',role) self.miner_addresses={'A':a.rpc('getnewaddress','common-regtest-rewards','legacy',wallet='sender'),'B':b.rpc('getnewaddress','private-branch-rewards','legacy',wallet='miner')} self.mine(a,103);self.connect() self.wait('txindexready',lambda:all(n.rpc('getindexinfo',quiet=True).get('txindex',{}).get('synced') for n in [a,b])) coins=sorted(a.rpc('listunspent',100,wallet='sender'),key=lambda x:(x['txid'],x['vout']));self.check('two distinctmaturecoins',len(coins)>=2) paddress=a.rpc('getnewaddress','intended-P','legacy',wallet='recipient');ptxid=self.make('P',coins[0],paddress,'1.00000000') self.metadata['common_height']=103;self.metadata['common_hash']=a.rpc('getbestblockhash');self.snapshot('S0-shared-chain') self.disconnect();a.rpc('sendrawtransaction',self.candidates['P']['raw_hex']);oldp=self.mine(a,1)[0];self.checkpoints['P']=oldp self.wait('Poldconfirmed',lambda:a.rpc('gettransaction',ptxid,wallet='sender',quiet=True)['confirmations']==1) self.snapshot('S1-P-old-branch-confirmed') branch_b=self.mine(b,3);fork=self.snapshot('S2-longer-branch-omits-P') self.check('firstwinningbranchhigherwork',int(fork['nodes']['B']['chain']['chainwork'],16)>int(fork['nodes']['A']['chain']['chainwork'],16)) self.events.append({'id':'reorg-P','common_height':103,'common_hash':self.metadata['common_hash'],'old_tip':oldp,'winning_tip_before_reconnect':branch_b[-1],'old_checkpoint':oldp,'winning_branch':branch_b}) self.connect();self.wait('Pconditionallyre-admitted-on-A',lambda:ptxid in a.rpc('getrawmempool',quiet=True)) s=self.snapshot('S3-P-returned-to-mempool') self.check('Pwalletnowunconfirmed',s['nodes']['A']['wallets']['sender']['transactions']['P']['confirmations']==0) self.check('Poldheaderstale',s['nodes']['A']['headers']['P']['confirmations']==-1) self.check('Pstaleexplicitbytesretained',s['nodes']['A']['raw']['P']['stale_block_lookup']['hex']==self.candidates['P']['raw_hex'] and s['nodes']['A']['raw']['P']['stale_block_lookup']['in_active_chain'] is False) if ptxid not in b.rpc('getrawmempool'): b.rpc('sendrawtransaction',self.candidates['P']['raw_hex']);self.events.append({'id':'explicit-P-rebroadcast-to-B','txid':ptxid,'same_raw_hex':True,'reason':'Re-admission on A does not require instant relay into B local mempool.'}) reconfirm=self.mine(b,1)[0];self.wait('Pbothreconfirmed',lambda:all(n.rpc('gettransaction',ptxid,wallet='sender' if n is a else 'alternate',allow_error=True,quiet=True).get('confirmations',0)>0 for n in [a])) self.wait('sync107',lambda:a.rpc('getbestblockhash',quiet=True)==b.rpc('getbestblockhash',quiet=True)) s=self.snapshot('S4-P-reconfirmed-and-removed',True);w=s['nodes']['A']['wallets']['sender'];since=w['since']['P']['target1'] active=[x for x in since['transactions'] if x['txid']==ptxid];removed=[x for x in since.get('removed',[]) if x['txid']==ptxid] self.check('Pbothtransactionsandremoved',bool(active) and bool(removed)) self.check('Premovedcurrentpositiveconfirmations',all(x['confirmations']>0 for x in removed)) self.check('Pcurrentblockchanged',w['transactions']['P']['blockhash']==reconfirm and reconfirm!=oldp) self.check('target6doesnotfilterP',any(x['txid']==ptxid for x in w['since']['P']['target6']['transactions'])) self.check('target6changescursor',w['since']['P']['target6']['lastblock']!=since['lastblock']) self.check('include_removedfalseomitsarray','removed' not in w['since']['P']['no_removed']) qaddress=a.rpc('getnewaddress','intended-Q','legacy',wallet='recipient');raddress=b.rpc('getnewaddress','different-R-recipient','legacy',wallet='alternate') qtxid=self.make('Q',coins[1],qaddress,'1.00000000');rtxid=self.make('R',coins[1],raddress,'2.00000000') self.check('QandRsameinputdifferentreceiver',self.candidates['Q']['funding']['txid']==self.candidates['R']['funding']['txid'] and self.candidates['Q']['funding']['vout']==self.candidates['R']['funding']['vout'] and qaddress!=raddress) self.disconnect();a.rpc('sendrawtransaction',self.candidates['Q']['raw_hex']);oldq=self.mine(a,1)[0];self.checkpoints['Q']=oldq self.snapshot('S5-Q-old-branch-confirmed') b.rpc('sendrawtransaction',self.candidates['R']['raw_hex']);branch_r=self.mine(b,3);s=self.snapshot('S6-longer-branch-confirms-R') self.check('secondwinningbranchhigherwork',int(s['nodes']['B']['chain']['chainwork'],16)>int(s['nodes']['A']['chain']['chainwork'],16)) self.events.append({'id':'reorg-Q','common_height':107,'common_hash':reconfirm,'old_tip':oldq,'winning_tip_before_reconnect':branch_r[-1],'old_checkpoint':oldq,'winning_branch':branch_r}) self.connect();self.wait('Qmarkedconflicted',lambda:a.rpc('gettransaction',qtxid,wallet='sender',quiet=True).get('confirmations',0)<0) s=self.snapshot('S7-Q-conflicted-R-confirmed');sender=s['nodes']['A']['wallets']['sender'];receiver=s['nodes']['A']['wallets']['recipient'] self.check('Qnegativeconfirmations',sender['transactions']['Q']['confirmations']<0) self.check('QsenderknowsRconflict',rtxid in sender['transactions']['Q']['walletconflicts']) self.check('Rconfirmed',sender['transactions']['R']['confirmations']>0) self.check('Qrecipientmarkedconflicted',receiver['transactions']['Q']['confirmations']<0) self.check('Qnotinmempool',qtxid not in s['nodes']['A']['mempool']) self.check('Rdifferentreceivergot2',s['nodes']['B']['wallets']['alternate']['transactions']['R']['amount']==2) self.check('MWEBinactiveboth',all(n.rpc('getblockchaininfo')['softforks']['mweb']['active'] is False for n in [a,b])) self.check('eightnamedsnapshots',len(self.snapshots)==8);self.check('threecandidates',len(self.candidates)==3) self.metadata['all_checks_passed']=True def close(self): for name,n in self.nodes.items():self.cleanup[name+'_process_stopped']=n.stop() target=self.lab.resolve() if target.parent!=self.scratch or not target.name.startswith('lw-reorg-regtest-'):raise RuntimeError('Refusing cleanup outside exactnewlab') shutil.rmtree(target);self.cleanup['temporary_datadir_removed']=not target.exists() def save(self,error): self.metadata['finished_at_utc']=utc() result={'metadata':self.metadata,'candidates':self.candidates,'blocks':sorted(self.blocks.values(),key=lambda x:(x['height'],x['hash'])),'snapshots':self.snapshots,'events':self.events,'checks':self.checks,'cleanup':self.cleanup,'error':error} write(self.output/'results.json',result);write(self.output/'rpc-trace.json',self.trace) fields=['case','A_height','B_height','A_tip','B_tip','P_confirmations','P_mempool_A','P_removed_confirmations','P_stale_header_confirmations','Q_confirmations','Q_walletconflicts','R_confirmations','recipient_trusted_ltc'] with (self.output/'cases.csv').open('w',encoding='utf-8',newline='') as f: out=csv.DictWriter(f,fieldnames=fields);out.writeheader() for s in self.snapshots: a=s['nodes']['A'];b=s['nodes']['B'];w=a['wallets']['sender'];tx=w['transactions'];since=w['since'].get('P',{}).get('target1',{});removed=[x['confirmations'] for x in since.get('removed',[]) if x['txid']==self.candidates['P']['txid']] out.writerow({'case':s['id'],'A_height':a['chain']['blocks'],'B_height':b['chain']['blocks'],'A_tip':a['chain']['bestblockhash'],'B_tip':b['chain']['bestblockhash'],'P_confirmations':tx.get('P',{}).get('confirmations',''),'P_mempool_A':str(self.candidates['P']['txid'] in a['mempool']).lower(),'P_removed_confirmations':json.dumps(removed),'P_stale_header_confirmations':a['headers'].get('P',{}).get('confirmations',''),'Q_confirmations':tx.get('Q',{}).get('confirmations',''),'Q_walletconflicts':json.dumps(tx.get('Q',{}).get('walletconflicts',[])),'R_confirmations':tx.get('R',{}).get('confirmations',''),'recipient_trusted_ltc':a['wallets']['recipient']['balances']['mine']['trusted']}) write(self.output/'progress.json',{'state':'passed' if error is None else 'failed','snapshots':len(self.snapshots),'checks':len(self.checks),'cleanup':self.cleanup,'error':error,'updated_at_utc':utc()}) def main(): if not __debug__:raise RuntimeError('Do not run with Python -O') ap=argparse.ArgumentParser(description=__doc__);ap.add_argument('daemon');ap.add_argument('cli');ap.add_argument('new_output_directory');args=ap.parse_args();s=None;error=None try:s=Study(args.daemon,args.cli,args.new_output_directory);s.execute() except Exception as e: error=type(e).__name__+': '+str(e) if s: s.metadata['all_checks_passed']=False s.metadata['failure_observation']={name:{'mempool':n.rpc('getrawmempool',allow_error=True),'chain':n.rpc('getblockchaininfo',allow_error=True)} for name,n in s.nodes.items()} for name,n in s.nodes.items(): debug=n.root/'regtest'/'debug.log' if debug.is_file():(s.output/('failed-debug-'+name+'.txt')).write_text(debug.read_text(encoding='utf-8',errors='replace')[-24000:].replace(str(s.lab),''),encoding='utf-8') finally: if s: try:s.close() except Exception as e:error=error or ('Cleanup: '+str(e)) s.save(error) print(json.dumps({'state':'passed' if error is None else 'failed','snapshots':len(s.snapshots) if s else 0,'checks':len(s.checks) if s else 0,'cleanup':s.cleanup if s else {},'error':error}));return 0 if error is None else 1 if __name__=='__main__':raise SystemExit(main())