diff --git a/tests/plugins/revault_no_spend.py b/tests/plugins/revault_no_spend.py new file mode 100755 index 0000000..66a988a --- /dev/null +++ b/tests/plugins/revault_no_spend.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""A plugin which returns any attempt without candidate spend transaction as needing to be revaulted""" + +import json +import sys + + +def read_request(): + """Read a JSON request from stdin up to the '\n' delimiter.""" + buf = "" + while len(buf) == 0 or buf[-1] != "\n": + buf += sys.stdin.read() + return json.loads(buf) + + +if __name__ == "__main__": + req = read_request() + block_info = req["block_info"] + + vaults_without_spend_outpoints = [] + for vault in block_info["new_attempts"]: + if vault["candidate_tx"] is None: + vaults_without_spend_outpoints.append(vault["deposit_outpoint"]) + + resp = {"revault": vaults_without_spend_outpoints} + sys.stdout.write(json.dumps(resp)) + sys.stdout.flush() diff --git a/tests/test_framework/coordinator.py b/tests/test_framework/coordinator.py index 1ff934d..24d5b50 100644 --- a/tests/test_framework/coordinator.py +++ b/tests/test_framework/coordinator.py @@ -1,6 +1,7 @@ import cryptography import json import os +import random import select import socket import threading @@ -149,6 +150,26 @@ def server_noise_conn(self, fd): f"Unknown client key. Keys: {','.join(k.hex() for k in self.client_pubkeys)}" ) + def client_noise_conn(self, client_noisepriv): + """Create a new connection to the coordinator, performing the Noise handshake.""" + conn = NoiseConnection.from_name(b"Noise_KK_25519_ChaChaPoly_SHA256") + + conn.set_as_initiator() + conn.set_keypair_from_private_bytes(Keypair.STATIC, client_noisepriv) + conn.set_keypair_from_private_bytes(Keypair.REMOTE_STATIC, self.coordinator_privkey) + conn.start_handshake() + + sock = socket.socket() + sock.settimeout(TIMEOUT // 10) + sock.connect(("localhost", self.port)) + msg = conn.write_message(b"practical_revault_0") + sock.sendall(msg) + resp = sock.recv(32 + 16) # Key size + Mac size + assert len(resp) > 0 + conn.read_message(resp) + + return sock, conn + def read_msg(self, fd, noise_conn): """read a noise-encrypted message from this stream. @@ -190,3 +211,39 @@ def read_data(self, fd, max_len): if d == b"": return data data += d + + def set_spend_tx( + self, + manager_privkey, + deposit_outpoints, + spend_tx, + ): + """ + Send a `set_spend_tx` message to the coordinator + """ + (sock, conn) = self.client_noise_conn(manager_privkey) + msg_id = random.randint(0, 2 ** 32) + msg = { + "id": msg_id, + "method": "set_spend_tx", + "params": { + "deposit_outpoints": deposit_outpoints, + "spend_tx": spend_tx, + } + } + + msg_serialized = json.dumps(msg) + self.send_msg(sock, conn, msg_serialized) + + # Same for decryption, careful to read length first and then the body + resp_header = sock.recv(2 + 16) + assert len(resp_header) > 0 + resp_header = conn.decrypt(resp_header) + resp_len = int.from_bytes(resp_header, "big") + resp = sock.recv(resp_len) + assert len(resp) == resp_len + resp = conn.decrypt(resp) + + resp = json.loads(resp) + assert resp["id"] == msg_id, "Reusing the same Noise connection across threads?" + assert resp["result"]["ack"] diff --git a/tests/test_plugins.py b/tests/test_plugins.py index f243a7b..635ddcc 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -119,6 +119,72 @@ def test_max_value_in_flight(miradord, bitcoind): miradord.wait_for_log(f"Forgetting about consumed vault at '{deposit_outpoint}'") +def test_revault_attempts_without_spend_tx(miradord, bitcoind, coordinator, noise_keys): + """ + Sanity check that we are only going to revault attempts that have no candidate + spend transaction. + """ + plugin_path = os.path.join( + os.path.dirname(__file__), "plugins", "revault_no_spend.py" + ) + miradord.add_plugins([{"path": plugin_path}]) + + # Should get us exactly to the max value + vaults_txs = [] + vaults_outpoints = [] + deposit_value = 4 + for _ in range(2): + deposit_txid, deposit_outpoint = bitcoind.create_utxo( + DEPOSIT_ADDRESS, deposit_value, + ) + bitcoind.generate_block(1, deposit_txid) + txs = miradord.watch_vault(deposit_outpoint, deposit_value * COIN, DERIV_INDEX) + vaults_outpoints.append(deposit_outpoint) + vaults_txs.append(txs) + + # We share the spend to the coordinator only for vault #0 + coordinator.set_spend_tx(noise_keys["manager"].privkey, [vaults_outpoints[0]], vaults_txs[0]["spend"]["tx"]) + + bitcoind.rpc.sendrawtransaction(vaults_txs[0]["unvault"]["tx"]) + unvault_txid = bitcoind.rpc.decoderawtransaction(vaults_txs[0]["unvault"]["tx"])["txid"] + bitcoind.generate_block(1, unvault_txid) + miradord.wait_for_logs( + [ + f"Got a confirmed Unvault UTXO for vault at '{vaults_outpoints[0]}'", + "Done processing block", + ] + ) + bitcoind.rpc.sendrawtransaction(vaults_txs[1]["unvault"]["tx"]) + unvault_txid = bitcoind.rpc.decoderawtransaction(vaults_txs[1]["unvault"]["tx"])["txid"] + bitcoind.generate_block(1, unvault_txid) + miradord.wait_for_logs( + [ + f"Got a confirmed Unvault UTXO for vault at '{vaults_outpoints[1]}'", + f"Broadcasted Cancel transaction '{vaults_txs[1]['cancel']['tx']['20']}'", + ] + ) + + # The Cancel transactions has been broadcast because the spend was not + # shared to coordinator. + cancel_txid = bitcoind.rpc.decoderawtransaction(vaults_txs[1]["cancel"]["tx"]["20"])["txid"] + bitcoind.generate_block(1, wait_for_mempool=cancel_txid) + miradord.wait_for_log( + f"Cancel transaction was confirmed for vault at '{vaults_outpoints[1]}'" + ) + + # Now mine the spend tx for vault #0 + bitcoind.generate_block(CSV) + bitcoind.rpc.sendrawtransaction(vaults_txs[0]["spend"]["tx"]) + spend_txid = bitcoind.rpc.decoderawtransaction(vaults_txs[0]["spend"]["tx"])["txid"] + bitcoind.generate_block(1, wait_for_mempool=spend_txid) + miradord.wait_for_log( + f"Noticed .* that Spend transaction was confirmed for vault at '{vaults_outpoints[0]}'" + ) + # Generate two days worth of blocks, the WT should forget about this vault + bitcoind.generate_block(288) + miradord.wait_for_log(f"Forgetting about consumed vault at '{deposit_outpoint}'") + + def test_multiple_plugins(miradord, bitcoind): """Test we use the union of all plugins output to revault. That is, the stricter one will always rule."""