"""Litecoin.watch transaction lab, isolated Core RPC experiment. Usage: python run-experiment.py /path/to/litecoind output-directory Python standard library only. Creates two NEW temporary regtest data directories. Both nodes have listening, outbound connections, discovery and DNS seeds disabled. Selected blocks are copied through localhost RPC: this is not a propagation test. Never points to an existing wallet or mainnet directory. No real funds are used. Signed transactions and public outputs are exported; private keys/cookies are not. """ import base64,csv,datetime,decimal,hashlib,json,pathlib,socket,subprocess,sys,tempfile,time,urllib.request,urllib.error D=decimal.Decimal COIN=100000000 def sats(value):return int(D(str(value))*COIN) def utc():return datetime.datetime.now(datetime.timezone.utc).isoformat() class Node: def __init__(self,daemon,name,root): self.root=root/name;self.root.mkdir();self.name=name with socket.socket() as s:s.bind(('127.0.0.1',0));self.port=s.getsockname()[1] self.process=subprocess.Popen([str(daemon),'-regtest','-datadir='+str(self.root),'-rpcport='+str(self.port),'-rpcbind=127.0.0.1','-listen=0','-connect=0','-dnsseed=0','-discover=0','-upnp=0','-txindex=1','-fallbackfee=0.00002','-printtoconsole=0'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0)) for _ in range(120): try:self.rpc('getblockchaininfo');break except Exception:time.sleep(.25) else:raise RuntimeError('Node did not start') def rpc(self,method,*args,wallet=None): cookie=(self.root/'regtest/.cookie').read_text().strip() request=urllib.request.Request('http://127.0.0.1:'+str(self.port)+('/wallet/'+wallet if wallet else '/'),data=json.dumps({'jsonrpc':'1.0','id':'lab','method':method,'params':args}).encode(),headers={'Authorization':'Basic '+base64.b64encode(cookie.encode()).decode(),'Content-Type':'application/json'}) try: with urllib.request.urlopen(request,timeout=60) as r:data=json.load(r) except urllib.error.HTTPError as e:data=json.loads(e.read()) if data['error']:raise RuntimeError(method+': '+str(data['error'])) return data['result'] def stop(self): try:self.rpc('stop') except Exception:self.process.terminate() self.process.wait(timeout=30) def main(): daemon=pathlib.Path(sys.argv[1]).resolve();out=pathlib.Path(sys.argv[2]).resolve();out.mkdir(parents=True,exist_ok=True) temp=pathlib.Path(tempfile.mkdtemp(prefix='lw-transaction-lab-'));nodes=[];checks=[] def check(name,condition): if not condition:raise AssertionError(name) checks.append({'test':name,'passed':True}) def submit(source,target,blocks): for block in blocks: raw=source.rpc('getblock',block,0);result=target.rpc('submitblock',raw) # BIP22 returns inconclusive for a stored side-branch block not yet connected. # The longer branch is checked separately for activation and transaction state. check('Block stored by '+target.name,result in [None,'duplicate','inconclusive'] and target.rpc('getblock',block,0)==raw) def state(node,txid): chain=node.rpc('getblockchaininfo');pool=node.rpc('getrawmempool');result={'node':node.name,'height':chain['blocks'],'tip':chain['bestblockhash'],'chainwork':chain['chainwork'],'observedAt':utc(),'confirmations':None,'status':'not-seen'} if txid in pool:result.update(status='unconfirmed',confirmations=0) else: try: tx=node.rpc('getrawtransaction',txid,True) if tx.get('confirmations',0)>0:result.update(status='confirmed',confirmations=tx['confirmations'],blockhash=tx['blockhash']) except RuntimeError:pass return result try: a=Node(daemon,'A',temp);nodes.append(a);b=Node(daemon,'B',temp);nodes.append(b) version=a.rpc('getnetworkinfo')['subversion'];check('Core version pinned','0.21.5.8' in version) for n in nodes:check('Isolated regtest '+n.name,n.rpc('getblockchaininfo')['chain']=='regtest' and n.rpc('getconnectioncount')==0) for wallet in ['miner','lab','recipient']:a.rpc('createwallet',wallet) b.rpc('createwallet','miner') mine=a.rpc('getnewaddress','','bech32',wallet='miner');mine_b=b.rpc('getnewaddress','','bech32',wallet='miner') initial=a.rpc('generatetoaddress',101,mine) addresses={kind:[a.rpc('getnewaddress','lab-'+kind,kind,wallet='lab') for _ in range(100)] for kind in ['legacy','p2sh-segwit','bech32']} funding=a.rpc('sendmany','',{addr:.02 for values in addresses.values() for addr in values},wallet='miner') funding_block=a.rpc('generatetoaddress',1,mine)[0] submit(a,b,initial+[funding_block]) utxos=a.rpc('listunspent',1,9999999,[],True,wallet='lab');check('300 confirmed test inputs',len(utxos)==300) grouped={kind:[u for u in utxos if u['address'] in group] for kind,group in addresses.items()} receiver=a.rpc('getnewaddress','recipient','bech32',wallet='recipient');change=a.rpc('getnewaddress','change','bech32',wallet='lab') cases=[] def candidate(kind,count,amount=.01,label=None): chosen=grouped[kind][:count] raw=a.rpc('createrawtransaction',[{'txid':u['txid'],'vout':u['vout']} for u in chosen],{receiver:amount}) funded=a.rpc('fundrawtransaction',raw,{'add_inputs':False,'changeAddress':change,'changePosition':1,'feeRate':.00002},wallet='lab') signed=a.rpc('signrawtransactionwithwallet',funded['hex'],wallet='lab');check('Complete signature '+str(label or (kind,count,amount)),signed['complete']) decoded=a.rpc('decoderawtransaction',signed['hex']);accepted=a.rpc('testmempoolaccept',[signed['hex']])[0] check('Policy accepts '+str(label or (kind,count,amount)),accepted['allowed']) total=sum(sats(u['amount']) for u in chosen);outputs=[{'index':v['n'],'valueLitoshi':sats(v['value']),'scriptHex':v['scriptPubKey']['hex'],'type':v['scriptPubKey']['type'],'role':'recipient' if v['n']==0 else 'change'} for v in decoded['vout']] fee=total-sum(v['valueLitoshi'] for v in outputs) check('Conservation '+str(label or (kind,count,amount)),fee==sats(funded['fee']) and len(decoded['vin'])==count and len(outputs)==2 and outputs[0]['valueLitoshi']==sats(amount)) record={'id':label or kind+'-'+str(count),'inputType':kind,'inputs':count,'outputs':2,'inputTotalLitoshi':total,'recipientLitoshi':sats(amount),'changeLitoshi':outputs[1]['valueLitoshi'],'feeLitoshi':fee,'requestedFeeRateLitoshiVb':2,'actualFeeRateLitoshiVb':fee/decoded['vsize'],'vsize':decoded['vsize'],'weight':decoded['weight'],'bytes':decoded['size'],'txid':decoded['txid'],'wtxid':decoded['hash'],'mempoolAllowed':accepted['allowed'],'hex':signed['hex'],'prevouts':[{'txid':u['txid'],'vout':u['vout'],'valueLitoshi':sats(u['amount']),'scriptHex':u['scriptPubKey']} for u in chosen],'decodedOutputs':outputs} return record for kind in addresses: for count in [1,10,50,100]:cases.append(candidate(kind,count)) flow=[candidate('bech32',1,x,'flow-'+str(i)) for i,x in enumerate([.005,.01,.015])] print('Measured 12 fee-size cases and 3 payment amounts.',flush=True) tx=flow[1];txid=a.rpc('sendrawtransaction',tx['hex']);timeline=[] def record(key,title):timeline.append({'key':key,'title':title,'A':state(a,txid),'B':state(b,txid)}) record('broadcast','Payment broadcast only to node A') original=a.rpc('generatetoaddress',1,mine);record('one-confirmation','Node A includes the payment') original+=a.rpc('generatetoaddress',1,mine);record('lagging-observer','Node A has two confirmations; node B is behind') check('Lagging observer distinction',timeline[-1]['A']['confirmations']==2 and timeline[-1]['B']['status']=='not-seen' and timeline[-1]['B']['height']==102) alternative=b.rpc('generatetoaddress',3,mine_b);record('competing-branch','Node B builds a competing branch without the payment') submit(b,a,alternative);record('reorganized','Node A adopts the branch with more accumulated work') check('Reorganization changed active tip',a.rpc('getbestblockhash')==b.rpc('getbestblockhash') and int(timeline[-1]['A']['chainwork'],16)>int(timeline[2]['A']['chainwork'],16)) check('Payment returned to mempool',timeline[-1]['A']['status']=='unconfirmed' and timeline[-1]['A']['confirmations']==0) check('Old confirming block is stale',a.rpc('getblockheader',original[0])['confirmations']==-1) final=a.rpc('generatetoaddress',1,mine);record('reconfirmed','Node A includes the payment again') submit(a,b,final);record('synchronized','Both nodes now agree') check('Both nodes confirm same payment',timeline[-1]['A']['confirmations']==1 and timeline[-1]['B']['confirmations']==1 and timeline[-1]['A']['tip']==timeline[-1]['B']['tip']) result={'schema':1,'testedAt':utc(),'version':version,'network':'regtest','platform':sys.platform,'binarySha256':hashlib.sha256(daemon.read_bytes()).hexdigest(),'isolation':'Two fresh nodes; listen=0, connect=0, dnsseed=0, discover=0, upnp=0. Blocks delivered manually by submitblock over localhost RPC. No peer propagation measurement.','method':'One 300-output funding transaction creates 100 UTXOs of 0.02 LTC for each input type. Candidates spend fixed explicit inputs, pay 0.01 LTC to a P2WPKH recipient and return P2WPKH change. Core fundrawtransaction requests 0.00002 LTC/kvB (2 litoshi/vB); signed vsize and actual fee are measured separately. Each candidate is checked with testmempoolaccept before the reorg payment is broadcast.','cases':cases,'flow':flow,'timeline':timeline,'branches':{'commonHeight':102,'commonHash':funding_block,'original':original,'replacement':alternative,'reconfirmed':final[0]},'checks':checks,'limitations':['Controlled regtest operations, not mainnet performance, attack economics or reorganization probability.','Transparent P2PKH, P2SH-P2WPKH and P2WPKH inputs only; both outputs use P2WPKH. No multisig, Taproot or MWEB size claim.','Different keys, signatures and coin selection can change measured sizes on another run.','Not-seen means absent from that observer; it does not prove no transaction exists elsewhere.','After a real reorganization a payment may be re-included, remain unconfirmed, conflict with another spend, or fail policy checks. This run covers one return-to-mempool and re-inclusion case.']} (out/'results.json').write_text(json.dumps(result,indent=2),encoding='utf-8') fields=['id','inputType','inputs','outputs','inputTotalLitoshi','recipientLitoshi','changeLitoshi','feeLitoshi','requestedFeeRateLitoshiVb','actualFeeRateLitoshiVb','vsize','weight','bytes','txid','wtxid','mempoolAllowed'] with (out/'measurements.csv').open('w',newline='',encoding='utf-8') as f: w=csv.DictWriter(f,fieldnames=fields,extrasaction='ignore');w.writeheader();w.writerows(cases+flow) print(json.dumps({'cases':len(cases),'flowAmounts':len(flow),'timelineSteps':len(timeline),'checks':len(checks),'version':version,'out':str(out)}),flush=True) finally: for n in reversed(nodes):n.stop() if __name__=='__main__':main()