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