"""Reproducible, disposable Litecoin Core 0.21.5.8 regtest experiments. Usage: python core-controls.py timelock|password /path/litecoind /path/litecoin-cli result.json [scratch-parent] The runner creates its own empty datadir, forces regtest, disables all peers, checks the chain before creating any wallets, and removes that disposable datadir. Never use an existing datadir or real funds. Public trace redacts generated private keys and disposable wallet passphrases. Ordinary transparent legacy wallets/UTXOs only: no MWEB, hardware wallets, mobile apps, or seed-passphrases. Python's small ECDSA helper below is solely for signing the synthetic CLTV case; it is not production wallet software. It is independently checked by Core's script and block validation before a successful result is recorded. """ 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} def timelock(s): destination=s.rpc('getnewaddress','destination','legacy',wallet='funding') ordinary_address=s.rpc('getnewaddress','ordinary-utxo','legacy',wallet='funding') coin=s.fund(ordinary_address) h=s.tip()['tipHeight']; lock=h+3 future=s.plain_tx(coin,destination,.9999,lock,4294967294) final=s.plain_tx(coin,destination,.9999,lock,4294967295) alternate=s.plain_tx(coin,destination,.9999,0,4294967294) for label,tx,expect in [('ordinary-future-nonfinal',future,False),('ordinary-all-final-bypass',final,True),('ordinary-same-input-alternate-now',alternate,True)]: verdict=s.accept(tx);assert verdict['allowed']==expect s.case(label,**s.tip(),lockTime=lock if tx!=alternate else 0,sequence=4294967295 if tx==final else 4294967294, outpoint={'txid':coin['txid'],'vout':coin['vout']},decodedTransaction=s.rpc('decoderawtransaction',tx),mempool=verdict) s.mine(2) equality=s.accept(future);assert s.tip()['nextBlockHeight']==lock and equality['allowed'] is False block=s.rpc('generateblock',s.miner,[future],allow_error=True);assert 'rpcError' in block s.case('ordinary-next-block-equals-lock',**s.tip(),lockTime=lock,mempool=equality,blockValidation=block) s.mine() ripe=s.accept(future);assert s.tip()['tipHeight']==lock and ripe['allowed'] is True s.case('ordinary-next-block-greater-than-lock',**s.tip(),lockTime=lock,mempool=ripe) # Ordinary UTXO has never been locked by its script: the alternate spends it. broadcast=s.rpc('sendrawtransaction',alternate);s.mine() old=s.rpc('gettxout',coin['txid'],coin['vout']) spent=s.accept(future);assert old is None and spent['allowed'] is False s.case('ordinary-alternate-confirmed',**s.tip(),alternateTxid=broadcast,oldOutpoint=old,futureCandidate=spent) # A second ordinary coin is actually spent and confirmed before its # future candidate's lock height. It does not disturb the boundary coin. early_address=s.rpc('getnewaddress','ordinary-before-lock','legacy',wallet='funding') early_coin=s.fund(early_address) early_lock=s.tip()['tipHeight']+10 early_future=s.plain_tx(early_coin,destination,.9999,early_lock,4294967294) early_alternate=s.plain_tx(early_coin,destination,.9999,0,4294967294) before=s.tip();future_m=s.accept(early_future);alternate_m=s.accept(early_alternate) assert future_m['allowed'] is False and alternate_m['allowed'] is True early_txid=s.rpc('sendrawtransaction',early_alternate);early_block=s.mine()[0] early_confirmed=s.rpc('getblock',early_block,2) assert early_confirmed['height']locktime and ready_m['allowed'] is True s.case('time-chain-median-now-past-lock',**s.tip(),transactionLockTime=locktime,mockNodeTime=mock,mempool=ready_m) s.metadata['limitations']='One node and one Core version, regtest synthetic keys/UTXOs. Block RPC checks finality and CLTV; mempool outcomes are reported separately. No claim of exact real-world delivery time, scheduling service, multisignature recovery product, or mainnet deployment.' def password(s): oldpass='disposable-study-old-pass';newpass='disposable-study-new-pass' s.rpc('createwallet','original') initial=s.rpc('getwalletinfo',wallet='original') encrypt=s.rpc('encryptwallet',oldpass,wallet='original') encrypted=s.rpc('getwalletinfo',wallet='original') s.case('setup-encrypt-before-copy',hdseedidBefore=initial.get('hdseedid'),hdseedidAfter=encrypted.get('hdseedid'), observedSeedUnchanged=initial.get('hdseedid')==encrypted.get('hdseedid'),encryptionRPCText=encrypt, note='RPC status prose is not used to infer key/seed rotation. All following copies are already encrypted.') s.rpc('walletpassphrase',oldpass,120,wallet='original') address=s.rpc('getnewaddress','shared-funded-key','legacy',wallet='original') coin=s.fund(address) info_a=s.rpc('getaddressinfo',address,wallet='original') backup=s.root/'encrypted-snapshot.dat';s.rpc('backupwallet',str(backup),wallet='original') # backupwallet produces a consistent snapshot, never a copy of a live database. bdir=s.root/'regtest'/'wallets'/'copied';bdir.mkdir(parents=True) shutil.copy2(backup,bdir/'wallet.dat');s.rpc('loadwallet','copied') info_b=s.rpc('getaddressinfo',address,wallet='copied') b_initial=s.rpc('getwalletinfo',wallet='copied') assert info_a['ismine'] and info_b['ismine'] and info_a['pubkey']==info_b['pubkey'] s.case('encrypted-snapshot-shares-funded-key',outpoint={'txid':coin['txid'],'vout':coin['vout']},amountLTC=1, originalOwns=info_a['ismine'],copiedOwns=info_b['ismine'],samePublicKey=info_a['pubkey']==info_b['pubkey'], originalHDSeedID=encrypted.get('hdseedid'),copiedHDSeedID=b_initial.get('hdseedid'),bothLoaded=True) s.rpc('walletlock',wallet='original');s.rpc('walletlock',wallet='copied') s.rpc('walletpassphrasechange',oldpass,newpass,wallet='original') changed=s.rpc('getwalletinfo',wallet='original') info_changed=s.rpc('getaddressinfo',address,wallet='original') s.case('passphrasechange-preserves-funded-key',samePublicKey=info_changed['pubkey']==info_a['pubkey'], hdseedidBefore=encrypted.get('hdseedid'),hdseedidAfter=changed.get('hdseedid'), observedSeedUnchanged=encrypted.get('hdseedid')==changed.get('hdseedid')) assert info_changed['pubkey']==info_a['pubkey'] for name,passvalue,passlabel,success in [('original',oldpass,'old',False),('original',newpass,'new',True),('copied',newpass,'new',False),('copied',oldpass,'old',True)]: s.rpc('walletlock',wallet=name) unlock=s.rpc('walletpassphrase',passvalue,120,wallet=name,allow_error=True) actual=not(isinstance(unlock,dict) and 'rpcError'in unlock);assert actual==success if not actual:assert 'error code: -14' in unlock['rpcError']['stderr'] s.case(name+'-'+passlabel+'-passphrase',wallet=name,passphrase=passlabel,unlockSucceeded=actual,result=unlock) # Compare a fresh receive address from the SAME wallet with the pre-change copy. s.rpc('walletpassphrase',newpass,120,wallet='original') new_same=s.rpc('getnewaddress','same-wallet-after-change','legacy',wallet='original') same_info=s.rpc('getaddressinfo',new_same,wallet='copied') same_case={'address':new_same,'copiedIsMine':same_info['ismine'], 'scope':'One address from the copied prefilled HD keypool, not exhaustive testing of all future derivation paths.'} if same_info['ismine']: msg='Disposable Litecoin.watch shared-keypool control' signature=s.rpc('signmessage',new_same,msg,wallet='copied') same_case['copiedSignatureVerified']=s.rpc('verifymessage',new_same,signature,msg) assert same_case['copiedSignatureVerified'] s.case('new-address-same-wallet',**same_case) # A separately created wallet has independent keys/seed. B sends the shared # old output there. This proves B can still authorize the old key's spend. s.rpc('createwallet','independent') dest=s.rpc('getnewaddress','independent-receive','legacy',wallet='independent') c_info=s.rpc('getaddressinfo',dest,wallet='independent') b_dest=s.rpc('getaddressinfo',dest,wallet='copied') assert c_info['ismine'] and not b_dest['ismine'] raw=s.rpc('createrawtransaction',[{'txid':coin['txid'],'vout':coin['vout'],'sequence':4294967294}],{dest:.9999}) b_signed=s.rpc('signrawtransactionwithwallet',raw,wallet='copied');assert b_signed['complete'] b_accepted=s.accept(b_signed['hex']);assert b_accepted['allowed'] s.case('old-copy-still-signs-funded-coin',outpoint={'txid':coin['txid'],'vout':coin['vout']}, signingComplete=b_signed['complete'],mempool=b_accepted,copiedOwnsDestination=b_dest['ismine'],newWalletOwnsDestination=c_info['ismine']) spenttx=s.rpc('sendrawtransaction',b_signed['hex']);blockhash=s.mine()[0] oldout=s.rpc('gettxout',coin['txid'],coin['vout']);assert oldout is None tx=s.rpc('getrawtransaction',spenttx,True,blockhash) out=next(v for v in tx['vout'] if dest in v['scriptPubKey'].get('addresses',[])) newcoin={'txid':spenttx,'vout':out['n'],'amount':out['value'],'scriptPubKey':out['scriptPubKey']['hex']} confirmed=s.rpc('gettxout',newcoin['txid'],newcoin['vout']);assert confirmed['confirmations']>=1 old_again=s.accept(b_signed['hex']);assert old_again['allowed'] is False s.case('confirmed-transfer-spends-old-outpoint',oldOutpoint=oldout,newOutpoint=newcoin,newConfirmations=confirmed['confirmations'], oldTransactionRetest=old_again,confirmingBlock=blockhash, meaning='The old coin is spent; the old private key was not cryptographically revoked.') # The old key still signs a DIFFERENT transaction for the spent old # outpoint. Rejection now comes from missing/spent input, not key revocation. old_sink=s.rpc('getnewaddress','spent-old-outpoint-test','legacy',wallet='funding') old_variant=s.rpc('createrawtransaction',[{'txid':coin['txid'],'vout':coin['vout'],'sequence':4294967294}],{old_sink:.9998}) old_prev=[{'txid':coin['txid'],'vout':coin['vout'],'scriptPubKey':coin['scriptPubKey'],'amount':coin['amount']}] old_variant_signed=s.rpc('signrawtransactionwithwallet',old_variant,old_prev,wallet='copied') assert old_variant_signed['complete'] is True old_variant_m=s.accept(old_variant_signed['hex']) assert old_variant_m['allowed'] is False and old_variant_m['reject-reason']=='missing-inputs' s.case('old-key-still-signs-spent-outpoint',outpoint={'txid':coin['txid'],'vout':coin['vout']}, signingComplete=old_variant_signed['complete'],mempool=old_variant_m, meaning='The old key still signs, but this different transaction cannot spend an already spent coin.') sink=s.rpc('getnewaddress','final-test-destination','legacy',wallet='funding') unsigned_new=s.rpc('createrawtransaction',[{'txid':newcoin['txid'],'vout':newcoin['vout'],'sequence':4294967294}],{sink:.9998}) prev=[{'txid':newcoin['txid'],'vout':newcoin['vout'],'scriptPubKey':newcoin['scriptPubKey'],'amount':newcoin['amount']}] old_sign=s.rpc('signrawtransactionwithwallet',unsigned_new,prev,wallet='copied') c_sign=s.rpc('signrawtransactionwithwallet',unsigned_new,prev,wallet='independent') assert old_sign['complete'] is False and c_sign['complete'] is True b_m=s.accept(old_sign['hex']);c_m=s.accept(c_sign['hex']) assert b_m['allowed'] is False and c_m['allowed'] is True s.case('new-independent-coin-needs-independent-key',newOutpoint={'txid':newcoin['txid'],'vout':newcoin['vout']}, copiedWalletSigning=old_sign,independentWalletSigning=c_sign,copiedWalletMempool=b_m,independentWalletMempool=c_m, copiedOwnsDestination=b_dest['ismine'],independentOwnsDestination=c_info['ismine']) s.metadata['limitations']='One Core 0.21.5.8 legacy encrypted-wallet snapshot, one shared funded key, one prefilled-keypool address, and one independent wallet. Not hardware-wallet, mobile-app password, PIN, BIP39 passphrase, or every possible backup-format testing.' def main(): p=argparse.ArgumentParser();p.add_argument('study',choices=['timelock','password']);p.add_argument('daemon');p.add_argument('cli');p.add_argument('output');p.add_argument('scratch_parent',nargs='?',default=None);args=p.parse_args() for binary in [args.daemon,args.cli]: assert Path(binary).is_file(), 'Missing pinned executable' with tempfile.TemporaryDirectory(prefix='lw-'+args.study+'-regtest-',dir=args.scratch_parent) as root: s=None try: s=Study.__new__(Study) s.__init__(args,root) (timelock if args.study=='timelock' else password)(s) assert s.rpc('getconnectioncount')==0 s.metadata['finishedAtUTC']=datetime.now(timezone.utc).isoformat() s.metadata['caseCount']=len(s.cases) s.metadata['allAssertionsPassed']=True finally: if s: s.close() Path(args.output).write_text(json.dumps(s.result(),indent=2,ensure_ascii=False)+'\n',encoding='utf8') print(json.dumps({'study':args.study,'cases':len(s.cases),'assertionsPassed':s.metadata.get('allAssertionsPassed',False)})) if __name__=='__main__':main()