"""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<secret<N
    return secret

def ecdsa_der(secret,digest):
    # RFC6979 deterministic ECDSA nonce, SHA256, plus low-S normalization.
    x=secret.to_bytes(32,'big'); z=int.from_bytes(digest,'big'); h1=(z%N).to_bytes(32,'big')
    v=b'\x01'*32; k=b'\0'*32
    k=hmac.new(k,v+b'\0'+x+h1,hashlib.sha256).digest(); v=hmac.new(k,v,hashlib.sha256).digest()
    k=hmac.new(k,v+b'\x01'+x+h1,hashlib.sha256).digest(); v=hmac.new(k,v,hashlib.sha256).digest()
    while True:
        v=hmac.new(k,v,hashlib.sha256).digest(); nonce=int.from_bytes(v,'big')
        if 0<nonce<N:
            r=multiply(nonce)[0]%N
            s=(pow(nonce,-1,N)*(z+r*secret))%N
            if r and s: break
        k=hmac.new(k,v+b'\0',hashlib.sha256).digest(); v=hmac.new(k,v,hashlib.sha256).digest()
    s=min(s,N-s)
    def integer(q):
        b=q.to_bytes((q.bit_length()+7)//8,'big')
        if b[0]&128: b=b'\0'+b
        return b'\x02'+bytes([len(b)])+b
    payload=integer(r)+integer(s)
    return b'\x30'+bytes([len(payload)])+payload

def sign_cltv_single_legacy(raw_hex,redeem_hex,wif):
    raw=bytes.fromhex(raw_hex); redeem=bytes.fromhex(redeem_hex)
    assert raw[4]==1 and raw[41]==0, 'Requires exactly one legacy input with empty scriptSig'
    preimage=raw[:41]+varint(len(redeem))+redeem+raw[42:]+(1).to_bytes(4,'little')
    signature=ecdsa_der(decode_disposable_wif(wif),double_sha(preimage))+b'\x01'
    script_sig=push(signature)+push(redeem)
    return (raw[:41]+varint(len(script_sig))+script_sig+raw[42:]).hex()

class RPCError(Exception):
    def __init__(self,method,record):
        super().__init__(method+': '+record['stderr']); self.record=record

class Study:
    def __init__(self,args,scratch):
        self.args=args; self.root=Path(scratch); self.trace=[]; self.cases=[]
        with socket.socket() as sock:
            sock.bind(('127.0.0.1',0)); self.port=sock.getsockname()[1]
        self.opts=['-regtest','-datadir='+str(self.root),'-rpcport='+str(self.port)]
        flags=['-server=1','-listen=0','-listenonion=0','-connect=0','-dnsseed=0','-discover=0','-upnp=0','-maxconnections=0','-rpcbind=127.0.0.1','-rpcallowip=127.0.0.1','-dbcache=32','-maxmempool=10','-par=1','-rpcthreads=1','-rpcworkqueue=8','-persistmempool=0','-fallbackfee=0.00001','-keypool=10','-printtoconsole=0']
        self.process=subprocess.Popen([args.daemon,*self.opts,*flags],stdout=subprocess.DEVNULL,stderr=subprocess.PIPE)
        for _ in range(120):
            try:
                info=self.rpc('getnetworkinfo',capture=False); break
            except RPCError:
                if self.process.poll() is not None:
                    raise RuntimeError('Fresh node exited: '+self.process.stderr.read().decode(errors='replace')[:2000])
                time.sleep(.25)
        else: raise RuntimeError('Fresh isolated regtest node failed to start')
        info=self.rpc('getnetworkinfo')
        chain=self.rpc('getblockchaininfo')
        assert chain['chain']=='regtest' and chain['blocks']==0
        assert self.rpc('getconnectioncount')==0
        assert info['subversion']=='/LitecoinCore:0.21.5.8/'
        self.rpc('setmocktime',1500000000)
        self.metadata={'coreVersion':info['subversion'],'coreNumericVersion':info['version'],'chain':'regtest','externalPeers':0,
          'startedAtUTC':datetime.now(timezone.utc).isoformat(),'daemonSHA256':hashlib.sha256(Path(args.daemon).read_bytes()).hexdigest(),
          'cliSHA256':hashlib.sha256(Path(args.cli).read_bytes()).hexdigest(),'runnerSHA256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
          'protocol':'litecoin-watch-core-controls-v1','study':args.study,
          'scope':'Disposable transparent legacy-wallet regtest. No mainnet, MWEB, hardware wallet, BIP39 seed passphrase, PIN, or mobile-app test.',
          'tracePolicy':'Raw stdout/stderr retained except generated private keys and disposable passphrases are redacted. Filesystem paths replaced with synthetic labels.'}
        self.rpc('createwallet','funding')
        self.miner=self.rpc('getnewaddress','block-rewards','bech32',wallet='funding')
        self.mine(1351 if args.study=='timelock' else 101)
        if args.study=='timelock':
            activated=self.rpc('getblockchaininfo')['softforks']
            assert activated['bip65']['active'] and activated['csv']['active']
            self.metadata['regtestActivation']='1351 initial blocks: BIP65/CLTV and CSV/MTP active. Fixed mock time1500000000 keeps MWEB unactivated.'
    def rpc(self,method,*params,wallet=None,capture=True,allow_error=False):
        def arg(p): return json.dumps(p,separators=(',',':')) if isinstance(p,(dict,list,bool)) else str(p)
        call=[self.args.cli,*self.opts,*(['-rpcwallet='+wallet] if wallet else []),method,*map(arg,params)]
        cp=subprocess.run(call,capture_output=True,text=True,timeout=45)
        safeparams=list(params)
        if method in ['walletpassphrase','walletpassphrasechange','encryptwallet']: safeparams=['<disposable-passphrase>' if isinstance(v,str) else v for v in params]
        if method=='dumpprivkey': safeparams=['<disposable-regtest-address>']
        if method in ['backupwallet','loadwallet','unloadwallet']:
            safeparams=[str(v).replace(str(self.root),'<isolated-datadir>') for v in params]
        if method=='signrawtransactionwithkey': safeparams=[params[0],['<disposable-private-key>'],*params[2:]]
        stdout=cp.stdout.strip(); stderr=cp.stderr.strip()
        if method=='dumpprivkey': stdout='<disposable-private-key-redacted>'
        stdout=stdout.replace(str(self.root),'<isolated-datadir>'); stderr=stderr.replace(str(self.root),'<isolated-datadir>')
        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']<early_lock and any(tx['txid']==early_txid for tx in early_confirmed['tx'])
    assert s.rpc('gettxout',early_coin['txid'],early_coin['vout']) is None
    s.case('ordinary-alternate-confirmed-before-future-lock',**before,lockTime=early_lock,
           originalOutpoint={'txid':early_coin['txid'],'vout':early_coin['vout']},
           futureMempool=future_m,alternateMempool=alternate_m,alternateTxid=early_txid,
           confirmingBlock=early_block,confirmedAtHeight=early_confirmed['height'],
           transactionIncluded=True,confirmedBeforeFutureLock=True)
    # Own-key P2SH CLTV output. This is a real signature, not an anyone-can-spend TRUE script.
    cltv_owner=s.rpc('getnewaddress','cltv-owner','legacy',wallet='funding')
    pubkey=s.rpc('getaddressinfo',cltv_owner,wallet='funding')['pubkey']
    secret=s.rpc('dumpprivkey',cltv_owner,wallet='funding')
    cltv_lock=s.tip()['tipHeight']+4
    redeem=(push(scriptnum(cltv_lock))+b'\xb1\x75'+push(bytes.fromhex(pubkey))+b'\xac').hex()
    cltv_address=s.rpc('decodescript',redeem)['p2sh'];locked=s.fund(cltv_address)
    def cltv_tx(locktime,sequence):
        raw=s.rpc('createrawtransaction',[{'txid':locked['txid'],'vout':locked['vout'],'sequence':sequence}],{destination:.9999},locktime)
        return sign_cltv_single_legacy(raw,redeem,secret)
    bypass=cltv_tx(0,4294967294);premature=cltv_tx(cltv_lock,4294967294)
    bypass_m=s.accept(bypass);assert bypass_m['allowed'] is False
    bypass_b=s.rpc('generateblock',s.miner,[bypass],allow_error=True);assert 'rpcError' in bypass_b
    s.case('cltv-zero-lock-cannot-bypass',**s.tip(),requiredLockHeight=cltv_lock,redeemScript=redeem,
           lockingAddress=cltv_address,mempool=bypass_m,blockValidation=bypass_b)
    premature_m=s.accept(premature);assert premature_m['allowed'] is False
    premature_b=s.rpc('generateblock',s.miner,[premature],allow_error=True);assert 'rpcError' in premature_b
    s.case('cltv-correct-lock-but-premature',**s.tip(),requiredLockHeight=cltv_lock,mempool=premature_m,blockValidation=premature_b)
    s.mine(cltv_lock-s.tip()['tipHeight'])
    final_cltv=cltv_tx(cltv_lock,4294967295)
    final_m=s.accept(final_cltv);assert final_m['allowed'] is False
    final_b=s.rpc('generateblock',s.miner,[final_cltv],allow_error=True);assert 'rpcError' in final_b
    s.case('cltv-final-sequence-cannot-bypass',**s.tip(),requiredLockHeight=cltv_lock,mempool=final_m,blockValidation=final_b)
    valid_m=s.accept(premature);assert valid_m['allowed'] is True
    before=s.tip(); mined=s.rpc('generateblock',s.miner,[premature]);assert 'hash' in mined
    included=s.rpc('getblock',mined['hash'],2);txid=s.rpc('decoderawtransaction',premature)['txid'];assert any(tx['txid']==txid for tx in included['tx'])
    s.case('cltv-key-signed-ripe-block-accepted',**before,requiredLockHeight=cltv_lock,mempool=valid_m,blockValidation=mined,
           confirmedAtHeight=included['height'],transactionIncluded=True,transactionTxid=txid)
    # Isolated mock node clock changes independently from chain median time.
    time_address=s.rpc('getnewaddress','time-utxo','legacy',wallet='funding');time_coin=s.fund(time_address)
    t=s.tip(); locktime=t['medianTimePast']+60
    timed=s.plain_tx(time_coin,destination,.9999,locktime)
    mock=locktime+3600;s.rpc('setmocktime',mock)
    wall_m=s.accept(timed);assert wall_m['allowed'] is False
    s.case('time-wall-clock-ahead-median-behind',**s.tip(),transactionLockTime=locktime,mockNodeTime=mock,mempool=wall_m)
    s.mine(6)
    ready_m=s.accept(timed);assert s.tip()['medianTimePast']>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()
