From a7643260a49d8a36b63d6748d25c6cd30b3900dd Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Tue, 7 Sep 2021 19:27:26 +0000 Subject: [PATCH 1/3] assets_tutorial: move Daemon into its own file --- contrib/assets_tutorial/assets_tutorial.py | 109 +----------------- .../assets_tutorial/test_framework/daemon.py | 109 ++++++++++++++++++ 2 files changed, 111 insertions(+), 107 deletions(-) create mode 100644 contrib/assets_tutorial/test_framework/daemon.py diff --git a/contrib/assets_tutorial/assets_tutorial.py b/contrib/assets_tutorial/assets_tutorial.py index 4e145b4d42..8e0919768a 100755 --- a/contrib/assets_tutorial/assets_tutorial.py +++ b/contrib/assets_tutorial/assets_tutorial.py @@ -1,14 +1,9 @@ #!/usr/bin/env python3 -from test_framework.authproxy import AuthServiceProxy, JSONRPCException +from test_framework.authproxy import JSONRPCException +from test_framework.daemon import Daemon, sync_all import argparse -import os -import pathlib import sys -import tempfile -import time -import subprocess -import shutil from decimal import Decimal ## 0. Boilerplate to make the tutorial executable as a script @@ -24,106 +19,6 @@ parser.add_argument('--no-cleanup', default=False, action="store_true") args = parser.parse_args() -class Daemon(): - """ - A class for representing a bitcoind or elementsd node. - - Wraps the process management, creation and deletion of datadirs, and - RPC connectivity, into a simple object that will clean itself up on - exit. The `cleanup_on_exit` parameter can be set to False to prevent - the node from deleting its datadir on restart (and can be set by - passing --no-cleanup to the main program). - """ - - def __init__(self, name, daemon, path, conf_path, cleanup_on_exit = True): - self.name = name - self.daemon = daemon - self.conf_path = path - self.path = path - self.cleanup_on_exit = cleanup_on_exit - self.conf_path = conf_path - self.datadir_path = None - self.proc = None - self.rpc = None - - # Parse config - self.config = {} - with open(self.conf_path, encoding="utf8") as f: - for line in f: - if len(line) == 0 or line[0] == "#" or len(line.split("=")) != 2: - continue - self.config[line.split("=")[0]] = line.split("=")[1].strip() - - def shutdown(self): - if self.proc is not None: - print ("Shutting down %s" % self.name) - self.proc.terminate() - ## FIXME determine why we need 30+ seconds to shut down with a tiny regtest chain - self.proc.wait(120) - self.proc = None - - if self.datadir_path is not None: - if self.cleanup_on_exit: - shutil.rmtree(self.datadir_path) - else: - print ("Leaving %s datadir at %s." % (self.name, self.datadir_path)) - - def start(self, ext_args = None, keep_datadir = False): - if keep_datadir and self.datadir_path is not None: - temp = self.datadir_path - self.datadir_path = None - self.shutdown() - self.datadir_path = temp - else: - self.shutdown() - # Create datadir and copy config into place - self.datadir_path = tempfile.mkdtemp() - shutil.copyfile(self.conf_path, self.datadir_path + '/' + self.daemon + '.conf') - print("%s datadir: %s" % (self.name, self.datadir_path)) - - # Start process - print ("Starting %s" % self.name) - if ext_args is None: - ext_args = [] - self.proc = subprocess.Popen([self.path, "-datadir=" + self.datadir_path] + ext_args) - self.rpc = AuthServiceProxy("http://" + self.config["rpcuser"] + ":" + self.config["rpcpassword"] + "@127.0.0.1:" + self.config["rpcport"]) - - # Give daemon a moment to start up - time.sleep(1) - - def connect_to(self, other): - self.addnode("localhost:%s" % other.config['port'], "onetry") - - def restart(self, ext_args = None, keep_datadir = False): - self.start(ext_args, keep_datadir) - - def __del__(self): - self.shutdown() - - def __getattr__(self, name): - """Dispatches any unrecognised messages to the RPC connection or a CLI instance.""" - return self.rpc.__getattr__(name) - - def __getitem__(self, key): - """Dispatches any keys to the underlying config file""" - return self.config[key] - -def sync_all(nodes, timeout_sec = 10): - totalWait = timeout_sec - - stop_time = time.time() + timeout_sec - while time.time() <= stop_time: - best_hash = [x.getbestblockhash() for x in nodes] - if best_hash.count(best_hash[0]) == len(nodes): - break - time.sleep(1) - while time.time() <= stop_time: - pool = [set(x.getrawmempool()) for x in nodes] - if pool.count(pool[0]) == len(nodes): - return - time.sleep(1) - raise Exception("Nodes cannot sync blocks or mempool!") - # Setup daemons bitcoin = Daemon( "Bitcoin", diff --git a/contrib/assets_tutorial/test_framework/daemon.py b/contrib/assets_tutorial/test_framework/daemon.py new file mode 100644 index 0000000000..18ccf9a3ff --- /dev/null +++ b/contrib/assets_tutorial/test_framework/daemon.py @@ -0,0 +1,109 @@ + +from test_framework.authproxy import AuthServiceProxy + +import pathlib +import tempfile +import time +import shutil +import subprocess + +class Daemon(): + """ + A class for representing a bitcoind or elementsd node. + + Wraps the process management, creation and deletion of datadirs, and + RPC connectivity, into a simple object that will clean itself up on + exit. The `cleanup_on_exit` parameter can be set to False to prevent + the node from deleting its datadir on restart (and can be set by + passing --no-cleanup to the main program). + """ + + def __init__(self, name, daemon, path, conf_path, cleanup_on_exit = True): + self.name = name + self.daemon = daemon + self.conf_path = path + self.path = path + self.cleanup_on_exit = cleanup_on_exit + self.conf_path = conf_path + self.datadir_path = None + self.proc = None + self.rpc = None + + # Parse config + self.config = {} + with open(self.conf_path, encoding="utf8") as f: + for line in f: + if len(line) == 0 or line[0] == "#" or len(line.split("=")) != 2: + continue + self.config[line.split("=")[0]] = line.split("=")[1].strip() + + def shutdown(self): + if self.proc is not None: + print ("Shutting down %s" % self.name) + self.proc.terminate() + ## FIXME determine why we need 30+ seconds to shut down with a tiny regtest chain + self.proc.wait(120) + self.proc = None + + if self.datadir_path is not None: + if self.cleanup_on_exit: + shutil.rmtree(self.datadir_path) + else: + print ("Leaving %s datadir at %s." % (self.name, self.datadir_path)) + + def start(self, ext_args = None, keep_datadir = False): + if keep_datadir and self.datadir_path is not None: + temp = self.datadir_path + self.datadir_path = None + self.shutdown() + self.datadir_path = temp + else: + self.shutdown() + # Create datadir and copy config into place + self.datadir_path = tempfile.mkdtemp() + shutil.copyfile(self.conf_path, self.datadir_path + '/' + self.daemon + '.conf') + print("%s datadir: %s" % (self.name, self.datadir_path)) + + # Start process + print ("Starting %s" % self.name) + if ext_args is None: + ext_args = [] + self.proc = subprocess.Popen([self.path, "-datadir=" + self.datadir_path] + ext_args) + self.rpc = AuthServiceProxy("http://" + self.config["rpcuser"] + ":" + self.config["rpcpassword"] + "@127.0.0.1:" + self.config["rpcport"]) + + # Give daemon a moment to start up + time.sleep(1) + + def connect_to(self, other): + self.addnode("localhost:%s" % other.config['port'], "onetry") + + def restart(self, ext_args = None, keep_datadir = False): + self.start(ext_args, keep_datadir) + + def __del__(self): + self.shutdown() + + def __getattr__(self, name): + """Dispatches any unrecognised messages to the RPC connection or a CLI instance.""" + return self.rpc.__getattr__(name) + + def __getitem__(self, key): + """Dispatches any keys to the underlying config file""" + return self.config[key] + +def sync_all(nodes, timeout_sec = 10): + totalWait = timeout_sec + + stop_time = time.time() + timeout_sec + while time.time() <= stop_time: + best_hash = [x.getbestblockhash() for x in nodes] + if best_hash.count(best_hash[0]) == len(nodes): + break + time.sleep(1) + while time.time() <= stop_time: + pool = [set(x.getrawmempool()) for x in nodes] + if pool.count(pool[0]) == len(nodes): + return + time.sleep(1) + raise Exception("Nodes cannot sync blocks or mempool!") + From ec27e74f21312f867e7e5c14a6b67a582751a40f Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Wed, 8 Sep 2021 23:34:47 +0000 Subject: [PATCH 2/3] pset_tutorial: write 2-party PSET coinjoin demo --- contrib/assets_tutorial/pset_swap_tutorial.py | 334 ++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100755 contrib/assets_tutorial/pset_swap_tutorial.py diff --git a/contrib/assets_tutorial/pset_swap_tutorial.py b/contrib/assets_tutorial/pset_swap_tutorial.py new file mode 100755 index 0000000000..1506cfbe07 --- /dev/null +++ b/contrib/assets_tutorial/pset_swap_tutorial.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 + +from test_framework.authproxy import JSONRPCException +from test_framework.daemon import Daemon, sync_all +import argparse +import sys +from decimal import Decimal + +## PSET Swap Tutorial +# +# This script demonstrates how to implement an atomic swap, using a single +# Elements transaction, between two parties, Alice and Carol. Alice has +# ten thousand BTC and Carol has 1000 of a new asset. After the swap, Alice +# will have the new asset and Carol will have the BTC. +# +# This script can be executed in a Python intepreter. The user is encouraged +# to add tracing or debugging code throughout to better understand what is +# going on. +# + +## 0. Boilerplate to make the tutorial executable as a script +# +# Skip ahead to step 1 if you are reading +# + +# Parse arguments +parser = argparse.ArgumentParser() +parser.add_argument("--elementsd-dir", type=str, default="./src") +parser.add_argument("--no-cleanup", default=False, action="store_true") + +args = parser.parse_args() + +# Setup daemons +alice = Daemon( + "Alice", + "elements", + args.elementsd_dir + "/elementsd", + "contrib/assets_tutorial/elements1.conf", + not args.no_cleanup, +) + +carol = Daemon( + "Carol", + "elements", + args.elementsd_dir + "/elementsd", + "contrib/assets_tutorial/elements2.conf", + not args.no_cleanup, +) + +## 1. Start nodes +print ("1. Start nodes and setup scenario") + +# 1a. Turn on both nodes. Disable -validatepegin as we will just swap the +# initialcoins asset with a newly issued asset. Using the peg would +# be an inessential distraction. +# +alice.start(["-validatepegin=0"]) +carol.start(["-validatepegin=0"]) +alice.connect_to(carol) +carol.connect_to(alice) +alice.createwallet("wallet") +carol.createwallet("wallet") +alice.rescanblockchain() + +# 1b. Split the initial coins so that both sides have some (but Alice has more) +alice.sendmany("", { alice.getnewaddress(): 10000 for x in range(0, 10) }) +alice.sendtoaddress(carol.getnewaddress(), 100000) +alice.generatetoaddress(1, alice.getnewaddress()) + +# 1c. Issue 1000 units of a new asset. No reissuance tokens. Send them +# all to the second node. +issue = alice.issueasset(1000, 0) +alice.sendtoaddress(address=carol.getnewaddress(), amount=1000, assetlabel=issue["asset"]) +alice.generatetoaddress(1, alice.getnewaddress()) +sync_all([alice, carol]) + +# 1d. Move the coins around on each wallet so that they do not share +# any wallet transaction. (Otherwise they may fill in each others' +# UTXO data, which is harmless but makes the tutorial harder to +# follow.) +alice.sendtoaddress(alice.getnewaddress(), alice.getbalance()["bitcoin"], "", "", True) +carol.sendtoaddress(carol.getnewaddress(), carol.getbalance()["bitcoin"], "", "", True) +carol.sendtoaddress(address=carol.getnewaddress(), amount=1000, assetlabel=issue["asset"]) +sync_all([alice, carol]) +alice.generatetoaddress(1, alice.getnewaddress()) +sync_all([alice, carol]) + +# Define some variables and continue.. +asset_ALT = issue["asset"] +asset_BTC = alice.dumpassetlabels()["bitcoin"] + +assert len(alice.listunspent()) > 0 +assert len(carol.listunspent()) > 0 + +## 2. Construct a swap transaction in PSET format +# +# At this point node `alice` has 10.5MM "bitcoin" and node `carol` has 1000 of +# a new asset. We want to do an atomic swap: 2500 BTC for 1000 of +# the new asset. +# +print ("2. Create an unsigned swap transaction in PSET format.") + +# +# Although PSBT2 was designed with coinjoining in mind, the RPC interface +# currently does not support iteratively creating a transaction. So we must +# create the coinjoin transaction using the raw transaction API. +# +# Our strategy will be for both parties to create single transactions, then +# we will manually combine them. +# +# This step is fairly complicated and will be significantly simplified by +# future RPC improvements. +# + +# First, each party generates a new address +print ("2a. Exchange addresses") +alice_addr = alice.getnewaddress() +carol_addr = carol.getnewaddress() +print (" Alice: ", alice_addr) +print (" Carol: ", carol_addr) + +# Then they each create and fund (but don't sign!) partial transactions +# which simply send the assets to each other. +print ("2b. Exchange partial transaction data") +raw_tx_a = alice.createrawtransaction(outputs=[{carol_addr: 2500}]) +funded_a = alice.fundrawtransaction(raw_tx_a)['hex'] + +raw_tx_c = carol.createrawtransaction(outputs=[{alice_addr: 1000, "asset": asset_ALT}]) +funded_c = carol.fundrawtransaction(raw_tx_c)['hex'] + +# Each party cross-checks the other's transaction +print ("2c. Check partial transactions") +decoded_a = carol.decoderawtransaction(funded_a) + +carol_spk = carol.validateaddress(carol_addr)['scriptPubKey'] +found_my_output = False +feerate = None +for output in decoded_a["vout"]: + if output["scriptPubKey"]["type"] == "fee" and output["asset"] == asset_BTC: + # Feerate multiplier is 10^8 (btc->sat) / 10^3 (bytes->kb) to get a value in sat/kb + feerate = 100000.0 * float(output["value"]) / decoded_a["vsize"] + if output["scriptPubKey"]["type"] == "witness_v0_keyhash" \ + and output["scriptPubKey"]["hex"] == carol_spk \ + and output["asset"] == asset_BTC \ + and output["value"] == 2500: + found_my_output = True + +assert feerate > 0.1 # should probably compare against your own feerate +assert found_my_output + +## +## Check that none of the inputs in the counterparty's transaction actually +## belong to us. This is the second-most important check (after making sure +## that your outputs are present :)) and by far the easiest to forget. Do +## not forget this!!! +## + +# The way we do this check in Python is by making a set of each +# output the wallet can spend... +my_unspent = { (x["txid"], x["vout"]) for x in carol.listunspent() if x["spendable"] } +# ...then making sure that none of the inputs in the counterparty's +# transaction appear in this set. +for inp in decoded_a["vin"]: + assert not (inp["txid"], inp["vout"]) in my_unspent + +# ...and same on Alice's end. We don't bother repeating the checks +# in this script but they should be done. +decoded_c = alice.decoderawtransaction(funded_c) + +print ("2d. Create combined transaction") + +# +# Once each party has cross-checked the counterparty's transaction, they can +# just combine the two. There is no need to communicate here since both parties +# will construct the same transaction. To eliminate all ambiguity we sort inputs +# by txid:vout and sort outputs by scriptPubKey. +# + +inputs = sorted( + decoded_a["vin"] + decoded_c["vin"], + key = lambda vin: vin["txid"] + str(vin["vout"]) +) +outputs = sorted( + decoded_a["vout"] + decoded_c["vout"], + key = lambda vout: vout["scriptPubKey"]["hex"] +) +# Sorting by scriptPubKey will put the fee outputs first +first_fee = outputs[0] +outputs = outputs[1:] +outputs[0]["value"] += first_fee["value"] + +# Determine blinder indices from inputs +alice_idx = None +carol_idx = None +for n, inp in enumerate(inputs): + if inp in decoded_a["vin"]: + alice_idx = n + elif inp in decoded_c["vin"]: + carol_idx = n + +# Convert inputs and outputs to a format needed by `createpsbt` +inputs_createpsbt = [{"txid": x["txid"], "vout": x["vout"], "sequence": 0xffffffff} for x in inputs] +outputs_createpsbt = [] + +for out in outputs: + blinder_idx = None + if out["scriptPubKey"]["type"] == "fee": + address = "fee" + else: + if out in decoded_a["vout"]: + blinder_idx = alice_idx + elif out in decoded_c["vout"]: + blinder_idx = carol_idx + + # The crappy rawtransaction API requires that we reconstruct blinded addresses, + # which are split between the "addresses" and "nonce" field + address = out["scriptPubKey"]["addresses"][0] + if "commitmentnonce" in out: + address = alice.createblindedaddress(address, out["commitmentnonce"]) + + if blinder_idx is None: + outputs_createpsbt.append({address: out["value"], "asset": out["asset"]}) + else: + outputs_createpsbt.append({address: out["value"], "asset": out["asset"], "blinder_index": blinder_idx}) + +alice_pset = alice.createpsbt(inputs_createpsbt, outputs_createpsbt) +carol_pset = alice.createpsbt(inputs_createpsbt, outputs_createpsbt) +assert alice_pset == carol_pset +print ("Created PSET: ", alice_pset) + +# Use `analyzepsbt` to see what's happening +analysis = alice.analyzepsbt(alice_pset) +assert analysis["next"] == "updater" +assert not any([x["has_utxo"] for x in analysis["inputs"]]) +assert not any([x["is_final"] for x in analysis["inputs"]]) +assert all([x["next"] == "updater" for x in analysis["inputs"]]) + +## 3. Update the PSET +# +# Before blinding can take place, both parties need to provide UTXO data +# for their inputs. They can share this data by either updating the PSET +# in turn, or by both updating their copies and then one party calls +# `combinepsbt` to combine the results. +# +# We will do the latter for symmetry reasons. +# + +print ("3. Both parties fill in their UTXO data.") +alice_pset = alice.walletprocesspsbt(alice_pset)["psbt"] +carol_pset = carol.walletprocesspsbt(carol_pset)["psbt"] +filled_pset = alice.combinepsbt([alice_pset, carol_pset]) + +# Use `analyzepsbt` to see what's happening +analysis = alice.analyzepsbt(filled_pset) +#assert analysis["next"] == "blinder" ## FIXME: should be "blinder" but is actually "signer" +assert all([x["has_utxo"] for x in analysis["inputs"]]) +assert not any([x["is_final"] for x in analysis["inputs"]]) +assert all([x["next"] == "signer" for x in analysis["inputs"]]) + +## 3. Blind the PSET +# +# Both parties now blind the PSET. Importantly, the final person to blind must +# have a combined PSET that has everyone else's blinding data included, so that +# they can make the blinding factors add up. +# +# In the 2-party case this is particularly simple because there is no need to +# combine anything. +# + +# Just for fun, try to have both parties blind independently and combine +alice_blinded = alice.walletprocesspsbt(filled_pset) +carol_blinded = carol.walletprocesspsbt(filled_pset) +try: + alice.combinepsbt([alice_blinded["psbt"], carol_blinded["psbt"]]) +except JSONRPCException: + pass +else: + raise Exception("combinepsbt should return 'Cannot combine PSETs as the values and blinders would become imbalanced'") + +# Ok, back to the tutorial +print ("3. One party blinds the PSET and passes to the other party, who also blinds") +# We set sign=False here, because otherwise carol's `walletprocesspsbt` +# will sign the transaction (since after she does her blinding, the +# transaction will be completely blinded and therefore signable). +# But for this tutorial we want that to happen only in the next step. +alice_blinded = alice.walletprocesspsbt(filled_pset) +carol_blinded = carol.walletprocesspsbt(psbt=alice_blinded["psbt"], sign=False) +blinded = carol_blinded + +# We won't print these out because they're very big now +assert not alice_blinded["complete"] +assert not carol_blinded["complete"] +assert len(blinded["psbt"]) > 5000 +assert len(blinded["psbt"]) > 10000 + +# Use `analyzepsbt` to see what's happening +analysis = alice.analyzepsbt(blinded["psbt"]) +assert analysis["next"] == "signer" +assert all([x["has_utxo"] for x in analysis["inputs"]]) +assert not any([x["is_final"] for x in analysis["inputs"]]) +assert all([x["next"] == "signer" for x in analysis["inputs"]]) + +## 4. Sign the PSET +# +# When signing, there are a couple options for workflows. Each party can sign +# in turn, like we did when blinding, or they can each sign independently and +# then combine the results with `combinepsbt`. We choose the latter one (a) for +# demonstration purposes. +# + +print ("4. Both parties sign the PSET") + +alice_signed = alice.walletprocesspsbt(blinded["psbt"]) +carol_signed = carol.walletprocesspsbt(blinded["psbt"]) +assert not alice_signed["complete"] +assert not carol_signed["complete"] + +complete = alice.combinepsbt([alice_signed["psbt"], carol_signed["psbt"]]) + +# Use `analyzepsbt` to see what's happening +analysis = alice.analyzepsbt(complete) +assert analysis["next"] == "extractor" +assert all([x["has_utxo"] for x in analysis["inputs"]]) +assert all([x["is_final"] for x in analysis["inputs"]]) +assert all([x["next"] == "extractor" for x in analysis["inputs"]]) + +print ("5. Finalize and Extract") +x = alice.finalizepsbt(complete, True) +assert x["complete"] +# The complete transaction is in x["hex"] but again we won't print it because +# it's massive. + + + From 048ca4a5503830d3f978344065d24f28d8f0ca45 Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Tue, 14 Sep 2021 15:48:35 +0000 Subject: [PATCH 3/3] pset: fix logic bug in `analyzepsbt` so that the "blinder" role may be returned --- contrib/assets_tutorial/pset_swap_tutorial.py | 2 +- src/node/psbt.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contrib/assets_tutorial/pset_swap_tutorial.py b/contrib/assets_tutorial/pset_swap_tutorial.py index 1506cfbe07..ff03ff63df 100755 --- a/contrib/assets_tutorial/pset_swap_tutorial.py +++ b/contrib/assets_tutorial/pset_swap_tutorial.py @@ -252,7 +252,7 @@ filled_pset = alice.combinepsbt([alice_pset, carol_pset]) # Use `analyzepsbt` to see what's happening analysis = alice.analyzepsbt(filled_pset) -#assert analysis["next"] == "blinder" ## FIXME: should be "blinder" but is actually "signer" +assert analysis["next"] == "blinder" assert all([x["has_utxo"] for x in analysis["inputs"]]) assert not any([x["is_final"] for x in analysis["inputs"]]) assert all([x["next"] == "signer" for x in analysis["inputs"]]) diff --git a/src/node/psbt.cpp b/src/node/psbt.cpp index dc0a5e87c8..a9e76c1493 100644 --- a/src/node/psbt.cpp +++ b/src/node/psbt.cpp @@ -22,6 +22,7 @@ PSBTAnalysis AnalyzePSBT(PartiallySignedTransaction psbtx) bool has_blinded_outputs = false; result.inputs.resize(psbtx.inputs.size()); + result.next = PSBTRole::EXTRACTOR; for (unsigned int i = 0; i < psbtx.inputs.size(); ++i) { PSBTInput& input = psbtx.inputs[i]; @@ -150,7 +151,6 @@ PSBTAnalysis AnalyzePSBT(PartiallySignedTransaction psbtx) } // Calculate next role for PSBT by grabbing "minimum" PSBTInput next role - result.next = PSBTRole::EXTRACTOR; for (unsigned int i = 0; i < psbtx.inputs.size(); ++i) { PSBTInputAnalysis& input_analysis = result.inputs[i]; result.next = std::min(result.next, input_analysis.next);