From 1d2dacb1e2e34837c4fd8f0179d3b7250adaaa5e Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Mon, 6 Sep 2021 14:48:42 +0000 Subject: [PATCH] assets_tutorial: basically rewrite the whole thing --- contrib/assets_tutorial/assets_tutorial.py | 731 ++++++++++++++------- contrib/assets_tutorial/bitcoin.conf | 9 + contrib/assets_tutorial/elements1.conf | 11 +- contrib/assets_tutorial/elements2.conf | 10 +- 4 files changed, 509 insertions(+), 252 deletions(-) diff --git a/contrib/assets_tutorial/assets_tutorial.py b/contrib/assets_tutorial/assets_tutorial.py index cf7ac6f343..4e145b4d42 100755 --- a/contrib/assets_tutorial/assets_tutorial.py +++ b/contrib/assets_tutorial/assets_tutorial.py @@ -56,7 +56,10 @@ class Daemon(): def shutdown(self): if self.proc is not None: - self.proc.kill() + 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: @@ -65,21 +68,34 @@ class Daemon(): else: print ("Leaving %s datadir at %s." % (self.name, self.datadir_path)) - def start(self, ext_args = None): - self.shutdown() + 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 = [] - # 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 - self.proc = subprocess.Popen([self.path, "-datadir=" + self.datadir_path] + ext_args, stdout=subprocess.PIPE) + 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"]) - def restart(self, ext_args = None): - self.start(ext_args) + # 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() @@ -95,11 +111,17 @@ class Daemon(): def sync_all(nodes, timeout_sec = 10): totalWait = timeout_sec - stop_time = time.time() + timeout + 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 @@ -128,6 +150,7 @@ e2 = Daemon( ) ## 1. Start nodes +print ("1. Start nodes") # # 1a. Confirm that we not start an elements node if validatepegin is set and there # is no bitcoind. When validatepegin is set, elementsd attempts to connect to @@ -136,11 +159,11 @@ e2 = Daemon( # Alternatively, you can set validatepegin=0 (it defaults to being on) in the # elementsd config, and run it without a Bitcoin node, but this means that you # will not be fully validating the two-way peg. +print ("1a. Attempting to start a validatepegin daemon without a bitcoind (will fail)") assert e1["validatepegin"] == "1" e1.start() -time.sleep(1) ## give daemon a moment to start up (or not) try: e1.getinfo() print ("ERROR: was able to start an elementsd without a working bitcoind") @@ -151,106 +174,208 @@ except: # 1b. Start bitcoind, then elementsd. Initially, the bitcoind may be warming up and # inaccessible over RPC. elementsd can detect this case and will stall until the # bitcoind is warmed up. +print ("1b. Attempting to start validatepegin daemons with a bitcoind (will succeed)") bitcoin.start() e1.start() e2.start() -time.sleep(1) ## give daemons a moment to start up +# Connect the nodes. This can also be accomplished with the `connect=` config +# parameter, but when starting two nodes simultaneously, this is unreliable. +e1.connect_to(e2) +e2.connect_to(e1) # 1c. Create a wallet on the Elements nodes. This is needed since version 0.21 # of the daemon; previously a wallet was created by default if one does not # already exist. -e1.createwallet("wallet1") -e2.createwallet("wallet2") +print ("1c. Creating wallets on all daemons") # We have configured this regtest chain to start with 21M bitcoins, which are initally # in a single OP_TRUE output. All Elements wallets recognize OP_TRUE outputs as their # own (this differs from Bitcoin), so the 21M bitcoins are immediately available for -# use. +# use. This can be disabled by setting `anyonecanspend_aremine=0` in the daemon config. # # This is useful for testing basic functionality and for blockchains that have no peg, # since every blockchain needs a default "policy asset". This policy asset is used # for transaction fees (which are required for anti-DoS purposes). Also, asset # issuances require some pre-existing asset, since they consume inputs for entropy. # +# To separate the policy asset (used for fees) from the peg asset, use the `-policyasset` +# configuration value. +# # In Elements there is no block subsidy. In a production sidechain, `initialfreecoins` # will likely be set to zero, necessitating peg-in functionality to get a policy asset. + +e1.createwallet("wallet1") +e2.createwallet("wallet2") + +# Because of https://github.com/ElementsProject/elements/issues/956 we need to run +# `rescanblockchain` after creating the wallets to detect the `TRUE` outputs assert e1["initialfreecoins"] == "2100000000000000" -print (e1.getwalletinfo()) +assert e1.getwalletinfo()['balance'] == { 'bitcoin': 0 } +e1.rescanblockchain() +e2.rescanblockchain() +assert e1.getwalletinfo()['balance'] == { 'bitcoin': 21000000 } +assert e2.getwalletinfo()['balance'] == { 'bitcoin': 21000000 } +# All the initial coins coins are in one UTXO +assert len(e1.listunspent()) == 1 +# ...and both nodes think they own it. This is a common situation when +# using near-empty test chains, so be aware of it. +assert e1.listunspent() == e2.listunspent() -print ("Waiting 2 mins") -time.sleep(120) ## give daemons a moment to start up -sys.exit(0) -# In regtest mining "target" is OP_TRUE since we have not set `-signblockscript` argument -# Generate simply works. -e1.generatetoaddress(101, e1.getnewaddress()) -sync_all(e1, e2) +# Generate 10 blocks to demonstrate how block generation work. On our test chain, +# each block has a subsidy of zero (this can be changed with `con_blocksubsidy`) +# and sends the coins to an OP_TRUE output (this script can be changed with +# the `-signblockscript` config). +e1.generatetoaddress(10, e1.getnewaddress()) +# The wallet does not recognize zero-valued outputs as being owned +assert len(e1.listunspent()) == 1 +assert e1.getwalletinfo()['balance'] == { 'bitcoin': 21000000 } -# WALLET +# Synchronize the chains +sync_all([e1, e2]) +assert e1.getblockcount() == 10 +assert e1.getbestblockhash() == e2.getbestblockhash() -# First, send all anyone-can-spend coins to e1 then split so balances are even -e1.sendtoaddress(e1.getnewaddress(), 21000000, "", "", True) -e1.generatetoaddress(101, e1.getnewaddress()) -sync_all(e1, e2) -e1.sendtoaddress(e2.getnewaddress(), 10500000, "", "", False) -e1.generatetoaddress(101, e1.getnewaddress()) -sync_all(e1, e2) +## 2. Basic wallet usage +print ("") +print ("2. Basic wallet usage") -# Funds should now be evenly split between the two wallets -e1.getwalletinfo() -e2.getwalletinfo() +# 2a. Send all the coins to e1 +# Observe that this address is a confidential address (is much longer +# than an ordinary address). Using the `validateaddress` RPC, you can +# see details of the address, including its unconfidential version. +addr = e1.getnewaddress() +print ("2a. Sending all initial coins to e1 (address %s)", addr) +txid1 = e1.sendtoaddress(addr, 21000000, "", "", True) +e1.generatetoaddress(1, e1.getnewaddress()) +sync_all([e1, e2]) +assert len(e1.listunspent()) == 1 # change output, but no coinbase +assert len(e2.listunspent()) == 0 -# Have e2 send coins to themself using a blinded Elements address -# Blinded addresses start with `CTE`, unblinded `2` +# 2b. Send half of these to e2 addr = e2.getnewaddress() +print ("2b. Sending half to e2 (address %s)." % addr) +txid2 = e1.sendtoaddress(addr, 10500000, "", "", False) +e1.generatetoaddress(1, e1.getnewaddress()) +assert len(e1.listunspent()) == 1 # change output, but no coinbase +e1.generatetoaddress(99, e1.getnewaddress()) +assert len(e1.listunspent()) == 2 # change output, and coinbase with fees from first transaction +e1.generatetoaddress(1, e1.getnewaddress()) +assert len(e1.listunspent()) == 3 # ...and fees from the second transaction +sync_all([e1, e2]) +assert len(e2.listunspent()) == 1 -# How do we know it's blinded? Check for blinding key, unblinded address. -e2.getaddressinfo(addr) +# Funds should now be evenly split between the two wallets. e2 directly +# received 10500000, while e1 has the remainder, less fees (because it +# created the transactions), plus fees (because it created the blocks). +assert e1.getbalance() == e2.getbalance() +assert e1.listunspent() != e2.listunspent() -# Basic blinded send -txid = e2.sendtoaddress(addr, 1) +# 2c. Self-send this half to e2 again +addr = e2.getnewaddress() +print ("2c. Self-sending this half to e2 (address %s)." % addr) +txid3 = e2.sendtoaddress(addr, 10500000, "", "", True) +sync_all([e1, e2]) +e1.generatetoaddress(101, e1.getnewaddress()) +sync_all([e1, e2]) -e2.generatetoaddress(1, e1.getnewaddress()) -sync_all(e1, e2) +# New e1 has slightly more coins than e2, because it received the fees +# from the last transaction +assert len(e1.listunspent()) == 4 +assert len(e2.listunspent()) == 1 +assert e1.getbalance()['bitcoin'] > e2.getbalance()['bitcoin'] -# Now let's examine the transaction, both in wallet and without +# 2d. Analyze transactions +print ("2d. Analyzing transactions.") -# In-wallet, take a look at blinding information -e2.gettransaction(txid) +# The first transaction is a 1-input-2-output transaction starting from an +# unblinded input and where one output (the fee) must be unblinded. Since +# blinding the remaining output would accomplish nothing, it is unblinded +# even though a confidential address was used. +tx1 = e1.getrawtransaction(txid1, True) +assert len(tx1['vin']) == 1 +assert len(tx1['vout']) == 2 +assert all(['value' in out for out in tx1['vout']]) +# The second transaction though has three outputs, including change. Now +# there is value in blinding, so both the destination output and change +# output are blinded +tx2 = e1.getrawtransaction(txid2, True) +assert len(tx2['vin']) == 1 +assert len(tx2['vout']) == 3 +assert any(['value-minimum' in out for out in tx2['vout']]) +# The third transaction, which set subtractfeefromamount (the `True` passed +# to `sendtoaddress`), will again be a 1-input-2-output transaction, where +# one output is the unblinded fee. But since its input is confidential, the +# output will be too. +tx3 = e1.getrawtransaction(txid3, True) +assert len(tx3['vin']) == 1 +assert len(tx3['vout']) == 2 +assert any(['value' in out for out in tx3['vout']]) +assert any(['value-minimum' in out for out in tx3['vout']]) -# e1 doesn't have in wallet since it's unrelated +# Check that these transactions are visible in the correct wallets, with the +# expected effects. Check that they are not visible in the opposing wallet +assert e1.gettransaction(txid1)['amount'] == { 'bitcoin': 0 } +assert e2.gettransaction(txid1)['amount']['bitcoin'] < -20999999 # exact value depends on fee +assert e1.gettransaction(txid2)['amount'] == { 'bitcoin': -10500000 } +assert e2.gettransaction(txid2)['amount'] == { 'bitcoin': 10500000 } +assert e2.gettransaction(txid3)['amount'] == { 'bitcoin': 0 } + +# txid3 appears only in e2, not e1 try: - e1.gettransaction(txid) - raise Exception("Transaction should not be in wallet") + e1.gettransaction(txid3) except JSONRPCException: pass +else: + raise Exception("Transaction 3 should not be in wallet 1") -# Get public info, see blinded ranges, etc -e1.getrawtransaction(txid, 1) +## 3. Confidential assets and keys +print ("") +print ("3. Confidential Keys") +current_e1_balance = e1.getbalance() + +# 3a. Import an address's secret key +print ("3a. Import an address's secret key") +# Recall that `addr` was last set to an address owned by e2, and which +# we sent 10.5 million coins to in `tx3`. The public data (mostly hidden) +# is visible with `getrawtransaction`, while the confidential data is +# visible on `e2` (but not `e1`") with `gettransaction`. # Now let's private import the key to attempt a spend e1.importprivkey(e2.dumpprivkey(addr)) +# Now `gettransaction` no longer triggers an exception, but the confidential +# data is still not available. +assert e1.gettransaction(txid3)['details'] == [] +assert e2.gettransaction(txid3)['details'] != [] +# Its output won't appear in listunspent, and the wallet balance will be unaffected +assert len(e1.listunspent()) == 4 +assert e1.getbalance() == current_e1_balance -# We can't see output value info though -# and can not send. -e1.gettransaction(txid) - -# And it won't show in balance or known outputs -e1.getwalletinfo() -# Amount for transaction is unknown, so it is not shown in listunspent. -e1.listunspent(1, 1) - +# 3a. Import an address's blinding key +print ("3b. Import an address's blinding key") # Solution: Import blinding key e1.importblindingkey(addr, e2.dumpblindingkey(addr)) # Check again, funds should show -e1.getwalletinfo() -e1.listunspent(1, 1) -e1.gettransaction(txid) +assert len(e1.listunspent()) == 5 +assert e1.getbalance()['bitcoin'] > current_e1_balance['bitcoin'] +assert e1.gettransaction(txid3)['details'] != [] +# Move funds to fresh addresses to avoid any confusion related to shared +# coins down the line (e.g. conflicting transactions). +e1.sendtoaddress(e1.getnewaddress(), e1.getbalance()['bitcoin'], "", "", True) +e1.sendtoaddress(e2.getnewaddress(), e1.getbalance()['bitcoin'] / 2, "", "", True) +e1.generatetoaddress(1, e1.getnewaddress()) +sync_all([e1, e2]) + +## 4. 2-of-2 multisig +# # Let's build a blinded 2-of-2 multisig p2sh address +print ("") +print ("4. 2-of-2 multisig") +print ("4a. Create a multisig address.") # 1) Get unblinded addresses from each participant addr1 = e1.getaddressinfo(e1.getnewaddress())["unconfidential"] addr2 = e2.getaddressinfo(e2.getnewaddress())["unconfidential"] @@ -264,6 +389,7 @@ blindingpubkey = addrinfo1["confidential_key"] # 3) Make multisig address like usual multisig = e1.createmultisig(2, [addrinfo1["pubkey"], addrinfo2["pubkey"]]) +print ("4b. Blind the multisig address (using a blinding key from e1).") # 4) Blind the address using the blinding pubkey blinded_addr = e1.createblindedaddress(multisig["address"], blindingpubkey) e1.importaddress(multisig["redeemScript"], "", True, True) # Make sure p2sh addr is added @@ -273,105 +399,171 @@ e2.importaddress(blinded_addr) # 5) Now the address can be funded, though e2 will not be able to see values txid = e1.sendtoaddress(blinded_addr, 1) -sync_all(e1, e2) -e2.gettransaction(txid, True) +sync_all([e1, e2]) +assert e1.gettransaction(txid, True)['details'] != [] +assert e2.gettransaction(txid, True)['details'] == [] -# 6) Import the blinding privkey and decode the values +print ("4c. Share the blinding key with e2") e2.importblindingkey(blinded_addr, blindingkey) -e2.gettransaction(txid, True) +assert e1.gettransaction(txid, True)['details'] != [] +assert e2.gettransaction(txid, True)['details'] != [] -# ASSETS - -# Many of the RPC calls have added asset type or label -# arguments and reveal alternative asset information. With no argument all are listed: -e1.getwalletinfo() - -# Notice we now see "bitcoin" as an asset. This is the asset label for the hex for "bitcoin" which can be discovered: -e1.dumpassetlabels() +## 5. Multi-asset support +# +# Many of the RPC calls have added asset type or label arguments, and reveal +# alternative asset information. With no argument all are listed. For example, +# try `getwalletinfo` or `getbalance`. (Notice in the above code our assertions +# have taken forms like {"bitcoin": 100} rather than bare numbers.) +# +# Notice we now see "bitcoin" as an asset. This is the asset label for the hex +# for "bitcoin" which can be discovered using the `dumpassetlabels` RPC. We +# can see more details of each issuance that your wallet knows about with the +# `listissuances` RPC. Initially there is only one asset, "bitcoin", and one +# issuance (the initial issuance). +print ("") +print ("5. Multi-asset support") +print ("Existing assets: ", e1.dumpassetlabels()) +assert len(e1.dumpassetlabels()) == 1 +assert len(e1.listissuances()) == 1 +assert e1.listissuances()[0]['assetlabel'] == "bitcoin" +assert e1.listissuances()[0]['assetamount'] == 21000000 # 21M initial free coins +assert e1.listissuances()[0]['tokenamount'] == 0 # no reissuance tokens +assert e1.dumpassetlabels() == e2.dumpassetlabels() +print ("5a. Issue a new asset, with reissuance token") # We can also issue our own assets, 1 asset and 1 reissuance token in this case issue = e1.issueasset(1, 1) asset = issue["asset"] # From there you can look at the issuances you have in your wallet -e1.listissuances() +assert len(e1.listissuances()) == 2 +assert len(e2.listissuances()) == 1 ## e2 does not recognize this as a wallet issuance +new_issuances = [i for i in e1.listissuances() if 'assetlabel' not in i] +assert len(new_issuances) == 1 +assert new_issuances[0]['assetamount'] == 1 +assert new_issuances[0]['tokenamount'] == 1 +assert len(e2.listissuances()) == 1 ## ANDREW +print ("5b. Reissue the asset using the reissuance token.") # If you gave `issueasset` a reissuance token argument greater than 0 -# you can also reissue the base asset +# you can also reissue the base asset. This will appear as a second issuance +# in `listissuances`. e1.reissueasset(asset, 1) +new_issuances = [i for i in e1.listissuances() if 'assetlabel' not in i] +assert len(new_issuances) == 2 +assert new_issuances[0]['assetamount'] == 1 +assert new_issuances[1]['assetamount'] == 1 +# The original issuance will show `tokenamount` while the new one will not have +# this field. Python makes it annoying to assert this. -# or make another different unblinded asset issuance, with only reissuance tokens initially -e1.issueasset(0, 1, False) +print ("5c. Issue a new asset with only reissuance tokens, no actual asset.") +new_issue = e1.issueasset(0, 1, False) # `False` tells elementsd not to blind the issuance +assert len(e1.listissuances()) == 4 +assert len(e2.listissuances()) == 1 +issuance = [i for i in e1.listissuances() if i.get('asset') == new_issue['asset']][0] +assert issuance['assetamount'] == -1 # should be 0, see https://github.com/ElementsProject/elements/issues/1035 +assert issuance['tokenamount'] == 1 -# Then two issuances for that particular asset will show -e1.listissuances(asset) +sync_all([e1, e2]) +e1.generatetoaddress(1, e1.getnewaddress()) +sync_all([e1, e2]) +print ("5d. Label a new asset.") # To label any asset add a new argument like this to your elements.conf file -# then restart your daemon: -assetentry = "-assetdir="+asset+":namedasset" +# then restart your daemon. Remember to reload the wallet after restarting. # Wallet labels have no consensus meaning, only local node/wallet meaning +assetentry = "-assetdir="+asset+":namedasset" +e1.restart([assetentry], keep_datadir=True) -sync_all(e1, e2) -e1.stop() -time.sleep(5) +assert e1.getbestblockhash() == e2.getbestblockhash() ## sanity check that node remembers the blockchain +e1.connect_to(e2) +e2.connect_to(e1) +e1.loadwallet("wallet1") -# Restart with a new asset label -e1 = startelementsd(e1_datadir, e1conf, [assetentry]) -time.sleep(5) - -e1.getwalletinfo() +# The new label will be reflected in the RPC +assert e1.getwalletinfo()['balance']['namedasset'] == 2 +assert e1.getbalance()['namedasset'] == 2 +print ("5e. Transfer assets.") # To send issued assets, add an additional argument to sendtoaddress using the hex or label e1.sendtoaddress(address=e2.getnewaddress(), amount=1, assetlabel="namedasset") # Reissuance tokens can also be sent like any other asset e1.sendtoaddress(address=e2.getnewaddress(), amount=1, assetlabel=issue["token"]) -sync_all(e1, e2) +sync_all([e1, e2]) # e2 wallet doesn't know about label, just an unnamed asset -e2.getwalletinfo()["unconfirmed_balance"][asset] +assert e2.getwalletinfo()["unconfirmed_balance"][asset] == 1 +assert "namedasset" not in e2.getwalletinfo()["unconfirmed_balance"] e2.generatetoaddress(1, e2.getnewaddress()) -sync_all(e1, e2) +sync_all([e1, e2]) -# e2 maybe doesn't know about the issuance for the transaction sending him the new asset -e2.listissuances() +# e2, despite receiving an asset, continues not to know about its issuances +assert len(e2.listissuances()) == 1 +# ...and therefore, despite receiving a reissuance token, does not understand +# it and cannot use it to issue +try: + e2.reissueasset(issue["asset"], 5) +except JSONRPCException: + pass +else: + raise Exception("Should not be able to reissue a reissuance token") -# let's import an associated address(so the wallet captures issuance transaction) and rescan +print ("5e. Import an address used in an issuance.") +# However, if we import the address used in the issuance transaction and +# rescan, the wallet _will_ learn about the issuance, although it will +# not know about the amounts (which are blinded) txid = issue["txid"] addr = e1.gettransaction(txid)["details"][0]["address"] e2.importaddress(addr) -# e2 now sees issuance, but doesn't know amounts as they are blinded -e2.listissuances() +assert len(e2.listissuances()) == 2 +new_issuances = [i for i in e2.listissuances() if 'assetlabel' not in i] +assert len(new_issuances) == 1 +assert new_issuances[0]['assetamount'] == -1 +assert new_issuances[0]['tokenamount'] == -1 + +# At this point, e2 knows that its reissuance token is actually a reissuance +# token, and can use it to reissue. Even though it does not know about the +# original issuance. +e2.reissueasset(issue["asset"], 1) # We need to import the issuance blinding key. We refer to issuances by their txid/vin pair # as there is only one per input vin = issue["vin"] issuekey = e1.dumpissuanceblindingkey(txid, vin) - e2.importissuanceblindingkey(txid, vin, issuekey) # Now e2 can see issuance amounts and blinds -e2.listissuances() +assert len(e2.listissuances()) == 3 +new_issuances = [i for i in e2.listissuances() if 'assetlabel' not in i] +assert len(new_issuances) == 2 +assert new_issuances[0]['assetamount'] == 1 +assert new_issuances[1]['assetamount'] == 1 -# Since it was also sent a reissuance token, it can reissue the base asset -e2.reissueasset(issue["asset"], 5) - - -# Reissuing reissuance tokens is currently not supported +# Reissuing reissuance tokens is not supported try: e2.reissueasset(issue["token"], 1) except JSONRPCException: pass +else: + raise Exception("Should not be able to reissue a reissuance token") # For de-issuance, we can send assets or issuance tokens to an OP_RETURN output, provably burning them -e2.destroyamount(issue["asset"], 5) +e2.destroyamount(issue["asset"], 1) -# BLOCKSIGNING - -# Recall blocksigning is OP_TRUE +sync_all([e1, e2]) e1.generatetoaddress(1, e1.getnewaddress()) -sync_all(e1, e2) +sync_all([e1, e2]) -# Let's set it to something more interesting... 2-of-2 multisig +## 6. Blocksigning +# +# Up to now, we have been generating blocks to the default OP_TRUE script. Let's +# make this script more interesting. We'll use a 2-of-2 multisig made from keys +# from our two Elements nodes. +# + +print ("") +print ("6. Blocksigning") +print ("6a. Generating 2-of-2 blocksigning script") # First lets get some keys from both clients to make our block "challenge" addr1 = e1.getnewaddress() @@ -384,46 +576,51 @@ pubkey2 = valid2["pubkey"] key1 = e1.dumpprivkey(addr1) key2 = e2.dumpprivkey(addr2) -e1.stop() -e2.stop() -time.sleep(5) - -# Now filled with the pubkeys as 2-of-2 checkmultisig -signblockarg="-signblockscript=5221"+pubkey1+"21"+pubkey2+"52ae" -# Anti-DoS argument, custom chain default is ~1 sig so let's make it at least 2 sigs -blocksign_max_size="-con_max_block_sig_size=150" -dyna_deploy_start="-con_dyna_deploy_start=0" +# We need to define a witness script, which defines the 2-of-2 checkmultisig +witness_script = "5221" + pubkey1 + "21" + pubkey2 + "52ae" extra_args = [ - signblockarg, - blocksign_max_size, - dyna_deploy_start, + # We set the signblockscript to the witness script + "-signblockscript=" + witness_script, + # To prevent malleability attacks, we must set a maximum signature size. Since + # we expect to have two ECDSA signatures (each at most 73 bytes), 150 bytes is + # a sufficient value. + "-con_max_block_sig_size=150", + # We also disable dynamic federations, since we are not going to do any + # dynafed transitions in this tutorial. FIXME we probably should. + "-con_dyna_deploy_start=0", ] -# Wipe out datadirs, start over -shutil.rmtree(e1_datadir) -shutil.rmtree(e2_datadir) -os.makedirs(e1_datadir) -os.makedirs(e2_datadir) +print ("6b. Restart both nodes") +# Restart both nodes with the new consensus rules +e1.restart(extra_args) +e2.restart(extra_args) +e1.connect_to(e2) +e2.connect_to(e1) -# Copy back config files -shutil.copyfile("contrib/assets_tutorial/elements1.conf", e1_datadir+"/elements.conf") -shutil.copyfile("contrib/assets_tutorial/elements2.conf", e2_datadir+"/elements.conf") - -e1 = startelementsd(e1_datadir, e1conf, extra_args) -e2 = startelementsd(e2_datadir, e2conf, extra_args) -time.sleep(5) -sync_all(e1, e2) +# We cleared the datadirs, but even if we had not, changing the consensus +# rules would have invalidated the past blockchain and required we reset +# anyway. Now we have only the genesis block. +assert e1.getblockcount() == 0 +assert e2.getblockcount() == 0 +assert e1.getbestblockhash() == e2.getbestblockhash() +print ("6c. Import signing keys") # Now import signing keys +e1.createwallet("wallet1") +e2.createwallet("wallet2") e1.importprivkey(key1) e2.importprivkey(key2) -# Generate no longer works, even if keys are in wallet +# Generate no longer works, since neither node has sufficiently +# many keys to sign a block. In fact, even if both keys were +# available, `generatetoaddress` would not work because it does +# not attempt to solve the blocksigning script. try: e1.generatetoaddress(1, e1.getnewaddress()) - raise Exception("Generate shouldn't work") except JSONRPCException: pass +else: + raise Exception("Generate shouldn't work") try: e1.generatetoaddress(1, e1.getnewaddress()) @@ -431,143 +628,192 @@ try: except JSONRPCException: pass -# Let's propose and accept some blocks, e1 is master! +print ("6d. Propose a block") + +# Have e1 propose a block for both nodes to sign blockhex = e1.getnewblockhex() -# Unsigned is no good +# Without signing the block, it is not accepted by consensus # 0 before, 0 after -e1.getblockcount() == 0 - +assert e1.getblockcount() == 0 e1.submitblock(blockhex) +assert e1.getblockcount() == 0 -# Still 0 -e1.getblockcount() == 0 - - +print ("6d. Sign the block") # Signblock tests validity except block signatures # This signing step can be outsourced to a HSM signing to enforce business logic of any sort # See Strong Federations paper -sign1 = e1.signblock(blockhex) -sign2 = e2.signblock(blockhex) +sign1 = e1.signblock(blockhex, witness_script) +sign2 = e2.signblock(blockhex, witness_script) +assert len(sign1) == 1 # both nodes produce one signature +assert len(sign2) == 1 - -# We now can gather signatures any way you want, combine them into a fully signed block +# Obtain signatures from both nodes. Both signatures are required for the block +# to be considered complete. +blockresult = e1.combineblocksigs(blockhex, [sign1[0]]) +assert not blockresult["complete"] +blockresult = e1.combineblocksigs(blockhex, [sign2[0]]) +assert not blockresult["complete"] blockresult = e1.combineblocksigs(blockhex, [sign1[0], sign2[0]]) +assert blockresult["complete"] -blockresult["complete"] == True - -signedblock = blockresult["hex"] - -# Now submit the block, doesn't matter who -e2.submitblock(signedblock) -sync_all(e1, e2) +print ("6d. Submit the block") +# Either node may submit the block to the network +e2.submitblock(blockresult["hex"]) +sync_all([e1, e2]) # We now have moved forward one block! -e1.getblockcount() == 1 -e2.getblockcount() == 1 +assert e1.getblockcount() == 1 +assert e2.getblockcount() == 1 -e1.stop() -e2.stop() -time.sleep(5) +## The peg +# +# Everything peg-related can be done inside the Elements daemon directly, +# except for processing pegouts. This is because processing pegouts involves +# moving coins on the Bitcoin blockchain. In a production system, this is +# the most difficult part to get right, and by far the most important, as +# there is no going back if you lose funds on Bitcoin. +# +print ("") +print ("7. Dealing with the peg") +print ("7a. Restart both nodes") -# Further Exercises: -# - Make a python script that does round-robin consensus +extra_args = [ + # We'll lazily reuse our blocksigning script to handle the peg, and + # leave the blocksigning script unset (so it will revert to OP_TRUE) + "-fedpegscript=" + witness_script, + # Set initial free coins to 0, since we will now use the peg + "-initialfreecoins=0", +] +# Restart both nodes with the new consensus rules +e1.restart(extra_args) +e2.restart(extra_args) +e1.connect_to(e2) +e2.connect_to(e1) -# Pegging +e1.createwallet("wallet1") +e2.createwallet("wallet2") +e1.rescanblockchain() +e2.rescanblockchain() +assert e1.getwalletinfo()['balance'] == { 'bitcoin': 0 } +assert e2.getwalletinfo()['balance'] == { 'bitcoin': 0 } -# Everything pegging related can be done inside the Elements daemon directly, except for -# pegging out. This is due to the multisig pool aka Watchmen that controls the bitcoin -# on the Bitcoin blockchain. That is the easiest part to get wrong, and by far the most -# important as there is no going back if you lose the funds. +# Generate some Elements blocks as we cannot accept pegin claims +# while the blockheight is 0 (this may be unintended behavior). +e1.generatetoaddress(5, e1.getnewaddress()) +sync_all([e1, e2]) -# Wipe out datadirs, start over -shutil.rmtree(e1_datadir) -shutil.rmtree(e2_datadir) -os.makedirs(e1_datadir) -os.makedirs(e2_datadir) - -# Copy back config files -shutil.copyfile("contrib/assets_tutorial/elements1.conf", e1_datadir+"/elements.conf") -shutil.copyfile("contrib/assets_tutorial/elements2.conf", e2_datadir+"/elements.conf") - -fedpegarg="-fedpegscript=5221"+pubkey1+"21"+pubkey2+"52ae" - -# Back to OP_TRUE blocks, re-using pubkeys for pegin pool instead -# Keys can be the same or different, doesn't matter -e1 = startelementsd(e1_datadir, e1conf, [fedpegarg]) -e2 = startelementsd(e2_datadir, e2conf, [fedpegarg]) -time.sleep(5) - -# Mature some outputs on each side -e1.generatetoaddress(101, e1.getnewaddress()) +# Create some mature Bitcoin outputs +bitcoin.createwallet("bwallet") bitcoin.generatetoaddress(101, bitcoin.getnewaddress()) -sync_all(e1, e2) -# Now we can actually start pegging in. Examine the pegin address fields -e1.getpeginaddress() -# Changes each time as it's a new sidechain address as well as new "tweak" for the watchmen keys -# mainchain_address : where you send your bitcoin from Bitcoin network -# sidechain_address : where the bitcoin will end up on the sidechain after pegging in +print ("7b. Create a peg-in address") +# Once there are mature coins on the Bitcoin side, we can peg them in. +# We create a pegin address, which we create with the Elements wallet +# but which is a Bitcoin address. +# +# Internally, this address is computed by generating an Elements address +# then using the underlying script, called a "claim script", to "tweak" +# the peg witness script. +# +# The claim script is provided by the `getpeginaddress` RPC, but it is +# not necessary to keep, for most usecases, because it is also stored +# in the wallet. +peg_in_addr = e1.getpeginaddress() +assert "mainchain_address" in peg_in_addr +assert "claim_script" in peg_in_addr -# Each call of this takes the pubkeys defined in the config file, adds a random number to them -# that is essetially the hash of the sidechain_address and other information, -# then creates a new P2SH Bitcoin address from that. We reveal that "tweak" to the functionaries -# during `claimpegin`, then they are able to calculate the necessary private key and control -# funds. -addrs = e1.getpeginaddress() +print ("7c. Send Bitcoin to the pegin address") +txid = bitcoin.sendtoaddress(peg_in_addr["mainchain_address"], 10) -#Send funds to unique watchmen P2SH address -txid = bitcoin.sendtoaddress(addrs["mainchain_address"], 1) +print ("7d. Claim the Bitcoin on the sidechain") +# Once the coins are sent on the Bitcoin side, they will not be recognized +# or accepted by the sidechain until they are buried by 100 confirmations. +# This value may be changed by use of the `-peginconfirmationdepth` config +# setting on the Elements daemon. +# +# Bear in mind that this is a consensus rule and all nodes must agree on it. + +# First, try claiming the pegin early. This will fail. +bitcoin.generatetoaddress(1, bitcoin.getnewaddress()) -# Confirmations in Bitcoin are what protects the -# sidechain from becoming fractional reserve during reorgs. -bitcoin.generatetoaddress(101, bitcoin.getnewaddress()) proof = bitcoin.gettxoutproof([txid]) raw = bitcoin.getrawtransaction(txid) +try: + claimtxid = e1.claimpegin(raw, proof, peg_in_addr["claim_script"]) +except JSONRPCException: + pass +else: + raise Exception("Should not be able to claim a pegin early") -# Attempt claim! -claimtxid = e1.claimpegin(raw, proof, addrs["claim_script"]) -sync_all(e1, e2) - -# Other node should accept to mempool and mine +# After 100 blocks, the claim will work. Note that the original proof will +# work, as long as no reorgs changed which block the pegin transaction was +# included in. +bitcoin.generatetoaddress(100, bitcoin.getnewaddress()) +claimtxid = e1.claimpegin(raw, proof, peg_in_addr["claim_script"]) +# Mine this from the other node, to confirm that mempool propagation works +sync_all([e1, e2]) e2.generatetoaddress(1, e1.getnewaddress()) -sync_all(e1, e2) +sync_all([e1, e2]) -# Should see confirmations -"confirmations" in e1.getrawtransaction(claimtxid, 1) +assert "confirmations" in e1.getrawtransaction(claimtxid, 1) +sys.exit(0) -# Pegging Out +print ("7e. Request pegout") +# This burns coins on Liquid using a specially-structured OP_RETURN output. +# In a production network, doing this would trigger the watchman federation +# to send payment to the specified Bitcoin address on the mainchain. +# +# The Bitcoin-side functionality is not supported directly in Elements; +# the watchmen are expected to notice this transaction and send the funds +# from their collective wallet. +e1.sendtomainchain(bitcoin.getnewaddress(), 5) -# This command would trigger watchmen to send payment to Bitcoin address on mainchain -# The Bitcoin-side functionality is not supported directly in Elements. -# The watchmen will notice this transaction and send the funds from their collective -# wallet. -e1.sendtomainchain(bitcoin.getnewaddress(), 10) -#Exercise(s) -#1. Implement really dumb/unsafe watchmen to allow pegouts for learning purposes -# Recover tweak from pegin, add to privkey, combined tweaked pubkeys into a redeemscript, add to Core wallet +## Exercise(s) +# +# 1. Implement really dumb/unsafe watchmen to allow pegouts for learning purposes. +# To custody the coins that users peg in, you need to extract the tweak from +# the pegin claim, use this tweak to adjust the secret keys, and import the +# resulting keys to a Core wallet. +# +# 2. Create an alternate peg from testnet to Liquid, using asset issuance and +# destruction. +# +# 3. Implement a round-robin blocksigner protocol, where each signer takes a turn +# proposing blocks for the others to sign. +# +print ("") +print ("8. Raw transaction demo") # RAW API # Let's create a basic transaction using the raw api, blind it, sign, and send -# Create a transaction with a single destination output to other wallet -rawtx = e1.createrawtransaction([], {e2.getnewaddress():100}) -# Biggest difference compared to Bitcoin is that we have explicit fee outputs -rawtx2 = e1.createrawtransaction([], {e2.getnewaddress():100, e1.getnewaddress():5, "fee":Decimal("0.1")}) -# Fee outputs are unblinded, with a scriptPubKey of "", in other words "" -# scriptPubKeys are unspendable +# Create a transaction with a single destination output to other wallet. +rawtx = e1.createrawtransaction([], [{ e2.getnewaddress(): 100 }]) +# Biggest difference compared to Bitcoin is that we have explicit fee outputs, +# which may be set in `createrawtransaction`. These will be added or adjusted +# by `fundrawtransaction` to make the transaction balance. +rawtx2 = e1.createrawtransaction( + [], + [{ e2.getnewaddress(): 100 }, { e1.getnewaddress() :5 }, { "fee": Decimal("0.1") }], +) +# Fee outputs are unblinded, with a scriptPubKey of "". On Elements, empty +# scriptPubKeys are unspendable. # Next we can fund the transaction (and replaces fee with something more appropriate) fundedtx = e1.fundrawtransaction(rawtx2) # Blind blindedtx = e1.blindrawtransaction(fundedtx["hex"]) -# *Warning*: Raw blinding logic can be quite complicated, requiring the use of `ignoreblindfails` -# to avoid having calls fail without manually inspecting transactions in great detail. -# In general any transaction with 2 or more outputs to blind should succeed, so adding additional -# is one strategy to resolve this. + +# In some cases, such as when there is one blinded output but no blinded inputs, +# blinding will fail. The `ignoreblindfails` option to `blindrawtransaction` may +# be set, in which case the transaction will "successfully" not be blinded. +# +# To unconditionally blind a transaction, ensure that it has 2 or more outputs, +# which will ensure that blinding is (a) useful and (b) mathematically possible. # Sign signedtx = e1.signrawtransactionwithwallet(blindedtx) @@ -575,18 +821,5 @@ signedtx = e1.signrawtransactionwithwallet(blindedtx) # And send txid = e1.sendrawtransaction(signedtx["hex"]) -sync_all(e1, e2) - -e2.gettransaction(txid) - -# ADVANCED OPTIONS -# rawblindrawtransaction : blind a raw transaction with no access to a wallet -# -policyasset= : set network fee asset type to something other than BTC - -bitcoin.stop() -e1.stop() -e2.stop() -time.sleep(2) -shutil.rmtree(e1_datadir) -shutil.rmtree(e2_datadir) +print ("Finished!") diff --git a/contrib/assets_tutorial/bitcoin.conf b/contrib/assets_tutorial/bitcoin.conf index 98d173b6c9..feb6a4afc5 100644 --- a/contrib/assets_tutorial/bitcoin.conf +++ b/contrib/assets_tutorial/bitcoin.conf @@ -2,10 +2,19 @@ regtest=1 txindex=1 # We are spawning these inside a Python script which will manage them, so don't daemonize daemon=0 +# Extra debugging output in case things go wrong +debug=1 +debugexclude=libevent +debugexclude=leveldb +printtoconsole=0 rpcuser=user3 rpcpassword=password3 +# Set a fallback fee, since initially the nodes will have no transaction data +# to do fee estimation from +fallbackfee=0.0002 + [regtest] rpcport=18888 port=18889 diff --git a/contrib/assets_tutorial/elements1.conf b/contrib/assets_tutorial/elements1.conf index fda14400d0..c504ad2977 100644 --- a/contrib/assets_tutorial/elements1.conf +++ b/contrib/assets_tutorial/elements1.conf @@ -12,6 +12,11 @@ daemon=0 listen=1 # Just for looking at random txs txindex=1 +# Extra debugging output in case things go wrong +debug=1 +debugexclude=libevent +debugexclude=leveldb +printtoconsole=0 # This is the script that controls pegged in funds in Bitcoin network # Users will be pegging into a P2SH of this, and the "watchmen" @@ -39,9 +44,11 @@ mainchainrpcpassword=password3 # Free money to make testing easier initialfreecoins=2100000000000000 +# Set a fallback fee, since initially the nodes will have no transaction data +# to do fee estimation from +fallbackfee=0.0002 + [elementsregtest] rpcport=18884 port=18886 -# Over p2p we will only connect to local other elementsd -connect=localhost:18887 diff --git a/contrib/assets_tutorial/elements2.conf b/contrib/assets_tutorial/elements2.conf index 59dd993214..e02a72f477 100644 --- a/contrib/assets_tutorial/elements2.conf +++ b/contrib/assets_tutorial/elements2.conf @@ -7,6 +7,11 @@ rpcpassword=password2 daemon=0 listen=1 txindex=1 +# Extra debugging output in case things go wrong +debug=1 +debugexclude=libevent +debugexclude=leveldb +printtoconsole=0 #fedpegscript=5151ae #signblockscript=5151ae @@ -18,8 +23,11 @@ validatepegin=1 initialfreecoins=2100000000000000 +# Set a fallback fee, since initially the nodes will have no transaction data +# to do fee estimation from +fallbackfee=0.0002 + [elementsregtest] rpcport=18885 port=18887 -connect=localhost:18886