"""Reproduce one controlled relative-lock study in fresh Litecoin Core 0.21.5.8 regtest. Usage: python relative-lock-test.py.txt DAEMON CLI NEW_OUTPUT_DIRECTORY Pinned Windows builds are checked, copied to a disposable local temporary lab, then run with all peers disabled. No existing datadir, wallet, seed or RPC endpoint is accepted. Outputs contain valueless synthetic transactions, never payment instructions. The educational ECDSA helper is not production wallet software; Core validates spends. Python3.10+ standard library. Original Litecoin.watch code MIT; exported observations CC0. """ from pathlib import Path from decimal import Decimal import argparse, subprocess, tempfile, time, socket, json, hashlib, hmac, shutil, sys from datetime import datetime, timezone P=0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f N=0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141 G=(0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798, 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8) def double_sha(x): return hashlib.sha256(hashlib.sha256(x).digest()).digest() def varint(n): if n<253: return bytes([n]) if n<=65535: return b'\xfd'+n.to_bytes(2,'little') raise ValueError('Synthetic script too large') def push(x): if len(x)<76: return bytes([len(x)])+x if len(x)<=255: return b'\x4c'+bytes([len(x)])+x raise ValueError('Synthetic push too large') def scriptnum(n): x=n.to_bytes((n.bit_length()+7)//8,'little') return x+(b'\0' if x[-1]&128 else b'') def add_points(a,b): if a is None: return b if b is None: return a if a[0]==b[0] and (a[1]+b[1])%P==0: return None slope=((3*a[0]*a[0])*pow(2*a[1],-1,P) if a==b else (b[1]-a[1])*pow(b[0]-a[0],-1,P))%P x=(slope*slope-a[0]-b[0])%P return x,(slope*(a[0]-x)-a[1])%P def multiply(k): r=None; point=G while k: if k&1: r=add_points(r,point) point=add_points(point,point); k>>=1 return r def decode_disposable_wif(wif): alphabet='123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; n=0 for c in wif: n=n*58+alphabet.index(c) raw=n.to_bytes((n.bit_length()+7)//8,'big') raw=b'\0'*(len(wif)-len(wif.lstrip('1')))+raw assert double_sha(raw[:-4])[:4]==raw[-4:] body=raw[:-4]; assert len(body)==34 and body[-1]==1 secret=int.from_bytes(body[1:33],'big'); assert 0' if isinstance(v,str) else v for v in params] if method=='dumpprivkey': safeparams=[''] if method in ['backupwallet','loadwallet','unloadwallet']: safeparams=[str(v).replace(str(self.root),'') for v in params] if method=='signrawtransactionwithkey': safeparams=[params[0],[''],*params[2:]] stdout=cp.stdout.strip(); stderr=cp.stderr.strip() if method=='dumpprivkey': stdout='' stdout=stdout.replace(str(self.root),''); stderr=stderr.replace(str(self.root),'') rec={'method':method,'wallet':wallet,'params':safeparams,'exitCode':cp.returncode,'stdout':stdout,'stderr':stderr} if capture:self.trace.append(rec) if cp.returncode: if allow_error:return {'rpcError':rec} raise RPCError(method,rec) if not cp.stdout.strip():return None try:return json.loads(cp.stdout) except json.JSONDecodeError:return cp.stdout.strip() def mine(self,n=1): hashes=self.rpc('generatetoaddress',n,self.miner) assert len(hashes)==n return hashes def tip(self): chain=self.rpc('getblockchaininfo') return {'tipHeight':chain['blocks'],'nextBlockHeight':chain['blocks']+1,'medianTimePast':chain['mediantime']} def case(self,label,**values): c={'case':label,**values};self.cases.append(c); return c def accept(self,raw):return self.rpc('testmempoolaccept',[raw])[0] def plain_tx(self,utxo,dest,amount,lock=0,sequence=4294967294,wallet='funding'): raw=self.rpc('createrawtransaction',[{'txid':utxo['txid'],'vout':utxo['vout'],'sequence':sequence}],{dest:amount},lock) signed=self.rpc('signrawtransactionwithwallet',raw,wallet=wallet) assert signed['complete'];return signed['hex'] def fund(self,address,amount=1): txid=self.rpc('sendtoaddress',address,amount,wallet='funding');blockhash=self.mine()[0] tx=self.rpc('getrawtransaction',txid,True,blockhash) out=next(v for v in tx['vout'] if address in v['scriptPubKey'].get('addresses',[])) return {'txid':txid,'vout':out['n'],'amount':amount,'scriptPubKey':out['scriptPubKey']['hex']} def close(self): try:self.rpc('stop') except Exception: self.process.terminate() try:self.process.wait(timeout=30) except subprocess.TimeoutExpired:self.process.kill();self.process.wait(timeout=10) def result(self):return {'metadata':getattr(self,'metadata',{'allAssertionsPassed':False}),'cases':self.cases,'rawRPC':self.trace} # New relative-age study; reuses only the published MIT educational helpers above. import csv, platform from types import SimpleNamespace PROTOCOL='litecoin-watch-relative-locks-v1' COMMIT='ec1b6489a900d09cf5991e220dce089c77a232a2' DAEMON_SHA='7430c6d0d4cb59d355361a0b90ffbfc4ac725609483e04c242bf95372cc9b8fe' CLI_SHA='6f835854576a473599f89b9842bf7855dabda1183f5a707ac5d5e15aa48761d8' DISABLE=1<<31 TIME_FLAG=1<<22 def utc():return datetime.now(timezone.utc).isoformat() def sha(v):return hashlib.sha256(v).hexdigest() def write(p,v):p.write_text(json.dumps(v,ensure_ascii=False,indent=2)+'\n',encoding='utf-8') class RelativeStudy(BaseStudy): def __init__(self,args,root,output): self.output=output;self.process=None;self.checks=[];self.candidates={};self.blocks=[];self.funding=[];self.validations=[];self.mock_time=1500000000 super().__init__(args,root) self.metadata.update(protocol=PROTOCOL,source_commit=COMMIT,platform=platform.system(),scope='Fresh isolated transparent regtest, valueless coins, zero peers, no mainnet or custody-product test.') self.check('exact pinned daemon hash',self.metadata['daemonSHA256']==DAEMON_SHA) self.check('exact pinned CLI hash',self.metadata['cliSHA256']==CLI_SHA) self.check('MWEB inactive',self.rpc('getblockchaininfo')['softforks']['mweb']['active'] is False) self.metadata['activation']=self.rpc('getblockchaininfo')['softforks'] def check(self,label,condition,details=None): self.checks.append({'label':label,'passed':bool(condition),'details':details}) if not condition:raise AssertionError(label) def snapshot(self): c=self.rpc('getblockchaininfo');return {'tip_height':c['blocks'],'candidate_height':c['blocks']+1,'median_time_past':c['mediantime'],'mock_time':self.mock_time} def mine(self,n=1): hashes=super().mine(n) if hasattr(self,'metadata') and n<=100: for h in hashes: b=self.rpc('getblockheader',h);self.blocks.append({'hash':h,'height':b['height'],'time':b['time'],'mediantime':b['mediantime']}) return hashes def address(self,label):return self.rpc('getnewaddress',label,'legacy',wallet='funding') def fund_group(self,label,addresses,confirmed=True): raw=self.rpc('createrawtransaction',[],{a:'1.00000000' for a in addresses.values()}) funded=self.rpc('fundrawtransaction',raw,{'replaceable':False},wallet='funding') signed=self.rpc('signrawtransactionwithwallet',funded['hex'],wallet='funding');self.check(label+' funding signed',signed['complete']) txid=self.rpc('sendrawtransaction',signed['hex']);block=self.mine()[0] if confirmed else None height=self.rpc('getblockheader',block)['height'] if block else None;decoded=self.rpc('decoderawtransaction',signed['hex']);points={} for name,address in addresses.items(): v=next(v for v in decoded['vout'] if address in v['scriptPubKey'].get('addresses',[])) points[name]={'txid':txid,'vout':v['n'],'value_litoshis':100000000,'script_pub_key':v['scriptPubKey']['hex'],'parent_height':height} self.funding.append({'id':label,'txid':txid,'raw_hex':signed['hex'],'height':height,'block_hash':block,'outpoints':points}) return points def candidate(self,name,point,sequence,version=2,redeem=None,wif=None): raw=self.rpc('createrawtransaction',[{'txid':point['txid'],'vout':point['vout'],'sequence':sequence}],{self.address(name):'0.99990000'},0) raw=(version.to_bytes(4,'little')+bytes.fromhex(raw)[4:]).hex() if redeem:signed_hex=sign_script(raw,redeem,wif) else: signed=self.rpc('signrawtransactionwithwallet',raw,wallet='funding');self.check(name+' signed',signed['complete']);signed_hex=signed['hex'] d=self.rpc('decoderawtransaction',signed_hex);self.check(name+' exact fields',d['version']==version and d['vin'][0]['sequence']==sequence and d['locktime']==0) self.candidates[name]={'id':name,'raw_hex':signed_hex,'sha256':sha(bytes.fromhex(signed_hex)),'txid':d['txid'],'version':version,'sequence':sequence,'locktime':0,'point':dict(point),'redeem_script':redeem,'created_at_tip':self.snapshot()['tip_height'],'created_at_utc':utc(),'fee_litoshis':10000,'decoded':d} return name def admit(self,ident,candidate,expected,parent_height=None,note=''): c=self.candidates[candidate];s=self.snapshot();h=c['point']['parent_height'] if parent_height is None else parent_height verdict=self.rpc('testmempoolaccept',[c['raw_hex']])[0] self.check(ident+' expected admission',verdict['allowed'] is expected,verdict) self.check(ident+' same candidate',verdict['txid']==c['txid']) self.check(ident+' no peers',self.rpc('getconnectioncount')==0) row={'id':ident,'candidate':candidate,**s,'parent_height':h,'parent_confirmations':s['tip_height']-h+1 if h is not None else 0,'expected_allowed':expected,'admission':verdict,'note':note} self.cases.append(row);self.progress();return row def validate_block(self,ident,names,expected): before=self.snapshot();result=self.rpc('generateblock',self.miner,[self.candidates[n]['raw_hex'] for n in names],allow_error=True);accepted='rpcError' not in result;after=self.snapshot() self.check(ident+' block verdict',accepted is expected,result) row={'id':ident,'candidates':names,'before':before,'after':after,'accepted':accepted,'result':result} if accepted: b=self.rpc('getblock',result['hash'],2);row.update(height=b['height'],included_txids=[t['txid'] for t in b['tx']]) self.check(ident+' actual inclusion',all(self.candidates[n]['txid'] in row['included_txids'] for n in names));self.check(ident+' one block',after['tip_height']==before['tip_height']+1) h=self.rpc('getblockheader',result['hash']);self.blocks.append({'hash':result['hash'],'height':h['height'],'time':h['time'],'mediantime':h['mediantime']}) else:self.check(ident+' rejection leaves chain unchanged',after['tip_height']==before['tip_height']) self.validations.append(row);self.progress();return row def advance_mtp(self,target): self.mock_time=target;self.rpc('setmocktime',target) for _ in range(12): if self.snapshot()['median_time_past']>=target:break self.mine() self.check('exact MTP '+str(target),self.snapshot()['median_time_past']==target) def run_relative(self): key=self.address('csv-key');pub=bytes.fromhex(self.rpc('getaddressinfo',key,wallet='funding')['pubkey']);wif=self.rpc('dumpprivkey',key,wallet='funding') redeem=(b'\x55\xb2\x75'+push(pub)+b'\xac').hex();csv_addr=self.rpc('decodescript',redeem)['p2sh'] points=self.fund_group('shared-confirmation',{'ordinary':self.address('ordinary'),'delayed_first_sign':self.address('untouched-until-aged'),'mandatory_csv5':csv_addr}) h=points['ordinary']['parent_height'];self.metadata['shared_parent_height']=h original=self.candidate('ordinary-original',points['ordinary'],5) v1=self.candidate('ordinary-version1',points['ordinary'],5,version=1) disabled=self.candidate('ordinary-disable-bit',points['ordinary'],DISABLE|5) csv5=self.candidate('csv5-correct',points['mandatory_csv5'],5,redeem=redeem,wif=wif) csvv1=self.candidate('csv5-version1',points['mandatory_csv5'],5,version=1,redeem=redeem,wif=wif) csvdis=self.candidate('csv5-disable-bit',points['mandatory_csv5'],DISABLE|5,redeem=redeem,wif=wif) self.admit('height-original-1-confirmation',original,False) self.admit('ordinary-version1-bypasses-age',v1,True) self.admit('ordinary-disable-bit-bypasses-age',disabled,True) self.admit('csv5-correct-still-too-young',csv5,False) self.admit('csv5-version1-cannot-bypass',csvv1,False) self.admit('csv5-disable-bit-cannot-bypass',csvdis,False) self.mine(3);self.check('four confirmations tip H+3',self.snapshot()['tip_height']==h+3) self.admit('height-original-4-confirmations',original,False);self.validate_block('height-premature-block',[original],False) self.mine();self.check('five confirmations tip H+4',self.snapshot()['tip_height']==h+4) self.admit('height-unchanged-original-5-confirmations',original,True) rebuilt=self.candidate('ordinary-rebuilt-later',points['ordinary'],5) self.check('new candidate has different bytes',self.candidates[rebuilt]['raw_hex']!=self.candidates[original]['raw_hex']) self.admit('height-rebuilt-later-is-immediately-eligible',rebuilt,True) delayed=self.candidate('ordinary-delayed-first-sign',points['delayed_first_sign'],5) self.admit('height-delayed-first-sign-is-immediately-eligible',delayed,True,note='Separate old output never used in an earlier candidate; first signing at H+4 does not start a new interval.') csv4=self.candidate('csv5-lowered-sequence4',points['mandatory_csv5'],4,redeem=redeem,wif=wif) self.admit('csv5-cannot-lower-required-sequence',csv4,False) self.admit('csv5-correct-5-confirmations',csv5,True) b=self.validate_block('height-eligible-block',[original,delayed,csv5],True);self.check('first eligible block H+5',b['height']==h+5) for p in points.values():self.check('shared outpoint spent '+str(p['vout']),self.rpc('gettxout',p['txid'],p['vout']) is None) point=self.fund_group('new-unconfirmed-output',{'fresh_output':self.address('fresh-age')},False)['fresh_output'] fresh5=self.candidate('fresh-output-sequence5',point,5);fresh0=self.candidate('fresh-output-sequence0',point,0) self.admit('unconfirmed-parent-sequence5-not-eligible',fresh5,False);self.admit('unconfirmed-parent-sequence0-can-share-block',fresh0,True) bh=self.mine()[0];ph=self.rpc('getblockheader',bh)['height'];self.check('fresh parent mined',point['txid'] in self.rpc('getblock',bh,1)['tx']) self.admit('newly-confirmed-output-sequence5-still-young',fresh5,False,parent_height=ph) self.mine(4);self.admit('new-output-sequence5-after-five-confirmations',fresh5,True,parent_height=ph) b=self.validate_block('new-output-eligible-block',[fresh5],True);self.check('fresh output first block parentH+5',b['height']==ph+5) point=self.fund_group('time-unit-output',{'time_output':self.address('time-age')})['time_output'];ph=point['parent_height'] previous=self.rpc('getblockheader',self.rpc('getblockhash',ph-1));parent=self.rpc('getblockheader',self.rpc('getblockhash',ph));origin=previous['mediantime'];required=origin+512 self.metadata['time_origin']={'parent_height':ph,'origin_block_height':ph-1,'origin_mtp':origin,'parent_header_time':parent['time'],'sequence':TIME_FLAG|1,'units':1,'seconds_per_unit':512,'first_eligible_previous_block_mtp':required} timed=self.candidate('time-one-512-second-unit',point,TIME_FLAG|1);self.admit('time-unit-initially-too-young',timed,False) old_mtp=self.snapshot()['median_time_past'];self.mock_time=origin+3600;self.rpc('setmocktime',self.mock_time) self.check('mock clock alone leaves MTP unchanged',self.snapshot()['median_time_past']==old_mtp);self.admit('time-unit-mock-clock-alone-does-not-unlock',timed,False) self.advance_mtp(required-1);self.admit('time-unit-MTP511-rejected',timed,False);self.validate_block('time-premature-block',[timed],False) self.advance_mtp(required);self.admit('time-unit-MTP512-allowed',timed,True);self.validate_block('time-eligible-block',[timed],True) self.check('final zero peers',self.rpc('getpeerinfo')==[] and self.rpc('getconnectioncount')==0);self.metadata['final_height']=self.snapshot()['tip_height'];self.metadata['allAssertionsPassed']=True def progress(self):write(self.output/'progress.json',{'state':'running','cases_completed':len(self.cases),'checks':len(self.checks),'latest_case':self.cases[-1]['id'] if self.cases else None,'updated_at_utc':utc()}) def save(self,error,cleanup): self.metadata['finished_at_utc']=utc();result={'metadata':self.metadata,'funding':self.funding,'candidates':self.candidates,'cases':self.cases,'block_validations':self.validations,'blocks':self.blocks,'checks':self.checks,'cleanup':cleanup,'error':error} write(self.output/'results.json',result);write(self.output/'rpc-trace.json',self.trace) fields=['case','candidate','version','sequence','parent_height','parent_confirmations','tip_height','candidate_height','median_time_past','mock_time','allowed','reject_reason'] with (self.output/'cases.csv').open('w',encoding='utf-8',newline='') as f: w=csv.DictWriter(f,fieldnames=fields);w.writeheader() for c in self.cases: x=self.candidates[c['candidate']];w.writerow({'case':c['id'],'candidate':c['candidate'],'version':x['version'],'sequence':x['sequence'],'parent_height':c['parent_height'],'parent_confirmations':c['parent_confirmations'],'tip_height':c['tip_height'],'candidate_height':c['candidate_height'],'median_time_past':c['median_time_past'],'mock_time':c['mock_time'],'allowed':str(c['admission']['allowed']).lower(),'reject_reason':c['admission'].get('reject-reason','')}) with (self.output/'blocks.csv').open('w',encoding='utf-8',newline='') as f: w=csv.DictWriter(f,fieldnames=['hash','height','time','mediantime']);w.writeheader();w.writerows(self.blocks) write(self.output/'progress.json',{'state':'passed' if error is None else 'failed','cases_completed':len(self.cases),'checks':len(self.checks),'cleanup':cleanup,'error':error,'updated_at_utc':utc()}) def main_relative(): if not __debug__:raise RuntimeError('Do not run with -O') p=argparse.ArgumentParser(description='Fresh no-peer regtest only; no existing datadir accepted.') p.add_argument('daemon');p.add_argument('cli');p.add_argument('new_output_directory');a=p.parse_args();a.study='timelock' for file,expected in [(a.daemon,DAEMON_SHA),(a.cli,CLI_SHA)]: if sha(Path(file).read_bytes())!=expected:raise RuntimeError('Pinned Windows binary mismatch') output=Path(a.new_output_directory).resolve();output.mkdir(parents=True,exist_ok=False) scratch=Path(tempfile.gettempdir()).resolve();lab=Path(tempfile.mkdtemp(prefix='lw-relative-locks-regtest-',dir=scratch)).resolve();node=lab/'node';node.mkdir();s=RelativeStudy.__new__(RelativeStudy);s.process=None;error=None;cleanup={} # Identical pinned binaries copied only into this fresh disposable lab for local process startup. for attr in ['daemon','cli']: original=Path(getattr(a,attr)).resolve();local=lab/original.name;shutil.copyfile(original,local);setattr(a,attr,str(local)) try:s.__init__(a,node,output);s.run_relative() except Exception as e: error=type(e).__name__+': '+str(e) if hasattr(s,'metadata'):s.metadata['allAssertionsPassed']=False finally: try: if s.process is not None:s.close() cleanup['process_stopped']=s.process is None or s.process.poll() is not None except Exception as e:error=error or ('stop: '+str(e));cleanup['process_stopped']=False if lab.parent!=scratch or not lab.name.startswith('lw-relative-locks-regtest-'):raise RuntimeError('Refusing cleanup outside disposable lab') shutil.rmtree(lab);cleanup['temporary_datadir_removed']=not lab.exists() if hasattr(s,'metadata'):s.save(error,cleanup) print(json.dumps({'state':'passed' if error is None else 'failed','cases':len(getattr(s,'cases',[])),'checks':len(getattr(s,'checks',[])),'cleanup':cleanup,'error':error}));return 0 if error is None else 1 if __name__=='__main__':raise SystemExit(main_relative())