"""Observe fee-estimator failures and wallet fallbacks in fresh, isolated regtest. Python 3 standard library; verified Litecoin Core 0.21.5.8 daemon and CLI required. No existing wallet/datadir/config/seed is accepted. No mainnet RPC is contacted. Usage: python fee-estimator-test.py.txt /path/litecoind /path/litecoin-cli new-output [--scratch-parent /fresh/lab/parent] """ from pathlib import Path from decimal import Decimal from datetime import datetime,timezone import argparse,base64,csv,hashlib,json,os,platform,shutil,socket,subprocess,tempfile,time,urllib.request,urllib.error VERSION='fee-estimator-20261026-v1' def utc():return datetime.now(timezone.utc).isoformat() def plain(value): if isinstance(value,Decimal):return format(value,'f') if isinstance(value,dict):return {k:plain(v) for k,v in value.items()} if isinstance(value,list):return [plain(v) for v in value] return value def write(path,value):path.write_text(json.dumps(plain(value),indent=2,ensure_ascii=False)+'\n',encoding='utf-8') def run(daemon,cli,output,scratch_parent=None): daemon,cli=Path(daemon).resolve(),Path(cli).resolve();assert daemon.is_file() and cli.is_file() output=Path(output).resolve();output.mkdir(parents=True,exist_ok=False) scratch=Path(scratch_parent).resolve() if scratch_parent else Path(tempfile.gettempdir()).resolve();scratch.mkdir(parents=True,exist_ok=True) lab=Path(tempfile.mkdtemp(prefix='lw-fee-estimator-regtest-',dir=str(scratch))).resolve();assert lab.is_relative_to(scratch) and lab.name.startswith('lw-fee-estimator-regtest-') datadir=lab/'node';datadir.mkdir();logfile=lab/'process.log';trace=[];assertions=[];lifecycle=[];checkpoints=[];candidates=[];blocks=[];started=utc() def check(name,condition,details=None): assertions.append({'name':name,'passed':bool(condition),'details':plain(details)}) if not condition:raise AssertionError(name) reservation=socket.socket();reservation.bind(('127.0.0.1',0));port=reservation.getsockname()[1];reservation.close() process=None;log=None;wallet=None;case=None;sequence=0;options={} common=['-regtest','-server=1','-listen=0','-connect=0','-dnsseed=0','-dns=0','-discover=0','-listenonion=0','-upnp=0','-onlynet=ipv4','-dbcache=32','-par=1','-acceptnonstdtxn=0','-debug=estimatefee'] class RPCError(RuntimeError): def __init__(self,value):self.value=value;super().__init__(value.get('message','RPC error')) def rpc(method,*params,wallet_rpc=False,quiet=False): nonlocal sequence sequence+=1;cookie=(datadir/'regtest'/'.cookie').read_text(encoding='utf-8').strip() payload=json.dumps({'jsonrpc':'1.0','id':sequence,'method':method,'params':plain(list(params))}).encode() path='/wallet/'+wallet if wallet_rpc else '/' request=urllib.request.Request('http://127.0.0.1:'+str(port)+path,data=payload,headers={'Content-Type':'application/json','Authorization':'Basic '+base64.b64encode(cookie.encode()).decode()}) try: with urllib.request.urlopen(request,timeout=25) as response:value=json.loads(response.read(),parse_float=Decimal) except urllib.error.HTTPError as error:value=json.loads(error.read(),parse_float=Decimal) record={'sequence':sequence,'time_utc':utc(),'case':case,'method':method,'wallet':wallet if wallet_rpc else None,'params':plain(list(params))} if value.get('error'): record['error']=plain(value['error']);trace.append(record);raise RPCError(value['error']) record['result']=plain(value.get('result'));trace.append(record);return value.get('result') def stop(): nonlocal process,log if process and process.poll() is None: rpc('stop');process.wait(timeout=30);lifecycle.append({'time_utc':utc(),'case':case,'event':'stopped','exit_code':process.returncode});check(case+' clean process exit',process.returncode==0) if log:log.close();log=None process=None def start(label,fallback,relay='0.00001000'): nonlocal process,log,case,options case=label;options={'fallbackfee_ltc_per_kb':fallback,'mintxfee_ltc_per_kb':'0.00001000','minrelaytxfee_ltc_per_kb':relay} flags=common+['-datadir='+str(datadir),'-rpcport='+str(port),'-rpcbind=127.0.0.1','-rpcallowip=127.0.0.1','-fallbackfee='+fallback,'-mintxfee=0.00001000','-minrelaytxfee='+relay] if wallet:flags.append('-wallet='+wallet) log=logfile.open('ab');process=subprocess.Popen([str(daemon),*flags],stdout=log,stderr=log,creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0)) deadline=time.monotonic()+30 while True: if process.poll() is not None:raise RuntimeError('The isolated regtest process exited before RPC readiness.') try: if (datadir/'regtest'/'.cookie').is_file():rpc('getblockchaininfo');break except (OSError,urllib.error.URLError,RPCError):pass if time.monotonic()>deadline:raise RuntimeError('Isolated RPC did not become ready.') time.sleep(.1) lifecycle.append({'time_utc':utc(),'case':case,'event':'started','network':'regtest','rpc_bind':'127.0.0.1','p2p_listen':False,'automatic_peers':False,'options':options}) def checkpoint(label): chain=rpc('getblockchaininfo');header=rpc('getblockheader',chain['bestblockhash']);mempool=rpc('getmempoolinfo');network=rpc('getnetworkinfo');peers=rpc('getpeerinfo');host=int(time.time());estimates=[] for target in [2,6,12]: for mode in ['ECONOMICAL','CONSERVATIVE']: result=rpc('estimatesmartfee',target,mode);estimates.append({'target':target,'mode':mode,'result':result}) row={'label':label,'case':case,'time_utc':utc(),'host_unix':host,'mock_time':False,'height':chain['blocks'],'headers':chain['headers'],'initialblockdownload':chain['initialblockdownload'],'bestblockhash':chain['bestblockhash'],'tip_unix':header['time'],'tip_age_seconds':host-header['time'],'currentness_inputs_match_pinned_gate':not chain['initialblockdownload'] and header['time']>=host-10800 and chain['blocks']>=chain['headers']-1,'connections':network['connections'],'peer_count':len(peers),'mempool':mempool,'network_relayfee':network['relayfee'],'options':dict(options),'estimates':estimates} checkpoints.append(row);check(label+' regtest chain',chain['chain']=='regtest');check(label+' no peers',network['connections']==0 and len(peers)==0);check(label+' empty local mempool',mempool['size']==0) check(label+' all six estimate calls report insufficient data',all('feerate' not in e['result'] and 'Insufficient data' in ' '.join(e['result'].get('errors',[])) for e in estimates)) return row def candidate(label,expected_failure=False): address=rpc('getnewaddress',label,'bech32',wallet_rpc=True);raw=rpc('createrawtransaction',[],{address:'1.00000000'}) try:funded=rpc('fundrawtransaction',raw,{'replaceable':False},wallet_rpc=True) except RPCError as error: if not expected_failure:raise check(label+' explicit fallback-disabled failure',error.value['code']==-4 and 'Fallbackfee is disabled' in error.value['message'],error.value) candidates.append({'label':label,'case':case,'created':False,'error':error.value,'options':dict(options)});return check(label+' expected construction success',not expected_failure) signed=rpc('signrawtransactionwithwallet',funded['hex'],wallet_rpc=True);check(label+' fully signed test candidate',signed['complete'] is True) decoded=rpc('decoderawtransaction',signed['hex']);acceptance=rpc('testmempoolaccept',[signed['hex']])[0];check(label+' candidate passes local dry-run policy',acceptance['allowed'] is True) check(label+' no broadcast',rpc('getrawmempool')==[]) fee=Decimal(str(funded['fee']));litoshis=fee*Decimal(100000000) check(label+' fee is whole litoshis',litoshis==int(litoshis)) previous_outputs=[] for vin in decoded['vin']: previous=rpc('gettransaction',vin['txid'],wallet_rpc=True);previous_decoded=rpc('decoderawtransaction',previous['hex']);out=previous_decoded['vout'][vin['vout']] previous_outputs.append({'txid':vin['txid'],'vout':vin['vout'],'value_ltc':out['value'],'previous_hex':previous['hex']}) check(label+' input minus outputs equals funded fee',sum(Decimal(str(v['value_ltc'])) for v in previous_outputs)-sum(Decimal(str(v['value'])) for v in decoded['vout'])==fee) row={'label':label,'case':case,'created':True,'previous_outputs':previous_outputs,'options':dict(options),'fee_ltc':fee,'fee_litoshis':int(litoshis),'vsize':decoded['vsize'],'weight':decoded['weight'],'actual_litoshis_per_vbyte':litoshis/Decimal(decoded['vsize']),'txid':decoded['txid'],'signed_hex':signed['hex'],'decoded':decoded,'funded':funded,'acceptance':acceptance,'broadcast':False} candidates.append(row) try: daemon_version=subprocess.run([str(daemon),'--version'],capture_output=True,text=True,timeout=20).stdout.splitlines()[0] cli_version=subprocess.run([str(cli),'--version'],capture_output=True,text=True,timeout=20).stdout.splitlines()[0] check('pinned daemon version','v0.21.5.8' in daemon_version);check('pinned CLI version','v0.21.5.8' in cli_version) start('fallback-disabled','0');wallet='fee-estimator-lab';rpc('createwallet',wallet) rpc('help','estimatesmartfee');rpc('help','fundrawtransaction');checkpoint('fresh-node');mine=rpc('getnewaddress','mining','bech32',wallet_rpc=True) hashes=rpc('generatetoaddress',101,mine);checkpoint('after-101-empty-blocks');candidate('after-101-disabled',True) hashes+=rpc('generatetoaddress',200,mine);row=checkpoint('after-301-empty-blocks');candidate('after-301-disabled',True) check('301 blocks and headers observed',row['height']==301 and row['headers']==301) check('after 301 blocks not in IBD',row['initialblockdownload'] is False) check('after 301 blocks currentness inputs match pinned estimator gate',row['currentness_inputs_match_pinned_gate'] is True) for blockhash in hashes: block=rpc('getblock',blockhash,1);blocks.append({'height':block['height'],'hash':blockhash,'time':block['time'],'transactions':len(block['tx'])}) check('all generated blocks contain only one coinbase transaction',len(blocks)==301 and all(b['transactions']==1 for b in blocks)) stop();start('fallback-two-litoshi','0.00002000');checkpoint('explicit-fallback-node');candidate('fallback-two-litoshi') stop();start('fallback-below-required','0.00000010');checkpoint('below-required-node');candidate('fallback-below-required') stop();start('relay-floor-above-fallback','0.00002000','0.00010000');checkpoint('higher-relay-floor-node');candidate('relay-floor-above-fallback') check('fallback and minimum fee construction differ',len({c.get('fee_litoshis') for c in candidates if c['created']})==3) stop();result={'version':VERSION,'started_at_utc':started,'completed_at_utc':utc(),'platform':{'system':platform.system(),'machine':platform.machine()},'daemon_version':daemon_version,'cli_version':cli_version,'binary_sha256':{'litecoind':hashlib.sha256(daemon.read_bytes()).hexdigest(),'litecoin-cli':hashlib.sha256(cli.read_bytes()).hexdigest()},'network':'regtest','scope':'One fresh regtest chain, 301 generated coinbase-only blocks, no peer connections, no broadcast payment, no mainnet fee/timing measurement, ordinary transparent P2WPKH wallet candidates. Wallet minimum and relay fees are explicit experimental settings, not recommended prices.','common_options':common,'checkpoints':checkpoints,'candidates':candidates,'block_count':len(blocks),'lifecycle':lifecycle,'assertions':assertions,'assertions_passed':sum(a['passed'] for a in assertions)} write(output/'results.json',result);write(output/'rpc-trace.json',trace) with (output/'blocks.csv').open('w',newline='',encoding='utf-8') as f: writer=csv.DictWriter(f,fieldnames=['height','hash','time','transactions']);writer.writeheader();writer.writerows(blocks) with (output/'checkpoints.csv').open('w',newline='',encoding='utf-8') as f: fields=['label','case','height','headers','initialblockdownload','tip_age_seconds','currentness_inputs_match_pinned_gate','peer_count','mempool_size'];writer=csv.DictWriter(f,fieldnames=fields);writer.writeheader() for c in checkpoints:writer.writerow({**{k:c[k] for k in fields if k!='mempool_size'},'mempool_size':c['mempool']['size']}) print(json.dumps({'state':'passed','assertions':result['assertions_passed'],'blocks':len(blocks),'candidates':len(candidates),'fees_litoshis':[c.get('fee_litoshis') for c in candidates]})) except BaseException as error: write(output/'failed-trace.json',trace);write(output/'failed-assertions.json',{'error_type':type(error).__name__,'error':str(error),'assertions':assertions});raise finally: if process and process.poll() is None: try:stop() except Exception: process.terminate();process.wait(timeout=20) if log:log.close() # Every recursive removal targets only this runner's newly created absolute lab directory. assert lab.resolve().is_relative_to(scratch.resolve()) and lab.name.startswith('lw-fee-estimator-regtest-') shutil.rmtree(lab) (output/'cleanup.json').write_text(json.dumps({'created_only_fresh_lab':True,'own_process_stopped':process is None or process.poll() is not None,'disposable_lab_removed':not lab.exists()})+'\n',encoding='utf-8') if __name__=='__main__': parser=argparse.ArgumentParser();parser.add_argument('daemon');parser.add_argument('cli');parser.add_argument('output');parser.add_argument('--scratch-parent');args=parser.parse_args();run(args.daemon,args.cli,args.output,args.scratch_parent)