fork_htlc_locktime_test.py raw

   1  #!/usr/bin/env python3
   2  # Copyright (c) 2026 The Limenka developers
   3  # Distributed under the MIT software license, see the accompanying
   4  # file COPYING or http://www.opensource.org/licenses/mit-license.php.
   5  """Fork-chain height-based locktime binds to the parent chain's cadence.
   6  
   7  HTLCs (and any contract using CLTV with a height-based nLockTime) must
   8  resolve at the same real time on the fork chain and on the parent
   9  (spamchain) chain, even though the two chains diverge in height after the
  10  fork point.  The fork chain therefore interprets a height-based locktime L
  11  against an effective height that advances one per ~600s of the fork
  12  chain's own median time past since the fork point - i.e. when a spamchain
  13  block at height L appears - rather than the fork chain's literal height.
  14  
  15  The fork point is height 50.  The fork chain mines at 300s/block (twice
  16  the parent cadence), so after 110 blocks the literal height is 160 but
  17  only 110 * 300s = 55 * 600s of real time has elapsed: the effective height
  18  is 105.  A transaction with nLockTime = 110 must therefore be NON-FINAL at
  19  literal height 160 (a literal-height rule would wrongly accept it), and
  20  become final only once the effective height reaches 110.
  21  """
  22  from decimal import Decimal
  23  
  24  from test_framework.blocktools import COINBASE_MATURITY
  25  from test_framework.test_framework import LimenkaTestFramework
  26  
  27  BASE_TIME = 1700000000
  28  FORK_POINT = 50
  29  
  30  
  31  class ForkHtlcLocktimeTest(LimenkaTestFramework):
  32      def set_test_params(self):
  33          self.setup_clean_chain = True
  34          self.num_nodes = 1
  35          self.chain = 'limenka'
  36          self.extra_args = [[
  37              '-forkactivationtime=1',
  38              '-forkactivationheight=%d' % FORK_POINT,
  39              '-forkdelaysteps=1024',
  40              '-forkmineondemand',
  41              '-forkstandalone',
  42              '-fallbackfee=0.0002',
  43              '-addresstype=bech32',
  44          ]]
  45          self.rpc_timeout = 240
  46  
  47      def skip_test_if_missing_module(self):
  48          self.skip_if_no_wallet()
  49  
  50      def add_options(self, parser):
  51          self.add_wallet_options(parser, descriptors=True, legacy=False)
  52  
  53      def init_wallet(self, *, node):
  54          wallet_name = self.default_wallet_name if self.wallet_names is None else self.wallet_names[node] if node < len(self.wallet_names) else False
  55          if wallet_name is not False:
  56              n = self.nodes[node]
  57              if wallet_name is not None:
  58                  n.createwallet(wallet_name=wallet_name, descriptors=True, load_on_startup=True)
  59                  import hashlib
  60                  from test_framework.address import byte_to_base58
  61                  from test_framework.descriptors import descsum_create
  62                  from test_framework.key import ECKey
  63                  key = ECKey()
  64                  key.set(hashlib.sha256(b'fork-htlc-funding').digest(), compressed=True)
  65                  priv = key.get_bytes()
  66                  wif = byte_to_base58(priv + b'\x01', 128)
  67                  desc = descsum_create(f"wpkh({wif})")
  68                  import_res = n.importdescriptors([{
  69                      "desc": desc,
  70                      "timestamp": 1231006506,
  71                      "active": False,
  72                      "internal": False,
  73                  }])
  74                  assert all(r['success'] for r in import_res)
  75  
  76      def mine_cadence(self, node, addr, n, step, start_index=0):
  77          for i in range(n):
  78              node.setmocktime(BASE_TIME + (start_index + i) * step)
  79              self.generatetoaddress(node, 1, addr, sync_fun=self.no_op)
  80  
  81      def run_test(self):
  82          node = self.nodes[0]
  83          mine_addr = node.getnewaddress()
  84  
  85          # Mine to literal height 160 at a 300s cadence.  Effective height at
  86          # the tip: 50 + (155*300 - 45*300) / 600 = 105.
  87          self.mine_cadence(node, mine_addr, 160, 300)
  88          assert node.getblockcount() == 160
  89  
  90          # A raw contract tx with nLockTime = 110, between the effective
  91          # height (105) and the literal height (160).
  92          utxo = node.listunspent()[0]
  93          dest = node.getnewaddress()
  94          raw = node.createrawtransaction(
  95              [{"txid": utxo["txid"], "vout": utxo["vout"]}],
  96              [{dest: utxo["amount"] - Decimal("0.001")}],
  97              110,
  98          )
  99          signed = node.signrawtransactionwithwallet(raw)
 100          assert signed["complete"]
 101  
 102          # A literal-height rule would accept locktime 110 at height 160; the
 103          # cadence-bound rule must reject it (effective height is 105).
 104          rejected = False
 105          try:
 106              node.sendrawtransaction(signed["hex"])
 107          except Exception as e:
 108              assert "non-final" in str(e), str(e)
 109              rejected = True
 110          assert rejected, "locktime 110 accepted at literal height 160 (effective 105)"
 111  
 112          # Mine to literal height 270 at 300s cadence: effective height =
 113          # 50 + (265*300 - 45*300) / 600 = 160, which is >= 110.  The tx is
 114          # now final even though it was created 110 blocks ago.
 115          self.mine_cadence(node, mine_addr, 110, 300, start_index=160)
 116          assert node.getblockcount() == 270
 117          txid = node.sendrawtransaction(signed["hex"])
 118          assert txid in node.getrawmempool()
 119  
 120          self.log.info("height-based locktime resolved at the parent cadence (effective height reached 110)")
 121  
 122  
 123  if __name__ == '__main__':
 124      ForkHtlcLocktimeTest(__file__).main()
 125