diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -120,6 +120,7 @@ {"importpubkey", 2, "rescan"}, {"importmulti", 0, "requests"}, {"importmulti", 1, "options"}, + {"importdescriptors", 0, "requests"}, {"verifychain", 0, "checklevel"}, {"verifychain", 1, "nblocks"}, {"getblockstats", 0, "hash_or_height"}, diff --git a/src/wallet/rpcdump.cpp b/src/wallet/rpcdump.cpp --- a/src/wallet/rpcdump.cpp +++ b/src/wallet/rpcdump.cpp @@ -1828,6 +1828,420 @@ return response; } +static UniValue ProcessDescriptorImport(CWallet *const pwallet, + const UniValue &data, + const int64_t timestamp) + EXCLUSIVE_LOCKS_REQUIRED(pwallet->cs_wallet) { + UniValue warnings(UniValue::VARR); + UniValue result(UniValue::VOBJ); + + try { + if (!data.exists("desc")) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Descriptor not found."); + } + + const std::string &descriptor = data["desc"].get_str(); + const bool active = + data.exists("active") ? data["active"].get_bool() : false; + const bool internal = + data.exists("internal") ? data["internal"].get_bool() : false; + const std::string &label = + data.exists("label") ? data["label"].get_str() : ""; + + // Parse descriptor string + FlatSigningProvider keys; + std::string error; + auto parsed_desc = + Parse(descriptor, keys, error, /* require_checksum = */ true); + if (!parsed_desc) { + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, error); + } + + // Range check + int64_t range_start = 0, range_end = 1, next_index = 0; + if (!parsed_desc->IsRange() && data.exists("range")) { + throw JSONRPCError( + RPC_INVALID_PARAMETER, + "Range should not be specified for an un-ranged descriptor"); + } else if (parsed_desc->IsRange()) { + if (data.exists("range")) { + auto range = ParseDescriptorRange(data["range"]); + range_start = range.first; + // Specified range end is inclusive, but we need range end as + // exclusive + range_end = range.second + 1; + } else { + warnings.push_back( + "Range not given, using default keypool range"); + range_start = 0; + range_end = gArgs.GetArg("-keypool", DEFAULT_KEYPOOL_SIZE); + } + next_index = range_start; + + if (data.exists("next_index")) { + next_index = data["next_index"].get_int64(); + // bound checks + if (next_index < range_start || next_index >= range_end) { + throw JSONRPCError(RPC_INVALID_PARAMETER, + "next_index is out of range"); + } + } + } + + // Active descriptors must be ranged + if (active && !parsed_desc->IsRange()) { + throw JSONRPCError(RPC_INVALID_PARAMETER, + "Active descriptors must be ranged"); + } + + // Ranged descriptors should not have a label + if (data.exists("range") && data.exists("label")) { + throw JSONRPCError(RPC_INVALID_PARAMETER, + "Ranged descriptors should not have a label"); + } + + // Internal addresses should not have a label either + if (internal && data.exists("label")) { + throw JSONRPCError(RPC_INVALID_PARAMETER, + "Internal addresses should not have a label"); + } + + // Combo descriptor check + if (active && !parsed_desc->IsSingleType()) { + throw JSONRPCError(RPC_WALLET_ERROR, + "Combo descriptors cannot be set to active"); + } + + // If the wallet disabled private keys, abort if private keys exist + if (pwallet->IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS) && + !keys.keys.empty()) { + throw JSONRPCError(RPC_WALLET_ERROR, + "Cannot import private keys to a wallet with " + "private keys disabled"); + } + + // Need to ExpandPrivate to check if private keys are available for all + // pubkeys + FlatSigningProvider expand_keys; + std::vector scripts; + parsed_desc->Expand(0, keys, scripts, expand_keys); + parsed_desc->ExpandPrivate(0, keys, expand_keys); + + // Check if all private keys are provided + bool have_all_privkeys = !expand_keys.keys.empty(); + for (const auto &entry : expand_keys.origins) { + const CKeyID &key_id = entry.first; + CKey key; + if (!expand_keys.GetKey(key_id, key)) { + have_all_privkeys = false; + break; + } + } + + // If private keys are enabled, check some things. + if (!pwallet->IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) { + if (keys.keys.empty()) { + throw JSONRPCError( + RPC_WALLET_ERROR, + "Cannot import descriptor without private keys to a wallet " + "with private keys enabled"); + } + if (!have_all_privkeys) { + warnings.push_back( + "Not all private keys provided. Some wallet functionality " + "may return unexpected errors"); + } + } + + WalletDescriptor w_desc(std::move(parsed_desc), timestamp, range_start, + range_end, next_index); + + // Check if the wallet already contains the descriptor + auto existing_spk_manager = + pwallet->GetDescriptorScriptPubKeyMan(w_desc); + if (existing_spk_manager) { + LOCK(existing_spk_manager->cs_desc_man); + if (range_start > + existing_spk_manager->GetWalletDescriptor().range_start) { + throw JSONRPCError( + RPC_INVALID_PARAMS, + strprintf( + "range_start can only decrease; current range = " + "[%d,%d]", + existing_spk_manager->GetWalletDescriptor().range_start, + existing_spk_manager->GetWalletDescriptor().range_end)); + } + } + + // Add descriptor to the wallet + auto spk_manager = pwallet->AddWalletDescriptor(w_desc, keys, label); + if (spk_manager == nullptr) { + throw JSONRPCError( + RPC_WALLET_ERROR, + strprintf("Could not add descriptor '%s'", descriptor)); + } + + // Set descriptor as active if necessary + if (active) { + if (!w_desc.descriptor->GetOutputType()) { + warnings.push_back( + "Unknown output type, cannot set descriptor to active."); + } else { + pwallet->SetActiveScriptPubKeyMan( + spk_manager->GetID(), *w_desc.descriptor->GetOutputType(), + internal); + } + } + + result.pushKV("success", UniValue(true)); + } catch (const UniValue &e) { + result.pushKV("success", UniValue(false)); + result.pushKV("error", e); + } catch (...) { + result.pushKV("success", UniValue(false)); + + result.pushKV("error", + JSONRPCError(RPC_MISC_ERROR, "Missing required fields")); + } + if (warnings.size()) { + result.pushKV("warnings", warnings); + } + return result; +} + +UniValue importdescriptors(const Config &config, + const JSONRPCRequest &main_request) { + // Acquire the wallet + std::shared_ptr const wallet = + GetWalletForJSONRPCRequest(main_request); + CWallet *const pwallet = wallet.get(); + if (!EnsureWalletIsAvailable(pwallet, main_request.fHelp)) { + return NullUniValue; + } + + RPCHelpMan{ + "importdescriptors", + "\nImport descriptors. This will trigger a rescan of the blockchain " + "based on the earliest timestamp of all descriptors being imported. " + "Requires a new wallet backup.\n" + "\nNote: This call can take over an hour to complete if using an early " + "timestamp; during that time, other rpc calls\n" + "may report that the imported keys, addresses or scripts exist but " + "related transactions are still missing.\n", + { + {"requests", + RPCArg::Type::ARR, + RPCArg::Optional::NO, + "Data to be imported", + { + { + "", + RPCArg::Type::OBJ, + RPCArg::Optional::OMITTED, + "", + { + {"desc", RPCArg::Type::STR, RPCArg::Optional::NO, + "Descriptor to import."}, + {"active", RPCArg::Type::BOOL, /* default */ "false", + "Set this descriptor to be the active descriptor for " + "the corresponding output type/externality"}, + {"range", RPCArg::Type::RANGE, + RPCArg::Optional::OMITTED, + "If a ranged descriptor is used, this specifies the " + "end or the range (in the form [begin,end]) to " + "import"}, + {"next_index", RPCArg::Type::NUM, + RPCArg::Optional::OMITTED, + "If a ranged descriptor is set to active, this " + "specifies the next index to generate addresses " + "from"}, + {"timestamp", + RPCArg::Type::NUM, + RPCArg::Optional::NO, + "Time from which to start rescanning the blockchain " + "for this descriptor, in " + + UNIX_EPOCH_TIME + + "\n" + " " + " Use the string \"now\" to " + "substitute the current synced blockchain time.\n" + " " + " \"now\" can be specified to " + "bypass scanning, for outputs which are known to " + "never have been used, and\n" + " " + " 0 can be specified to scan the " + "entire blockchain. Blocks up to 2 hours before " + "the earliest timestamp\n" + " " + " of all descriptors being imported " + "will be scanned.", + /* oneline_description */ "", + {"timestamp | \"now\"", "integer / string"}}, + {"internal", RPCArg::Type::BOOL, /* default */ "false", + "Whether matching outputs should be treated as not " + "incoming payments (e.g. change)"}, + {"label", RPCArg::Type::STR, /* default */ "''", + "Label to assign to the address, only allowed with " + "internal=false"}, + }, + }, + }, + "\"requests\""}, + }, + RPCResult{RPCResult::Type::ARR, + "", + "Response is an array with the same size as the input that " + "has the execution result", + { + {RPCResult::Type::OBJ, + "", + "", + { + {RPCResult::Type::BOOL, "success", ""}, + {RPCResult::Type::ARR, + "warnings", + /* optional */ true, + "", + { + {RPCResult::Type::STR, "", ""}, + }}, + {RPCResult::Type::OBJ, + "error", + /* optional */ true, + "", + { + {RPCResult::Type::ELISION, "", "JSONRPC error"}, + }}, + }}, + }}, + RPCExamples{ + HelpExampleCli("importdescriptors", + "'[{ \"desc\": \"\", " + "\"timestamp\":1455191478, \"internal\": true }, " + "{ \"desc\": \"\", \"label\": " + "\"example 2\", \"timestamp\": 1455191480 }]'") + + HelpExampleCli( + "importdescriptors", + "'[{ \"desc\": \"\", \"timestamp\":1455191478, " + "\"active\": true, \"range\": [0,100], \"label\": \"\" }]'")}, + } + .Check(main_request); + + // Make sure wallet is a descriptor wallet + if (!pwallet->IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS)) { + throw JSONRPCError( + RPC_WALLET_ERROR, + "importdescriptors is not available for non-descriptor wallets"); + } + + RPCTypeCheck(main_request.params, {UniValue::VARR, UniValue::VOBJ}); + + WalletRescanReserver reserver(*pwallet); + if (!reserver.reserve()) { + throw JSONRPCError( + RPC_WALLET_ERROR, + "Wallet is currently rescanning. Abort existing rescan or wait."); + } + + const UniValue &requests = main_request.params[0]; + const int64_t minimum_timestamp = 1; + int64_t now = 0; + int64_t lowest_timestamp = 0; + bool rescan = false; + UniValue response(UniValue::VARR); + { + LOCK(pwallet->cs_wallet); + EnsureWalletIsUnlocked(pwallet); + + CHECK_NONFATAL(pwallet->chain().findBlock( + pwallet->GetLastBlockHash(), + FoundBlock().time(lowest_timestamp).mtpTime(now))); + + // Get all timestamps and extract the lowest timestamp + for (const UniValue &request : requests.getValues()) { + // This throws an error if "timestamp" doesn't exist + const int64_t timestamp = + std::max(GetImportTimestamp(request, now), minimum_timestamp); + const UniValue result = + ProcessDescriptorImport(pwallet, request, timestamp); + response.push_back(result); + + if (lowest_timestamp > timestamp) { + lowest_timestamp = timestamp; + } + + // If we know the chain tip, and at least one request was successful + // then allow rescan + if (!rescan && result["success"].get_bool()) { + rescan = true; + } + } + pwallet->ConnectScriptPubKeyManNotifiers(); + } + + // Rescan the blockchain using the lowest timestamp + if (rescan) { + int64_t scanned_time = pwallet->RescanFromTime( + lowest_timestamp, reserver, true /* update */); + { + LOCK(pwallet->cs_wallet); + pwallet->ReacceptWalletTransactions(); + } + + if (pwallet->IsAbortingRescan()) { + throw JSONRPCError(RPC_MISC_ERROR, "Rescan aborted by user."); + } + + if (scanned_time > lowest_timestamp) { + std::vector results = response.getValues(); + response.clear(); + response.setArray(); + + // Compose the response + for (unsigned int i = 0; i < requests.size(); ++i) { + const UniValue &request = requests.getValues().at(i); + + // If the descriptor timestamp is within the successfully + // scanned range, or if the import result already has an error + // set, let the result stand unmodified. Otherwise replace the + // result with an error message. + if (scanned_time <= GetImportTimestamp(request, now) || + results.at(i).exists("error")) { + response.push_back(results.at(i)); + } else { + UniValue result = UniValue(UniValue::VOBJ); + result.pushKV("success", UniValue(false)); + result.pushKV( + "error", + JSONRPCError( + RPC_MISC_ERROR, + strprintf( + "Rescan failed for descriptor with timestamp " + "%d. There was an error reading a block from " + "time %d, which is after or within %d seconds " + "of key creation, and could contain " + "transactions pertaining to the desc. As a " + "result, transactions and coins using this " + "desc may not appear in the wallet. This error " + "could be caused by pruning or data corruption " + "(see bitcoind log for details) and could be " + "dealt with by downloading and rescanning the " + "relevant blocks (see -reindex and -rescan " + "options).", + GetImportTimestamp(request, now), + scanned_time - TIMESTAMP_WINDOW - 1, + TIMESTAMP_WINDOW))); + response.push_back(std::move(result)); + } + } + } + } + + return response; +} + // clang-format off static const CRPCCommand commands[] = { // category name actor (function) argNames @@ -1835,6 +2249,7 @@ { "wallet", "abortrescan", abortrescan, {} }, { "wallet", "dumpprivkey", dumpprivkey, {"address"} }, { "wallet", "dumpwallet", dumpwallet, {"filename"} }, + { "wallet", "importdescriptors", importdescriptors, {"requests"} }, { "wallet", "importmulti", importmulti, {"requests","options"} }, { "wallet", "importprivkey", importprivkey, {"privkey","label","rescan"} }, { "wallet", "importwallet", importwallet, {"filename"} }, diff --git a/src/wallet/scriptpubkeyman.h b/src/wallet/scriptpubkeyman.h --- a/src/wallet/scriptpubkeyman.h +++ b/src/wallet/scriptpubkeyman.h @@ -746,6 +746,14 @@ bool AddKey(const CKeyID &key_id, const CKey &key); bool AddCryptedKey(const CKeyID &key_id, const CPubKey &pubkey, const std::vector &crypted_key); + + bool HasWalletDescriptor(const WalletDescriptor &desc) const; + void AddDescriptorKey(const CKey &key, const CPubKey &pubkey); + void WriteDescriptor(); + + const WalletDescriptor GetWalletDescriptor() const + EXCLUSIVE_LOCKS_REQUIRED(cs_desc_man); + const std::vector GetScriptPubKeys() const; }; #endif // BITCOIN_WALLET_SCRIPTPUBKEYMAN_H diff --git a/src/wallet/scriptpubkeyman.cpp b/src/wallet/scriptpubkeyman.cpp --- a/src/wallet/scriptpubkeyman.cpp +++ b/src/wallet/scriptpubkeyman.cpp @@ -1814,6 +1814,16 @@ } } +void DescriptorScriptPubKeyMan::AddDescriptorKey(const CKey &key, + const CPubKey &pubkey) { + LOCK(cs_desc_man); + WalletBatch batch(m_storage.GetDatabase()); + if (!AddDescriptorKeyWithDB(batch, key, pubkey)) { + throw std::runtime_error(std::string(__func__) + + ": writing descriptor private key failed"); + } +} + bool DescriptorScriptPubKeyMan::AddDescriptorKeyWithDB(WalletBatch &batch, const CKey &key, const CPubKey &pubkey) { @@ -2212,3 +2222,36 @@ m_map_crypted_keys[key_id] = make_pair(pubkey, crypted_key); return true; } + +bool DescriptorScriptPubKeyMan::HasWalletDescriptor( + const WalletDescriptor &desc) const { + LOCK(cs_desc_man); + return m_wallet_descriptor.descriptor != nullptr && + desc.descriptor != nullptr && + m_wallet_descriptor.descriptor->ToString() == + desc.descriptor->ToString(); +} + +void DescriptorScriptPubKeyMan::WriteDescriptor() { + LOCK(cs_desc_man); + WalletBatch batch(m_storage.GetDatabase()); + if (!batch.WriteDescriptor(GetID(), m_wallet_descriptor)) { + throw std::runtime_error(std::string(__func__) + + ": writing descriptor failed"); + } +} + +const WalletDescriptor DescriptorScriptPubKeyMan::GetWalletDescriptor() const { + return m_wallet_descriptor; +} + +const std::vector DescriptorScriptPubKeyMan::GetScriptPubKeys() const { + LOCK(cs_desc_man); + std::vector script_pub_keys; + script_pub_keys.reserve(m_map_script_pub_keys.size()); + + for (auto const &script_pub_key : m_map_script_pub_keys) { + script_pub_keys.push_back(script_pub_key.first); + } + return script_pub_keys; +} diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -1529,6 +1529,18 @@ //! Create new DescriptorScriptPubKeyMans and add them to the wallet void SetupDescriptorScriptPubKeyMans(); + + //! Return the DescriptorScriptPubKeyMan for a WalletDescriptor if it is + //! already in the wallet + DescriptorScriptPubKeyMan * + GetDescriptorScriptPubKeyMan(const WalletDescriptor &desc) const; + + //! Add a descriptor to the wallet, return a ScriptPubKeyMan & associated + //! output type + ScriptPubKeyMan * + AddWalletDescriptor(WalletDescriptor &desc, + const FlatSigningProvider &signing_provider, + const std::string &label); }; /** diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -2788,7 +2788,8 @@ // When there are no available providers for the remaining inputs, use the // legacy provider so we can get proper error messages. auto legacy_spk_man = GetLegacyScriptPubKeyMan(); - if (legacy_spk_man->SignTransaction(tx, coins, sighash, input_errors)) { + if (legacy_spk_man && + legacy_spk_man->SignTransaction(tx, coins, sighash, input_errors)) { return true; } @@ -4927,6 +4928,9 @@ void CWallet::SetActiveScriptPubKeyMan(uint256 id, OutputType type, bool internal, bool memonly) { + WalletLogPrintf( + "Setting spkMan to active: id = %s, type = %d, internal = %d\n", + id.ToString(), static_cast(type), static_cast(internal)); auto &spk_mans = internal ? m_internal_spk_managers : m_external_spk_managers; auto spk_man = m_spk_managers.at(id).get(); @@ -4953,3 +4957,99 @@ m_internal_spk_managers.at(OutputType::LEGACY)); return spk_man != nullptr; } + +DescriptorScriptPubKeyMan * +CWallet::GetDescriptorScriptPubKeyMan(const WalletDescriptor &desc) const { + for (auto &spk_man_pair : m_spk_managers) { + // Try to downcast to DescriptorScriptPubKeyMan then check if the + // descriptors match + DescriptorScriptPubKeyMan *spk_manager = + dynamic_cast( + spk_man_pair.second.get()); + if (spk_manager != nullptr && spk_manager->HasWalletDescriptor(desc)) { + return spk_manager; + } + } + + return nullptr; +} + +ScriptPubKeyMan * +CWallet::AddWalletDescriptor(WalletDescriptor &desc, + const FlatSigningProvider &signing_provider, + const std::string &label) { + if (!IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS)) { + WalletLogPrintf( + "Cannot add WalletDescriptor to a non-descriptor wallet\n"); + return nullptr; + } + + LOCK(cs_wallet); + auto new_spk_man = std::unique_ptr( + new DescriptorScriptPubKeyMan(*this, desc)); + + // If we already have this descriptor, remove it from the maps but add the + // existing cache to desc + auto old_spk_man = GetDescriptorScriptPubKeyMan(desc); + if (old_spk_man) { + WalletLogPrintf("Update existing descriptor: %s\n", + desc.descriptor->ToString()); + + { + LOCK(old_spk_man->cs_desc_man); + new_spk_man->SetCache(old_spk_man->GetWalletDescriptor().cache); + } + + // Remove from maps of active spkMans + auto old_spk_man_id = old_spk_man->GetID(); + for (bool internal : {false, true}) { + for (OutputType t : OUTPUT_TYPES) { + auto active_spk_man = GetScriptPubKeyMan(t, internal); + if (active_spk_man && + active_spk_man->GetID() == old_spk_man_id) { + if (internal) { + m_internal_spk_managers.erase(t); + } else { + m_external_spk_managers.erase(t); + } + break; + } + } + } + m_spk_managers.erase(old_spk_man_id); + } + + // Add the private keys to the descriptor + for (const auto &entry : signing_provider.keys) { + const CKey &key = entry.second; + new_spk_man->AddDescriptorKey(key, key.GetPubKey()); + } + + // Top up key pool, the manager will generate new scriptPubKeys internally + new_spk_man->TopUp(); + + // Apply the label if necessary + // Note: we disable labels for ranged descriptors + if (!desc.descriptor->IsRange()) { + auto script_pub_keys = new_spk_man->GetScriptPubKeys(); + if (script_pub_keys.empty()) { + WalletLogPrintf( + "Could not generate scriptPubKeys (cache is empty)\n"); + return nullptr; + } + + CTxDestination dest; + if (ExtractDestination(script_pub_keys.at(0), dest)) { + SetAddressBook(dest, label, "receive"); + } + } + + // Save the descriptor to memory + auto ret = new_spk_man.get(); + m_spk_managers[new_spk_man->GetID()] = std::move(new_spk_man); + + // Save the descriptor to DB + ret->WriteDescriptor(); + + return ret; +} diff --git a/test/functional/test_framework/key.py b/test/functional/test_framework/key.py --- a/test/functional/test_framework/key.py +++ b/test/functional/test_framework/key.py @@ -12,6 +12,8 @@ import hashlib import random +from .address import byte_to_base58 + def modinv(a, n): """Compute the modular inverse of a modulo n @@ -421,3 +423,16 @@ assert pubkey.verify_schnorr(sig, msg32) return sig + + +def bytes_to_wif(b, compressed=True): + if compressed: + b += b'\x01' + return byte_to_base58(b, 239) + + +def generate_wif_key(): + # Makes a WIF privkey for imports + k = ECKey() + k.generate() + return bytes_to_wif(k.get_bytes(), k.is_compressed) diff --git a/test/functional/test_framework/wallet_util.py b/test/functional/test_framework/wallet_util.py --- a/test/functional/test_framework/wallet_util.py +++ b/test/functional/test_framework/wallet_util.py @@ -9,6 +9,10 @@ key_to_p2pkh, script_to_p2sh, ) +from test_framework.key import ( + bytes_to_wif, + ECKey, +) from test_framework.script import ( CScript, OP_2, @@ -49,6 +53,22 @@ p2pkh_addr=key_to_p2pkh(pubkey)) +def get_generate_key(): + """Generate a fresh key + + Returns a named tuple of privkey, pubkey and all address and scripts.""" + eckey = ECKey() + eckey.generate() + privkey = bytes_to_wif(eckey.get_bytes()) + pubkey = eckey.get_pubkey().get_bytes().hex() + pkh = hash160(hex_str_to_bytes(pubkey)) + return Key(privkey=privkey, + pubkey=pubkey, + p2pkh_script=CScript( + [OP_DUP, OP_HASH160, pkh, OP_EQUALVERIFY, OP_CHECKSIG]).hex(), + p2pkh_addr=key_to_p2pkh(pubkey)) + + def get_multisig(node): """Generate a fresh 2-of-3 multisig on node diff --git a/test/functional/wallet_importdescriptors.py b/test/functional/wallet_importdescriptors.py new file mode 100755 --- /dev/null +++ b/test/functional/wallet_importdescriptors.py @@ -0,0 +1,465 @@ +#!/usr/bin/env python3 +# Copyright (c) 2019 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test the importdescriptors RPC. + +Test importdescriptors by generating keys on node0, importing the corresponding +descriptors on node1 and then testing the address info for the different address +variants. + +- `get_generate_key()` is called to generate keys and return the privkeys, + pubkeys and all variants of scriptPubKey and address. +- `test_importdesc()` is called to send an importdescriptors call to node1, test + success, and (if unsuccessful) test the error code and error message returned. +- `test_address()` is called to call getaddressinfo for an address on node1 + and test the values returned.""" + +from test_framework.test_framework import BitcoinTestFramework +from test_framework.descriptors import descsum_create +from test_framework.util import ( + assert_equal, + assert_raises_rpc_error, + find_vout_for_address, +) +from test_framework.wallet_util import ( + get_generate_key, + test_address, +) + + +class ImportDescriptorsTest(BitcoinTestFramework): + def set_test_params(self): + self.num_nodes = 2 + self.extra_args = [[], ["-keypool=5"]] + self.setup_clean_chain = True + + def skip_test_if_missing_module(self): + self.skip_if_no_wallet() + + def test_importdesc(self, req, success, error_code=None, + error_message=None, warnings=None, wallet=None): + """Run importdescriptors and assert success""" + if warnings is None: + warnings = [] + wrpc = self.nodes[1].get_wallet_rpc('w1') + if wallet is not None: + wrpc = wallet + + result = wrpc.importdescriptors([req]) + observed_warnings = [] + if 'warnings' in result[0]: + observed_warnings = result[0]['warnings'] + assert_equal( + "\n".join( + sorted(warnings)), "\n".join( + sorted(observed_warnings))) + assert_equal(result[0]['success'], success) + if error_code is not None: + assert_equal(result[0]['error']['code'], error_code) + assert_equal(result[0]['error']['message'], error_message) + + def run_test(self): + self.log.info('Setting up wallets') + self.nodes[0].createwallet( + wallet_name='w0', + disable_private_keys=False) + w0 = self.nodes[0].get_wallet_rpc('w0') + + self.nodes[1].createwallet( + wallet_name='w1', + disable_private_keys=True, + blank=True, + descriptors=True) + w1 = self.nodes[1].get_wallet_rpc('w1') + assert_equal(w1.getwalletinfo()['keypoolsize'], 0) + + self.nodes[1].createwallet( + wallet_name="wpriv", + disable_private_keys=False, + blank=True, + descriptors=True) + wpriv = self.nodes[1].get_wallet_rpc("wpriv") + assert_equal(wpriv.getwalletinfo()['keypoolsize'], 0) + + self.log.info('Mining coins') + w0.generatetoaddress(101, w0.getnewaddress()) + + # RPC importdescriptors ----------------------------------------------- + + # # Test import fails if no descriptor present + key = get_generate_key() + self.log.info("Import should fail if a descriptor is not provided") + self.test_importdesc({"timestamp": "now"}, + success=False, + error_code=-8, + error_message='Descriptor not found.') + + # # Test importing of a P2PKH descriptor + key = get_generate_key() + self.log.info("Should import a p2pkh descriptor") + self.test_importdesc({"desc": descsum_create("pkh(" + key.pubkey + ")"), + "timestamp": "now", + "label": "Descriptor import test"}, + success=True) + test_address(w1, + key.p2pkh_addr, + solvable=True, + ismine=True, + labels=["Descriptor import test"]) + assert_equal(w1.getwalletinfo()['keypoolsize'], 0) + + self.log.info("Internal addresses cannot have labels") + self.test_importdesc({"desc": descsum_create("pkh(" + key.pubkey + ")"), + "timestamp": "now", + "internal": True, + "label": "Descriptor import test"}, + success=False, + error_code=-8, + error_message="Internal addresses should not have a label") + + assert_equal(w1.getwalletinfo()['keypoolsize'], 0) + + test_address(w1, + key.p2pkh_addr, + ismine=True, + solvable=True) + + # # Test importing of a multisig descriptor + key1 = get_generate_key() + key2 = get_generate_key() + self.log.info("Should import a 1-of-2 bare multisig from descriptor") + self.test_importdesc({"desc": descsum_create("multi(1," + key1.pubkey + "," + key2.pubkey + ")"), + "timestamp": "now"}, + success=True) + self.log.info( + "Should not treat individual keys from the imported bare multisig as watchonly") + test_address(w1, + key1.p2pkh_addr, + ismine=False) + + # # Test ranged descriptors + xpriv = "tprv8ZgxMBicQKsPeuVhWwi6wuMQGfPKi9Li5GtX35jVNknACgqe3CY4g5xgkfDDJcmtF7o1QnxWDRYw4H5P26PXq7sbcUkEqeR4fg3Kxp2tigg" + xpub = "tpubD6NzVbkrYhZ4YNXVQbNhMK1WqguFsUXceaVJKbmno2aZ3B6QfbMeraaYvnBSGpV3vxLyTTK9DYT1yoEck4XUScMzXoQ2U2oSmE2JyMedq3H" + addresses = [ + "2N7yv4p8G8yEaPddJxY41kPihnWvs39qCMf", + "2MsHxyb2JS3pAySeNUsJ7mNnurtpeenDzLA"] # hdkeypath=m/0'/0'/0' and 1' + # wpkh subscripts corresponding to the above addresses + addresses += ["bchreg:prvn9ycvgr5atuyh49sua3mapskh2mnnzg34lqtyst", + "bchreg:pp3n087yx0njv2e5wcvltahfxqst7l66ruyuaun8qt"] + desc = "sh(pkh(" + xpub + "/0/0/*" + "))" + + self.log.info("Ranged descriptors cannot have labels") + self.test_importdesc({"desc": descsum_create(desc), + "timestamp": "now", + "range": [0, 100], + "label": "test"}, + success=False, + error_code=-8, + error_message='Ranged descriptors should not have a label') + + self.log.info("Private keys required for private keys enabled wallet") + self.test_importdesc({"desc": descsum_create(desc), + "timestamp": "now", + "range": [0, 100]}, + success=False, + error_code=-4, + error_message='Cannot import descriptor without private keys to a wallet with private keys enabled', + wallet=wpriv) + + self.log.info( + "Ranged descriptor import should warn without a specified range") + self.test_importdesc({"desc": descsum_create(desc), + "timestamp": "now"}, + success=True, + warnings=['Range not given, using default keypool range']) + assert_equal(w1.getwalletinfo()['keypoolsize'], 0) + + # # Test importing of a ranged descriptor with xpriv + self.log.info( + "Should not import a ranged descriptor that includes xpriv into a watch-only wallet") + desc = "sh(pkh(" + xpriv + "/0'/0'/*'" + "))" + self.test_importdesc({"desc": descsum_create(desc), + "timestamp": "now", + "range": 1}, + success=False, + error_code=-4, + error_message='Cannot import private keys to a wallet with private keys disabled') + + for address in addresses: + test_address(w1, + address, + ismine=False, + solvable=False) + + self.test_importdesc({"desc": descsum_create(desc), "timestamp": "now", "range": -1}, + success=False, error_code=-8, error_message='End of range is too high') + + self.test_importdesc({"desc": descsum_create(desc), "timestamp": "now", "range": [-1, 10]}, + success=False, error_code=-8, error_message='Range should be greater or equal than 0') + + self.test_importdesc({"desc": descsum_create(desc), "timestamp": "now", "range": [(2 << 31 + 1) - 1000000, (2 << 31 + 1)]}, + success=False, error_code=-8, error_message='End of range is too high') + + self.test_importdesc({"desc": descsum_create(desc), "timestamp": "now", "range": [2, 1]}, + success=False, error_code=-8, error_message='Range specified as [begin,end] must not have begin after end') + + self.test_importdesc({"desc": descsum_create(desc), "timestamp": "now", "range": [0, 1000001]}, + success=False, error_code=-8, error_message='Range is too large') + + # Make sure ranged imports import keys in order + w1 = self.nodes[1].get_wallet_rpc('w1') + self.log.info('Key ranges should be imported in order') + xpub = "tpubDAXcJ7s7ZwicqjprRaEWdPoHKrCS215qxGYxpusRLLmJuT69ZSicuGdSfyvyKpvUNYBW1s2U3NSrT6vrCYB9e6nZUEvrqnwXPF8ArTCRXMY" + addresses = [ + 'bchreg:qp0v86h53rc92hjrlpwzpjtdlgzsxu25svryj39hul', # m/0'/0'/0 + 'bchreg:qqasy0zlkdleqt4pkn8fs4ehm5gnnz6qpgzxm0035q', # m/0'/0'/1 + 'bchreg:qp0sp4wlhctvprqvdt2dgvqcfdjssu04xgk64mmwew', # m/0'/0'/2 + 'bchreg:qrhn24tegn04cptfv4ldhtkduxq55zcwryhvnfcm3r', # m/0'/0'/3 + 'bchreg:qzpqhett2uwltq803vrxv7zkqhft5vsnmca8ds9jjp', # m/0'/0'/4 + ] + + self.test_importdesc({'desc': descsum_create('sh(pkh([abcdef12/0h/0h]' + xpub + '/*))'), + 'active': True, + 'range': [0, 2], + 'timestamp': 'now' + }, + success=True) + self.test_importdesc({'desc': descsum_create('pkh([12345678/0h/0h]' + xpub + '/*)'), + 'active': True, + 'range': [0, 2], + 'timestamp': 'now' + }, + success=True) + + assert_equal(w1.getwalletinfo()['keypoolsize'], 5) + for i, expected_addr in enumerate(addresses): + pkh_addr = w1.getnewaddress('') + assert_raises_rpc_error(-4, + 'This wallet has no available keys', + w1.getrawchangeaddress) + + assert_equal(pkh_addr, expected_addr) + pkh_addr_info = w1.getaddressinfo(pkh_addr) + assert_equal(pkh_addr_info['desc'][:22], + 'pkh([12345678/0\'/0\'/{}]'.format(i)) + + # After retrieving a key, we don't refill the keypool again, so + # it's one less for each address type + assert_equal(w1.getwalletinfo()['keypoolsize'], 4) + w1.keypoolrefill() + assert_equal(w1.getwalletinfo()['keypoolsize'], 5) + + # Check active=False default + self.log.info('Check imported descriptors are not active by default') + self.test_importdesc({'desc': descsum_create('pkh([12345678/0h/0h]' + xpub + '/*)'), + 'range': [0, 2], + 'timestamp': 'now', + 'internal': True + }, + success=True) + assert_raises_rpc_error(-4, + 'This wallet has no available keys', + w1.getrawchangeaddress) + + # # Test importing a descriptor containing a WIF private key + wif_priv = "cTe1f5rdT8A8DFgVWTjyPwACsDPJM9ff4QngFxUixCSvvbg1x6sh" + address = "bchreg:ppn85zpvym8cdccmgw8km6e48jfhnpa435c0djwhs6" + desc = "sh(pkh(" + wif_priv + "))" + self.log.info( + "Should import a descriptor with a WIF private key as spendable") + self.test_importdesc({"desc": descsum_create(desc), + "timestamp": "now"}, + success=True, + wallet=wpriv) + test_address(wpriv, + address, + solvable=True, + ismine=True) + txid = w0.sendtoaddress(address, 49.99999600) + w0.generatetoaddress(6, w0.getnewaddress()) + self.sync_blocks() + tx = wpriv.createrawtransaction([{"txid": txid, "vout": 0}], { + w0.getnewaddress(): 49.999}) + signed_tx = wpriv.signrawtransactionwithwallet(tx) + w1.sendrawtransaction(signed_tx['hex']) + + # Make sure that we can use import and use multisig as addresses + self.log.info( + 'Test that multisigs can be imported, signed for, and getnewaddress\'d') + self.nodes[1].createwallet( + wallet_name="wmulti_priv", + disable_private_keys=False, + blank=True, + descriptors=True) + wmulti_priv = self.nodes[1].get_wallet_rpc("wmulti_priv") + assert_equal(wmulti_priv.getwalletinfo()['keypoolsize'], 0) + + self.test_importdesc({"desc": "sh(multi(2,tprv8ZgxMBicQKsPevADjDCWsa6DfhkVXicu8NQUzfibwX2MexVwW4tCec5mXdCW8kJwkzBRRmAay1KZya4WsehVvjTGVW6JLqiqd8DdZ4xSg52/84h/0h/0h/*,tprv8ZgxMBicQKsPdSNWUhDiwTScDr6JfkZuLshTRwzvZGnMSnGikV6jxpmdDkC3YRc4T3GD6Nvg9uv6hQg73RVv1EiTXDZwxVbsLugVHU8B1aq/84h/0h/0h/*,tprv8ZgxMBicQKsPeonDt8Ka2mrQmHa61hQ5FQCsvWBTpSNzBFgM58cV2EuXNAHF14VawVpznnme3SuTbA62sGriwWyKifJmXntfNeK7zeqMCj1/84h/0h/0h/*))#f5nqn4ax", + "active": True, + "range": 1000, + "next_index": 0, + "timestamp": "now"}, + success=True, + wallet=wmulti_priv) + self.test_importdesc({"desc": "sh(multi(2,tprv8ZgxMBicQKsPevADjDCWsa6DfhkVXicu8NQUzfibwX2MexVwW4tCec5mXdCW8kJwkzBRRmAay1KZya4WsehVvjTGVW6JLqiqd8DdZ4xSg52/84h/1h/0h/*,tprv8ZgxMBicQKsPdSNWUhDiwTScDr6JfkZuLshTRwzvZGnMSnGikV6jxpmdDkC3YRc4T3GD6Nvg9uv6hQg73RVv1EiTXDZwxVbsLugVHU8B1aq/84h/1h/0h/*,tprv8ZgxMBicQKsPeonDt8Ka2mrQmHa61hQ5FQCsvWBTpSNzBFgM58cV2EuXNAHF14VawVpznnme3SuTbA62sGriwWyKifJmXntfNeK7zeqMCj1/84h/1h/0h/*))#m4e4s5de", + "active": True, + "internal": True, + "range": 1000, + "next_index": 0, + "timestamp": "now"}, + success=True, + wallet=wmulti_priv) + + # Range end (1000) is inclusive, so 1001 addresses generated + assert_equal(wmulti_priv.getwalletinfo()['keypoolsize'], 1001) + addr = wmulti_priv.getnewaddress('') + # Derived at m/84'/0'/0'/0 + assert_equal( + addr, + 'bchreg:pzkcf26dw7np58jcspnpxaupgz9csnc3wsx25fa5q3') + change_addr = wmulti_priv.getrawchangeaddress() + assert_equal( + change_addr, + 'bchreg:prnkfg7pxe3kpyv3l4v00ft6q3sfseag7vuj8tutcn') + + assert_equal(wmulti_priv.getwalletinfo()['keypoolsize'], 1000) + txid = w0.sendtoaddress(addr, 10) + self.nodes[0].generate(6) + + self.nodes[0].generate(6) + self.sync_all() + + self.nodes[1].createwallet( + wallet_name="wmulti_pub", + disable_private_keys=True, + blank=True, + descriptors=True) + wmulti_pub = self.nodes[1].get_wallet_rpc("wmulti_pub") + assert_equal(wmulti_pub.getwalletinfo()['keypoolsize'], 0) + + self.test_importdesc({"desc": "sh(multi(2,[7b2d0242/84h/0h/0h]tpubDCJtdt5dgJpdhW4MtaVYDhG4T4tF6jcLR1PxL43q9pq1mxvXgMS9Mzw1HnXG15vxUGQJMMSqCQHMTy3F1eW5VkgVroWzchsPD5BUojrcWs8/*,[59b09cd6/84h/0h/0h]tpubDDBF2BTR6s8drwrfDei8WxtckGuSm1cyoKxYY1QaKSBFbHBYQArWhHPA6eJrzZej6nfHGLSURYSLHr7GuYch8aY5n61tGqgn8b4cXrMuoPH/*,[e81a0532/84h/0h/0h]tpubDCsWoW1kuQB9kG5MXewHqkbjPtqPueRnXju7uM2NK7y3JYb2ajAZ9EiuZXNNuE4661RAfriBWhL8UsnAPpk8zrKKnZw1Ug7X4oHgMdZiU4E/*))#x75vpsak", + "active": True, + "range": 1000, + "next_index": 0, + "timestamp": "now"}, + success=True, + wallet=wmulti_pub) + self.test_importdesc({"desc": "sh(multi(2,[7b2d0242/84h/1h/0h]tpubDCXqdwWZcszwqYJSnZp8eARkxGJfHAk23KDxbztV4BbschfaTfYLTcSkSJ3TN64dRqwa1rnFUScsYormKkGqNbbPwkorQimVevXjxzUV9Gf/*,[59b09cd6/84h/1h/0h]tpubDCYfZY2ceyHzYzMMVPt9MNeiqtQ2T7Uyp9QSFwYXh8Vi9iJFYXcuphJaGXfF3jUQJi5Y3GMNXvM11gaL4txzZgNGK22BFAwMXynnzv4z2Jh/*,[e81a0532/84h/1h/0h]tpubDC6UGqnsQStngYuGD4MKsMy7eD1Yg9NTJfPdvjdG2JE5oZ7EsSL3WHg4Gsw2pR5K39ZwJ46M1wZayhedVdQtMGaUhq5S23PH6fnENK3V1sb/*))#v0t48ucu", + "active": True, + "internal": True, + "range": 1000, + "next_index": 0, + "timestamp": "now"}, + success=True, + wallet=wmulti_pub) + + # The first one was already consumed by previous import and is detected + # as used + assert_equal(wmulti_pub.getwalletinfo()['keypoolsize'], 1000) + addr = wmulti_pub.getnewaddress('') + # Derived at m/84'/0'/0'/1 + assert_equal( + addr, + 'bchreg:pr5xql8r03jp5dvrep22dns59vf7hhykr5u98cj6hh') + change_addr = wmulti_pub.getrawchangeaddress() + assert_equal( + change_addr, + 'bchreg:prnkfg7pxe3kpyv3l4v00ft6q3sfseag7vuj8tutcn') + + assert_equal(wmulti_pub.getwalletinfo()['keypoolsize'], 999) + txid = w0.sendtoaddress(addr, 10) + vout = find_vout_for_address(self.nodes[0], txid, addr) + self.nodes[0].generate(6) + self.sync_all() + assert_equal(wmulti_pub.getbalance(), wmulti_priv.getbalance()) + + self.log.info("Multisig with distributed keys") + self.nodes[1].createwallet( + wallet_name="wmulti_priv1", + descriptors=True) + wmulti_priv1 = self.nodes[1].get_wallet_rpc("wmulti_priv1") + res = wmulti_priv1.importdescriptors([ + { + "desc": descsum_create("sh(multi(2,tprv8ZgxMBicQKsPevADjDCWsa6DfhkVXicu8NQUzfibwX2MexVwW4tCec5mXdCW8kJwkzBRRmAay1KZya4WsehVvjTGVW6JLqiqd8DdZ4xSg52/84h/0h/0h/*,[59b09cd6/84h/0h/0h]tpubDDBF2BTR6s8drwrfDei8WxtckGuSm1cyoKxYY1QaKSBFbHBYQArWhHPA6eJrzZej6nfHGLSURYSLHr7GuYch8aY5n61tGqgn8b4cXrMuoPH/*,[e81a0532/84h/0h/0h]tpubDCsWoW1kuQB9kG5MXewHqkbjPtqPueRnXju7uM2NK7y3JYb2ajAZ9EiuZXNNuE4661RAfriBWhL8UsnAPpk8zrKKnZw1Ug7X4oHgMdZiU4E/*))"), + "active": True, + "range": 1000, + "next_index": 0, + "timestamp": "now" + }, + { + "desc": descsum_create("sh(multi(2,tprv8ZgxMBicQKsPevADjDCWsa6DfhkVXicu8NQUzfibwX2MexVwW4tCec5mXdCW8kJwkzBRRmAay1KZya4WsehVvjTGVW6JLqiqd8DdZ4xSg52/84h/1h/0h/*,[59b09cd6/84h/1h/0h]tpubDCYfZY2ceyHzYzMMVPt9MNeiqtQ2T7Uyp9QSFwYXh8Vi9iJFYXcuphJaGXfF3jUQJi5Y3GMNXvM11gaL4txzZgNGK22BFAwMXynnzv4z2Jh/*,[e81a0532/84h/1h/0h]tpubDC6UGqnsQStngYuGD4MKsMy7eD1Yg9NTJfPdvjdG2JE5oZ7EsSL3WHg4Gsw2pR5K39ZwJ46M1wZayhedVdQtMGaUhq5S23PH6fnENK3V1sb/*))"), + "active": True, + "internal": True, + "range": 1000, + "next_index": 0, + "timestamp": "now" + }]) + assert_equal(res[0]['success'], True) + assert_equal( + res[0]['warnings'][0], + 'Not all private keys provided. Some wallet functionality may return unexpected errors') + assert_equal(res[1]['success'], True) + assert_equal( + res[1]['warnings'][0], + 'Not all private keys provided. Some wallet functionality may return unexpected errors') + + self.nodes[1].createwallet( + wallet_name='wmulti_priv2', + blank=True, + descriptors=True) + wmulti_priv2 = self.nodes[1].get_wallet_rpc('wmulti_priv2') + res = wmulti_priv2.importdescriptors([ + { + "desc": descsum_create("sh(multi(2,[7b2d0242/84h/0h/0h]tpubDCJtdt5dgJpdhW4MtaVYDhG4T4tF6jcLR1PxL43q9pq1mxvXgMS9Mzw1HnXG15vxUGQJMMSqCQHMTy3F1eW5VkgVroWzchsPD5BUojrcWs8/*,tprv8ZgxMBicQKsPdSNWUhDiwTScDr6JfkZuLshTRwzvZGnMSnGikV6jxpmdDkC3YRc4T3GD6Nvg9uv6hQg73RVv1EiTXDZwxVbsLugVHU8B1aq/84h/0h/0h/*,[e81a0532/84h/0h/0h]tpubDCsWoW1kuQB9kG5MXewHqkbjPtqPueRnXju7uM2NK7y3JYb2ajAZ9EiuZXNNuE4661RAfriBWhL8UsnAPpk8zrKKnZw1Ug7X4oHgMdZiU4E/*))"), + "active": True, + "range": 1000, + "next_index": 0, + "timestamp": "now" + }, + { + "desc": descsum_create("sh(multi(2,[7b2d0242/84h/1h/0h]tpubDCXqdwWZcszwqYJSnZp8eARkxGJfHAk23KDxbztV4BbschfaTfYLTcSkSJ3TN64dRqwa1rnFUScsYormKkGqNbbPwkorQimVevXjxzUV9Gf/*,tprv8ZgxMBicQKsPdSNWUhDiwTScDr6JfkZuLshTRwzvZGnMSnGikV6jxpmdDkC3YRc4T3GD6Nvg9uv6hQg73RVv1EiTXDZwxVbsLugVHU8B1aq/84h/1h/0h/*,[e81a0532/84h/1h/0h]tpubDC6UGqnsQStngYuGD4MKsMy7eD1Yg9NTJfPdvjdG2JE5oZ7EsSL3WHg4Gsw2pR5K39ZwJ46M1wZayhedVdQtMGaUhq5S23PH6fnENK3V1sb/*))"), + "active": True, + "internal": True, + "range": 1000, + "next_index": 0, + "timestamp": "now" + }]) + assert_equal(res[0]['success'], True) + assert_equal( + res[0]['warnings'][0], + 'Not all private keys provided. Some wallet functionality may return unexpected errors') + assert_equal(res[1]['success'], True) + assert_equal( + res[1]['warnings'][0], + 'Not all private keys provided. Some wallet functionality may return unexpected errors') + + rawtx = self.nodes[1].createrawtransaction( + [{'txid': txid, 'vout': vout}], {w0.getnewaddress(): 9.999}) + tx_signed_1 = wmulti_priv1.signrawtransactionwithwallet(rawtx) + assert_equal(tx_signed_1['complete'], False) + tx_signed_2 = wmulti_priv2.signrawtransactionwithwallet( + tx_signed_1['hex']) + assert_equal(tx_signed_2['complete'], True) + self.nodes[1].sendrawtransaction(tx_signed_2['hex']) + + self.log.info("Combo descriptors cannot be active") + self.test_importdesc({"desc": descsum_create("combo(tpubDCJtdt5dgJpdhW4MtaVYDhG4T4tF6jcLR1PxL43q9pq1mxvXgMS9Mzw1HnXG15vxUGQJMMSqCQHMTy3F1eW5VkgVroWzchsPD5BUojrcWs8/*)"), + "active": True, + "range": 1, + "timestamp": "now"}, + success=False, + error_code=-4, + error_message="Combo descriptors cannot be set to active") + + self.log.info("Descriptors with no type cannot be active") + self.test_importdesc({"desc": descsum_create("pk(tpubDCJtdt5dgJpdhW4MtaVYDhG4T4tF6jcLR1PxL43q9pq1mxvXgMS9Mzw1HnXG15vxUGQJMMSqCQHMTy3F1eW5VkgVroWzchsPD5BUojrcWs8/*)"), + "active": True, + "range": 1, + "timestamp": "now"}, + success=True, + warnings=["Unknown output type, cannot set descriptor to active."]) + + +if __name__ == '__main__': + ImportDescriptorsTest().main()