#!/usr/bin/env python3 """Offline reorg export checks. No network, wallet execution or signature engine. Usage: python verify-reorg.py.txt [package-directory] [--report private-report.json] """ import argparse, csv, hashlib, json, re from pathlib import Path COUNT = 0 def check(condition, message): global COUNT COUNT += 1 if not condition: raise ValueError(message) def digest(data): return hashlib.sha256(hashlib.sha256(data).digest()).digest() def compact(n): if n < 253: return bytes([n]) if n <= 0xffff: return b'\xfd' + n.to_bytes(2, 'little') if n <= 0xffffffff: return b'\xfe' + n.to_bytes(4, 'little') return b'\xff' + n.to_bytes(8, 'little') class Reader: def __init__(self, data): self.data, self.pos = data, 0 def take(self, n): check(n >= 0 and self.pos+n <= len(self.data), 'truncated transaction') b=self.data[self.pos:self.pos+n]; self.pos += n; return b def num(self, n): return int.from_bytes(self.take(n), 'little') def varint(self): n=self.num(1) if n < 253: return n width={253:2,254:4,255:8}[n]; value=self.num(width) check(value >= {253:253,254:65536,255:4294967296}[n], 'noncanonical CompactSize') check(value <= 1000000, 'unexpectedly large CompactSize') return value def blob(self): return self.take(self.varint()) def parse_tx(raw_hex): raw=bytes.fromhex(raw_hex); r=Reader(raw) v=r.take(4); n=r.varint(); witness=False if n == 0: flag=r.num(1) check(flag == 1, 'unsupported transaction flags (MWEB outside scope)') witness=True; n=r.varint() check(n > 0, 'no transparent inputs') vin=[]; vin_raw=[] for _ in range(n): start=r.pos; prev=r.take(32)[::-1].hex(); index=r.num(4) script=r.blob(); sequence=r.num(4) vin.append({'txid':prev,'vout':index,'script_sig':script.hex(),'sequence':sequence}) vin_raw.append(raw[start:r.pos]) m=r.varint(); outputs=[]; out_raw=[] for _ in range(m): start=r.pos; value=r.num(8); script=r.blob() outputs.append({'value_litoshis':value,'script_pub_key':script.hex()}) out_raw.append(raw[start:r.pos]) if witness: for item in vin: item['witness']=[r.blob().hex() for _ in range(r.varint())] lock=r.take(4) check(r.pos == len(raw), 'trailing transaction data') stripped=v+compact(n)+b''.join(vin_raw)+compact(m)+b''.join(out_raw)+lock return {'txid':digest(stripped)[::-1].hex(), 'wtxid':digest(raw)[::-1].hex(), 'version':int.from_bytes(v,'little',signed=True),'locktime':int.from_bytes(lock,'little'), 'inputs':vin,'outputs':outputs,'size':len(raw), 'weight':len(stripped)*3+len(raw),'vsize':(len(stripped)*3+len(raw)+3)//4} def objects(value): if isinstance(value,dict): yield value for child in value.values(): yield from objects(child) elif isinstance(value,list): for child in value: yield from objects(child) def base58check(address): alphabet='123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; n=0 for char in address: check(char in alphabet,'non-Base58 address') n=n*58+alphabet.index(char) data=b'\x00'*(len(address)-len(address.lstrip('1')))+n.to_bytes((n.bit_length()+7)//8,'big') check(len(data)>=5 and digest(data[:-4])[:4]==data[-4:],'address checksum mismatch') return data[:-4] def address_script(address): payload=base58check(address) check(len(payload)==21 and payload[0]==111,'expected regtest legacy P2PKH address') return '76a914'+payload[1:].hex()+'88ac' def merkle_root(txids): check(bool(txids),'empty block txid list') row=[bytes.fromhex(txid)[::-1] for txid in txids] while len(row)>1: if len(row)%2: row.append(row[-1]) row=[digest(row[i]+row[i+1]) for i in range(0,len(row),2)] return row[0][::-1].hex() def target_work(bits): bits=int(bits,16) if isinstance(bits,str) else bits size=bits>>24; value=bits&0x7fffff check(not bits&0x800000 and value>0,'negative/zero compact target') target=value>>(8*(3-size)) if size<=3 else value<<(8*(size-3)) check(0=0 and rest[0]['value_litoshis']>=0,cid+': negative fee/change') for key in ('txid','version','locktime','size','weight','vsize'): check(c['decoded'][key]==tx[key],cid+': decoded '+key+' mismatch') check(c['decoded']['hash']==tx['wtxid'],cid+': decoded wtxid mismatch') check(set(candidates)=={'P','Q','R'},'unexpected candidate IDs') q,r=parsed['Q']['inputs'][0],parsed['R']['inputs'][0] check((q['txid'],q['vout'])==(r['txid'],r['vout']),'Q/R do not compete for the same outpoint') check(candidates['Q']['raw_hex']!=candidates['R']['raw_hex'] and candidates['Q']['txid']!=candidates['R']['txid'],'Q/R must be distinct signed transactions') check(candidates['Q']['recipient_address']!=candidates['R']['recipient_address'],'Q/R recipient difference missing') return parsed def flag_privacy(value): expanded=list(objects(value)) for item in list(expanded): if isinstance(item.get('stdout'),str): try: expanded.extend(objects(json.loads(item['stdout']))) except (ValueError,TypeError): pass for item in expanded: for key,val in item.items(): if key.lower() in ('rpcpassword','private_key','privkey','seed','mnemonic','cookie') and val: check(isinstance(val,str) and any(s in val.lower() for s in ('redact','placeholder','generated','not stored')), 'possible secret field: '+key) if item.get('method')=='dumpprivkey': check('redact' in str(item.get('stdout','')).lower(), 'unredacted dumpprivkey output') text=json.dumps(value) check(not re.search(r'-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY',text), 'private-key PEM marker') check(not re.search(r'\b(?:xprv|tprv|Ltpv|ltpv)[1-9A-HJ-NP-Za-km-z]{40,}',text), 'extended private-key marker') alphabet='123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' for token in re.findall(r'(?1 else 1 include_removed=params[3] if len(params)>3 else True check(isinstance(target,int) and target>=1,label+': invalid cursor target') check(value['lastblock']==path[max(0,tip+1-min(target,tip+1))],label+': lastblock cursor equation') check(('removed' in value)==include_removed,label+': include_removed presence mismatch') old=branch(graph,cursor) common=max(h for h in old if path.get(h)==old[h]) detached={old[h] for h in old if h>common} bytxid={t['txid']:cid for cid,t in parsed.items()} for collection in ('transactions','removed'): for row in value.get(collection,[]): if row['txid'] not in bytxid: continue cid=bytxid[row['txid']]; tx=parsed[cid]; current=wallet_transactions[cid] check(not failed(current),label+': listed candidate missing from selected wallet') check(row['confirmations']==current['confirmations'],label+': emitted row is not current wallet status') out=tx['outputs'][row['vout']] if 'address' in row: check(address_script(row['address'])==out['script_pub_key'],label+': emitted output address mismatch') expected=out['value_litoshis']*(-1 if row['category']=='send' else 1) check(litoshis(row['amount'])==expected,label+': emitted output amount mismatch') if 'fee' in row: check(litoshis(row['fee'])==-candidates[cid]['fee_litoshis'],label+': emitted fee mismatch') if row['confirmations']>0: block=active_membership(graph,path,row['txid']) check(row['blockhash']==block['hash'] and row['blockheight']==block['height'],label+': emitted current block mismatch') if collection=='removed': check(any(row['txid'] in graph[h]['txids'] for h in detached),label+': removed row has no detached-block membership') # Row multiplicity is intentional: sender accounting can have send/receive # effects for multiple outputs. Never impose one row per transaction ID. def verify(folder,skip_checksums=False): results=json.loads((folder/'results.json').read_text(encoding='utf-8')) trace=json.loads((folder/'rpc-trace.json').read_text(encoding='utf-8')) meta=results['metadata']; candidates=results['candidates']; snapshots=results['snapshots'] check(meta['protocol']=='litecoin-watch-reorg-v1','unexpected export protocol') check(meta['source_commit']=='ec1b6489a900d09cf5991e220dce089c77a232a2','source pin mismatch') check(meta['core_version']=='/LitecoinCore:0.21.5.8/','declared version mismatch') check(meta['daemon_sha256']=='7430c6d0d4cb59d355361a0b90ffbfc4ac725609483e04c242bf95372cc9b8fe','declared daemon hash mismatch') check(meta['cli_sha256']=='6f835854576a473599f89b9842bf7855dabda1183f5a707ac5d5e15aa48761d8','declared CLI hash mismatch') check(hashlib.sha256((folder/'reorg-test.py.txt').read_bytes()).hexdigest()==meta['runner_sha256'],'exported runner hash mismatch') check(results['error'] is None and meta['all_checks_passed'] is True,'recorded run incomplete') check(all(c['passed'] is True for c in results['checks']),'recorded runtime check failed') check(all(v is True for v in results['cleanup'].values()),'recorded cleanup incomplete') check(len(snapshots)==8 and len(results['checks'])==83,'study snapshot/check count changed') check(all(r['index']==i for i,r in enumerate(trace)),'trace indices not sequential and zero-based') graph=block_graph(results['blocks']); parsed=candidate_bytes(candidates,graph) check(len(graph)==112,'study block count changed') for block in results['blocks']: node=block['node']; h=block['hash'] find_rpc(trace,node,None,'getblockheader',lambda p:p==[h,False],block['raw_header'],'raw block header '+h) recs=[r for r in trace if r['node']==node and r.get('wallet') is None and r['method']=='getblock' and r['params'][0]==h and 'result' in r] check(bool(recs),'block has no raw getblock observation') body=recs[0]['result']; txids=[x['txid'] if isinstance(x,dict) else x for x in body['tx']] check(txids==block['txids'],'block transaction list differs from RPC') for key in ('hash','height','previousblockhash','chainwork','time','version','merkleroot','bits','nonce'): check(body[key]==block[key],'block '+key+' differs from RPC') snapshot_byid={}; snapshot_paths={}; raw_bindings=0; since_bindings=0 for snap in snapshots: sid=snap['id']; check(sid not in snapshot_byid,'duplicate snapshot ID');snapshot_byid[sid]=snap first,last=snap['trace_from'],snap['trace_to'] check(0<=first<=last1 else 1)==target and (p[3] if len(p)>3 else True)==removed,value,sid+' '+role+' '+checkpoint+' '+variant) validate_since(value,record,wallet['transactions'],parsed,candidates,graph,path,tip,sid+' '+role+' '+checkpoint+' '+variant) since_bindings+=1 if 'target6' in variants: check(variants['target6']['transactions']==variants['target1']['transactions'],'target_confirmations incorrectly treated as transaction filter') check(variants['target6']['removed']==variants['target1']['removed'],'target_confirmations changed removed rows') for cid,variants in data['raw'].items(): txid=candidates[cid]['txid'] for variant,value in variants.items(): explicit=variant=='stale_block_lookup' record=find_rpc(records,node,None,'getrawtransaction',lambda p:p[0]==txid and (len(p)>=3)==explicit,value,sid+' '+node+' raw '+cid+' '+variant) if failed(value): continue check(value['txid']==txid and value['hex']==candidates[cid]['raw_hex'],sid+': raw lookup bytes changed') if explicit: h=record['params'][2]; active=h in path.values() check(value['in_active_chain']==active,sid+': specified block active flag mismatch') check(txid in graph[h]['txids'],sid+': specified block does not contain candidate') check(value['confirmations']==(tip-graph[h]['height']+1 if active else 0),sid+': explicit-block raw confirmation mismatch') elif 'blockhash' in value: # A txindex lookup can retain a stale-block association. # Assess the returned block, not an assumed active location. block=graph[value['blockhash']]; active=block['hash'] in path.values() check(txid in block['txids'],sid+': indexed raw association lacks transaction membership') check(value['confirmations']==(tip-block['height']+1 if active else 0),sid+': indexed raw confirmation mismatch') check('in_active_chain' not in value,sid+': active flag unexpectedly present without explicit block argument') for cid,value in data['headers'].items(): record=find_rpc(records,node,None,'getblockheader',lambda p:p[0]==value.get('hash') if not failed(value) else True,value,sid+' '+node+' saved header '+cid) if failed(value):continue h=value['hash']; active=h in path.values() check(value['confirmations']==(tip-graph[h]['height']+1 if active else -1),sid+': saved-header active depth mismatch') # This study's recipient only receives P/Q and never spends them. if node=='A': trusted=0 for cid in ('P','Q'): value=data['wallets']['recipient']['transactions'].get(cid) if value and not failed(value) and value['confirmations']>0:trusted+=candidates[cid]['amount_litoshis'] check(litoshis(data['wallets']['recipient']['balances']['mine']['trusted'])==trusted,sid+': controlled recipient trusted-balance mismatch') for event in results['events']: if not event['id'].startswith('reorg-'):continue old=branch(graph,event['old_tip']); winning=branch(graph,event['winning_tip_before_reconnect']) common=max(h for h in old if winning.get(h)==old[h]) check(common==event['common_height'] and old[common]==event['common_hash'],'event fork point mismatch') check(int(graph[event['winning_tip_before_reconnect']]['chainwork'],16)>int(graph[event['old_tip']]['chainwork'],16),'winning private branch lacks greater recorded work') check(event['winning_branch']==[winning[h] for h in range(common+1,max(winning)+1)],'event winning-branch linkage mismatch') check(event['old_checkpoint']==event['old_tip'],'study cursor not old tip') # Explicit hand anchors for the article's main conclusions. states=[snapshot_byid[s['id']] for s in snapshots] a=lambda i:states[i]['nodes']['A'] tx=lambda i,w,c:a(i)['wallets'][w]['transactions'][c] p=candidates['P']['txid'];q=candidates['Q']['txid'];r=candidates['R']['txid'] check(tx(1,'recipient','P')['confirmations']==1 and a(1)['chain']['blocks']==104,'P initial confirmation anchor') check(tx(3,'recipient','P')['confirmations']==0 and p in a(3)['mempool'],'P did not return to A mempool') check(p not in states[3]['nodes']['B']['mempool'],'study assumes instantaneous P relay into B') check(tx(4,'recipient','P')['confirmations']==1 and tx(4,'recipient','P')['blockheight']==107,'P re-confirmation anchor') since=a(4)['wallets']['recipient']['since']['P'] for name in ('transactions','removed'): entries=[row for row in since['target1'][name] if row['txid']==p] check(len(entries)==1,'clean recipient requires one P receive entry in each array') row=entries[0];check(row['category']=='receive' and row['vout']==0 and row['confirmations']==1 and litoshis(row['amount'])==100000000,'P current-positive removed-row anchor') check(since['target1']['lastblock']==snapshot_paths[states[4]['id'],'A'][107] and since['target6']['lastblock']==snapshot_paths[states[4]['id'],'A'][102],'target1/6 cursor hand anchor') check(a(4)['raw']['P']['stale_block_lookup']['in_active_chain'] is False and a(4)['raw']['P']['stale_block_lookup']['confirmations']==0 and a(4)['headers']['P']['confirmations']==-1,'three-object stale-block hand anchor') rebroadcast=[x for x in trace[states[3]['trace_to']+1:states[4]['trace_from']] if x['node']=='B' and x['method']=='sendrawtransaction' and x['params'][0]==candidates['P']['raw_hex'] and x.get('result')==p] check(bool(rebroadcast),'explicit unchanged-byte P submission to B missing') sender=tx(7,'sender','Q'); recipient=tx(7,'recipient','Q') check(sender['confirmations']==recipient['confirmations']==-3,'Q current conflict-depth anchor') check(r in sender['walletconflicts'] and recipient['walletconflicts']==[],'selected-wallet conflict-scope anchor') check(litoshis(recipient['amount'])==100000000 and recipient['details']==[],'Q retained amount/empty detail hazard missing') check(tx(7,'sender','R')['confirmations']==3,'R active confirmation anchor') alternate=states[7]['nodes']['B']['wallets']['alternate']['transactions']['R'] check(alternate['confirmations']==3 and litoshis(alternate['amount'])==200000000,'R different recipient amount anchor') check(failed(tx(7,'recipient','R')) and tx(7,'recipient','R')['error']['code']==-5,'original recipient unexpectedly knows R as wallet payment') with (folder/'cases.csv').open(encoding='utf-8',newline='') as stream:rows=list(csv.DictReader(stream)) check(len(rows)==len(states),'CSV snapshot count mismatch') for row,state in zip(rows,states): data=state['nodes']['A']; wa=data['wallets']['sender']; rec=data['wallets']['recipient']; pa=wa['transactions'].get('P',{});qa=wa['transactions'].get('Q',{});ra=wa['transactions'].get('R',{}) expected={'case':state['id'],'A_height':data['chain']['blocks'],'B_height':state['nodes']['B']['chain']['blocks'],'A_tip':data['chain']['bestblockhash'],'B_tip':state['nodes']['B']['chain']['bestblockhash'],'P_confirmations':pa.get('confirmations',''),'P_mempool_A':str(p in data['mempool']).lower(),'P_removed_confirmations':json.dumps([x['confirmations'] for x in wa.get('since',{}).get('P',{}).get('target1',{}).get('removed',[]) if x['txid']==p]),'P_stale_header_confirmations':data.get('headers',{}).get('P',{}).get('confirmations',''),'Q_confirmations':qa.get('confirmations',''),'Q_walletconflicts':json.dumps(qa.get('walletconflicts',[])),'R_confirmations':ra.get('confirmations','')} for key,value in expected.items():check(row[key]==str(value),state['id']+': CSV '+key+' mismatch') check(litoshis(row['recipient_trusted_ltc'])==litoshis(rec['balances']['mine']['trusted']),state['id']+': CSV trusted balance mismatch') flag_privacy(results);flag_privacy(trace) for path in folder.iterdir(): if path.is_file() and path.suffix in ('.json','.csv','.txt'):flag_privacy(path.read_text(encoding='utf-8')) checksums=0 if not skip_checksums: for line in (folder/'SHA256SUMS.txt').read_text(encoding='utf-8').splitlines(): if not line.strip():continue expected,name=line.split(None,1);name=name.lstrip('*') check('/' not in name and '\\' not in name and ':' not in name and name not in ('.','..'),'unsafe checksum filename') check(hashlib.sha256((folder/name).read_bytes()).hexdigest()==expected,'checksum mismatch: '+name) checksums+=1 check(checksums>0,'empty checksum file') return {'status':'passed','checks':COUNT,'snapshots':len(states),'candidate_transactions':len(parsed),'block_headers':len(graph),'rpc_records':len(trace),'wallet_transaction_bindings':raw_bindings,'listsinceblock_bindings':since_bindings,'checksum_files':checksums,'limits':['No ECDSA signature validation.','No Scrypt proof-of-work validation or full block consensus validation.','No independent attestation that the published Core execution occurred.','This is consistency checking of one controlled export, not a mainnet reorg probability, finality threshold or production payment processor.']} if __name__=='__main__': parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('directory',nargs='?',default=str(Path(__file__).resolve().parent)) parser.add_argument('--report',type=Path) parser.add_argument('--skip-checksums',action='store_true',help='development only; excludes package checksum phase') args=parser.parse_args() try: report=verify(Path(args.directory),args.skip_checksums) except Exception as error:report={'status':'failed','checks':COUNT,'error':str(error)} if args.report:args.report.write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8') print(json.dumps(report,indent=2)) raise SystemExit(0 if report['status']=='passed' else 1)